feat: explorer file download via the host route; editor line numbers

- explorer row menu gains a download action (files only); the host route
  serves raw bytes with Content-Disposition: attachment, binary-safe
- editor enables line numbers
This commit is contained in:
Menghuan1918
2026-08-09 22:07:03 +08:00
parent e2ee427ea3
commit 9b153844e9
5 changed files with 49 additions and 14 deletions
+2 -1
View File
@@ -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' }),
+30 -10
View File
@@ -7,15 +7,16 @@
* Row actions: hovering a row reveals an @-reference button on the far
* right (appends `@<relative path>` 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<string | null>(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 ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
<span className={css.explorerName}>{entry.name}</span>
@@ -172,7 +184,7 @@ export function ExplorerView(props: {
onOpenFile(entry.path)
}
}}
onContextMenu={(event) => { openRowMenu(event, entry.path) }}
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
>
<IconCodeOutline16 size={14} />
<span className={css.explorerName}>{entry.name}</span>
@@ -208,7 +220,7 @@ export function ExplorerView(props: {
<div
className={css.explorerRow}
style={{ paddingLeft: 6 }}
onContextMenu={(event) => { openRowMenu(event, root) }}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<IconFolderOpen16 size={14} />
<span className={css.explorerName}>{baseName(root)}</span>
@@ -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: <IconDownloadOutline16 size={14} /> }]
: []),
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
]}
@@ -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,
+12
View File
@@ -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()}`
}
+2
View File
@@ -69,6 +69,7 @@ const zh = {
referenceFile: '@文件',
copyRelative: '复制相对地址',
copyAbsolute: '复制绝对地址',
download: '下载',
}
const en: Record<keyof typeof zh, string> = {
@@ -135,6 +136,7 @@ const en: Record<keyof typeof zh, string> = {
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). */
+3 -3
View File
@@ -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;
}