diff --git a/src/client/EditorView.tsx b/src/client/EditorView.tsx index b446920..9db7654 100644 --- a/src/client/EditorView.tsx +++ b/src/client/EditorView.tsx @@ -10,7 +10,7 @@ import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' import { EditorState } from '@codemirror/state' -import { EditorView as CodeMirrorView, keymap } from '@codemirror/view' +import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import { api, mediaUrl, type SessionScope } from './api.ts' @@ -98,6 +98,7 @@ export function EditorView(props: { scope: SessionScope; path: string; title: st doc: load.content, extensions: [ CodeMirrorView.lineWrapping, + lineNumbers(), history(), EditorState.tabSize.of(2), CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }), diff --git a/src/client/ExplorerView.tsx b/src/client/ExplorerView.tsx index 3c0d869..d0eb593 100644 --- a/src/client/ExplorerView.tsx +++ b/src/client/ExplorerView.tsx @@ -7,15 +7,16 @@ * Row actions: hovering a row reveals an @-reference button on the far * right (appends `@` to the composer draft), and right-click * opens a context menu to copy the relative or absolute path (with a brief - * "copied" label replacing the button after a successful write). + * "copied" label replacing the button after a successful write); file rows + * also offer a download action (the host serves raw bytes, binary-safe). */ import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { - IconCodeOutline16, IconCopyOutline16, IconFolderClose16, IconFolderOpen16, + IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, Menu, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' -import { api, type FsEntry } from './api.ts' +import { api, downloadUrl, type FsEntry } from './api.ts' import { relativeTo } from './paths.ts' import { t } from './locales.ts' import css from './sidebar.module.css' @@ -50,8 +51,8 @@ export function ExplorerView(props: { const [refreshTick, setRefreshTick] = useState(0) /** The row whose path was just copied ("copied" label replaces its button). */ const [copiedPath, setCopiedPath] = useState(null) - /** Open context menu: the row path plus the cursor position. */ - const [rowMenu, setRowMenu] = useState<{ path: string; x: number; y: number } | null>(null) + /** Open context menu: the row path (and whether it is a directory) plus the cursor position. */ + const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null) const storeLevel = useCallback((path: string, level: LevelData) => { dataRef.current = { ...dataRef.current, [path]: level } @@ -109,10 +110,21 @@ export function ExplorerView(props: { ) } - const openRowMenu = (event: MouseEvent, path: string): void => { + const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => { event.preventDefault() event.stopPropagation() - setRowMenu({ path, x: event.clientX, y: event.clientY }) + setRowMenu({ path, isDir, x: event.clientX, y: event.clientY }) + } + + /** Download a file through the host route (raw bytes, binary-safe). */ + const downloadFile = (path: string): void => { + const url = downloadUrl({ sessionId, cwd }, path) + const anchor = document.createElement('a') + anchor.href = url + anchor.style.display = 'none' + document.body.appendChild(anchor) + anchor.click() + anchor.remove() } const root = cwd @@ -147,7 +159,7 @@ export function ExplorerView(props: { onToggle(entry.path) } }} - onContextMenu={(event) => { openRowMenu(event, entry.path) }} + onContextMenu={(event) => { openRowMenu(event, entry.path, true) }} > {isOpen ? : } {entry.name} @@ -172,7 +184,7 @@ export function ExplorerView(props: { onOpenFile(entry.path) } }} - onContextMenu={(event) => { openRowMenu(event, entry.path) }} + onContextMenu={(event) => { openRowMenu(event, entry.path, false) }} > {entry.name} @@ -208,7 +220,7 @@ export function ExplorerView(props: {
{ openRowMenu(event, root) }} + onContextMenu={(event) => { openRowMenu(event, root, true) }} > {baseName(root)} @@ -241,6 +253,10 @@ export function ExplorerView(props: { open={rowMenu !== null} onClose={() => { setRowMenu(null) }} items={[ + // Download applies to files only (the host route refuses directories). + ...(rowMenu?.isDir === false + ? [{ id: 'download', label: t('download'), icon: }] + : []), { id: 'relative', label: t('copyRelative'), icon: }, { id: 'absolute', label: t('copyAbsolute'), icon: }, ]} @@ -248,6 +264,10 @@ export function ExplorerView(props: { const target = rowMenu if (target === null) return setRowMenu(null) + if (id === 'download') { + downloadFile(target.path) + return + } copyPath( id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path, target.path, diff --git a/src/client/api.ts b/src/client/api.ts index 8492a04..f0c4c91 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -123,7 +123,19 @@ export const api = { /** Absolute URL of the media route for one path (images only). */ export function mediaUrl(scope: SessionScope, path: string): string { + return fileUrl(scope, path, false) +} + +/** Absolute URL of the download route: serves raw bytes (binary-safe) with + * `Content-Disposition: attachment`, so the browser saves the file. */ +export function downloadUrl(scope: SessionScope, path: string): string { + return fileUrl(scope, path, true) +} + +/** Shared URL builder for the /sidebar/file route (media vs download). */ +function fileUrl(scope: SessionScope, path: string, download: boolean): string { const params = new URLSearchParams({ sessionId: scope.sessionId, path }) if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd) + if (download) params.set('download', '1') return `/sidebar/file?${params.toString()}` } diff --git a/src/client/locales.ts b/src/client/locales.ts index bdf80d7..a228d16 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -69,6 +69,7 @@ const zh = { referenceFile: '@文件', copyRelative: '复制相对地址', copyAbsolute: '复制绝对地址', + download: '下载', } const en: Record = { @@ -135,6 +136,7 @@ const en: Record = { referenceFile: '@file', copyRelative: 'Copy relative path', copyAbsolute: 'Copy absolute path', + download: 'Download', } /** Translate a copy key in the browser's language (zh → zh, else en). */ diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index b29eebc..f13d580 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -182,18 +182,18 @@ flex: none; z-index: 3; touch-action: none; - background: transparent; + background: var(--dsw-alias-border-l2); transition: background var(--ds-transition-duration-slow) var(--ds-ease-in-out); } .dividerRow { - width: 5px; + width: 7px; margin: 0 -2px; cursor: col-resize; } .dividerCol { - height: 5px; + height: 7px; margin: -2px 0; cursor: row-resize; }