diff --git a/README.md b/README.md index 5ac894f..d272cce 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件:文件资 - 懒加载目录树,根目录 = 当前会话工作目录(cwd) - 目录优先排序、隐藏文件(点开头)变暗显示、每层 1000 项上限 - 点击文件在侧边栏打开编辑器;刷新按钮重载 +- **右键行**弹出菜单:复制相对地址 / 复制绝对地址(复制成功后行内短暂显示"已复制") +- **悬浮行尾的 `@文件` 按钮**:点击把 `@相对路径` 追加到输入框草稿(如 `@src/main.ts`) ### 文件编辑与预览(Editor) - **CodeMirror 6** 编辑器:**自动换行** + 按扩展名**语法高亮**(js/ts/jsx/tsx、json、python、html、css、xml、yaml、sql、java、c/c++、rust、go、php、shell、toml、nginx、dockerfile、properties…),**明暗主题自适应**(跟随应用色系,切换时文档/撤销/滚动不丢) @@ -33,6 +35,7 @@ DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件:文件资 ### Tab 栏 - Tab 最大宽度 160px、横向滚动;**`+` 按钮紧贴最右 Tab**(溢出时吸附右缘) +- **Tab 与 `+` 菜单均带类型图标**(Explorer=文件夹、Git=分支、终端=终端、编辑器=代码文件) - `+` 菜单新建 Tab(Explorer / Git / 终端,终端受 3 个上限约束);**中键点击关闭 Tab** - 空分栏不再显示空提示,而是**可打开类型卡片**(点击即开) diff --git a/src/client/ExplorerView.tsx b/src/client/ExplorerView.tsx index 84a47ba..3c0d869 100644 --- a/src/client/ExplorerView.tsx +++ b/src/client/ExplorerView.tsx @@ -3,13 +3,20 @@ * working directory. Levels load on expansion (one API call per directory), * directories sort first, hidden entries render dimmed, and the expansion * set lives in the per-session state. Clicking a file opens an editor tab. + * + * 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). */ -import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react' import clsx from 'clsx' import { - IconCodeOutline16, IconFolderClose16, IconFolderOpen16, IconRefreshOutline16, + IconCodeOutline16, IconCopyOutline16, IconFolderClose16, IconFolderOpen16, + IconRefreshOutline16, Menu, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import { api, type FsEntry } from './api.ts' +import { relativeTo } from './paths.ts' import { t } from './locales.ts' import css from './sidebar.module.css' @@ -25,17 +32,26 @@ function baseName(path: string): string { return at === -1 ? trimmed : trimmed.slice(at + 1) } +/** How long the row's "copied" label stays after a successful write. */ +const COPIED_MS = 1200 + export function ExplorerView(props: { sessionId: string cwd: string | undefined expanded: string[] onToggle: (path: string) => void onOpenFile: (path: string) => void + /** Insert `@` into the composer draft. */ + onReferenceFile: (path: string) => void }) { - const { sessionId, cwd, expanded, onToggle, onOpenFile } = props + const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props const [data, setData] = useState>({}) const dataRef = useRef(data) 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) const storeLevel = useCallback((path: string, level: LevelData) => { dataRef.current = { ...dataRef.current, [path]: level } @@ -61,6 +77,44 @@ export function ExplorerView(props: { for (const dir of expanded) loadDir(dir) }, [cwd, expanded, refreshTick, loadDir]) + /** Copy `text`; on success flip the row's copied label for a moment. */ + const copyPath = useCallback((text: string, path: string): void => { + void writeClipboard(text).then((ok) => { + if (!ok) return + setCopiedPath(path) + window.setTimeout(() => { + setCopiedPath(current => current === path ? null : current) + }, COPIED_MS) + }) + }, []) + + /** The row's trailing actions: the @-reference button, or the copied label. */ + const rowActions = (entry: FsEntry): ReactNode => { + if (copiedPath === entry.path) { + return {t('copied')} + } + return ( + + ) + } + + const openRowMenu = (event: MouseEvent, path: string): void => { + event.preventDefault() + event.stopPropagation() + setRowMenu({ path, x: event.clientX, y: event.clientY }) + } + const root = cwd const renderLevel = (dir: string, depth: number): ReactNode => { @@ -81,31 +135,49 @@ export function ExplorerView(props: { const isOpen = expanded.includes(entry.path) return (
- + {rowActions(entry)} +
{isOpen && renderLevel(entry.path, depth + 1)} ) } return ( - + {rowActions(entry)} + ) }) } @@ -133,14 +205,59 @@ export function ExplorerView(props: {
{t('noSession')}
) : ( <> -
+
{ openRowMenu(event, root) }} + > {baseName(root)} + {copiedPath === root + ? {t('copied')} + : ( + + )}
{data[root] !== undefined && renderLevel(root, 1)} )}
+ {/* + The one shared context menu, positioned at the right-click cursor + (portal so the explorer's overflow clip cannot crop it). + */} + { setRowMenu(null) }} + items={[ + { id: 'relative', label: t('copyRelative'), icon: }, + { id: 'absolute', label: t('copyAbsolute'), icon: }, + ]} + onSelect={(id) => { + const target = rowMenu + if (target === null) return + setRowMenu(null) + copyPath( + id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path, + target.path, + ) + }} + portal + align="start" + getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))} + anchor={} + /> ) } diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index d7b8a07..a00a301 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -22,6 +22,8 @@ import { import { Workbench, type WorkbenchActions } from './split-pane.tsx' import type { NewTabOption } from './TabBar.tsx' import type { TabDragPayload } from './TabBar.tsx' +import { tabTypeIcon } from './icons.tsx' +import { relativeTo } from './paths.ts' import { ExplorerView } from './ExplorerView.tsx' import { EditorView } from './EditorView.tsx' import { TerminalView } from './TerminalView.tsx' @@ -38,9 +40,10 @@ function TabContent(props: { cwd: string | undefined expanded: string[] onToggleDir: (path: string) => void + onReferenceFile: (path: string) => void ctx: Context }) { - const { tab, sessionId, cwd, expanded, onToggleDir, ctx } = props + const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx } = props const scope = { sessionId, cwd } switch (tab.type) { case 'explorer': @@ -51,6 +54,7 @@ function TabContent(props: { expanded={expanded} onToggle={onToggleDir} onOpenFile={(path) => { openSidebarFile(ctx, sessionId, path) }} + onReferenceFile={onReferenceFile} /> ) case 'git': @@ -68,12 +72,13 @@ function buildNewTabOptions(state: SidebarState): NewTabOption[] { .flatMap(leaf => leaf.tabs) .filter(tab => tab.type === 'terminal').length return [ - { id: 'explorer', label: t('openExplorer') }, - { id: 'git', label: t('openGit') }, + { id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) }, + { id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) }, { id: 'terminal', label: terminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'), disabled: terminalCount >= TERMINAL_LIMIT, + icon: tabTypeIcon('terminal', 16), }, ] } @@ -214,6 +219,25 @@ export function Sidebar(props: { ctx: Context }) { }) } + /** + * The explorer's @-reference button: append `@` to the + * session's composer draft (space-separated). Resolves the session-scope + * ctx and the conversation input service at click time; a missing service + * or scope degrades to a logged no-op, never a crash. + */ + const referenceInChat = useCallback((path: string): void => { + try { + const actx = ctx.sessions.scope(sessionId) + if (actx === undefined) return + const input = ctx.conversation.input.for(actx) + const mention = `@${relativeTo(cwd ?? '', path)}` + const draft = input.state.getSnapshot().draft + input.setDraft(draft.trim() === '' ? mention : `${draft} ${mention}`) + } catch (error) { + console.warn('[dsh-better-sidebar] reference insert failed:', error) + } + }, [ctx, sessionId, cwd]) + const renderTab = (tab: SidebarTab) => ( { sidebarStore.reduce(s => toggleExpanded(s, path)) }} + onReferenceFile={referenceInChat} ctx={ctx} /> ) diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 38a1a17..04e5e63 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -11,6 +11,7 @@ import { IconCloseFill14, IconPlusOutline16, Menu, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarTab } from './state.ts' +import { tabTypeIcon } from './icons.tsx' import { t } from './locales.ts' import css from './sidebar.module.css' @@ -19,6 +20,8 @@ export interface NewTabOption { id: string label: string disabled?: boolean + /** Leading icon (Menu row). */ + icon?: ReactNode } /** Drag payload for tab moves (HTML5 DnD dataTransfer). */ @@ -109,6 +112,7 @@ export function TabBar(props: { } }} > + {tabTypeIcon(tab.type, 14)} {tab.title} ))} diff --git a/src/context-types.ts b/src/context-types.ts index 08b8e92..ecf8363 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -9,6 +9,7 @@ * - httpServer: @deepseek-ai/dsh-host-webserver * - sessions: host side @deepseek-ai/dsh-session (SessionStore), client * side the runtime ISessions list feed + * - conversation: client side ui-conversation's IConversation (composer draft) * - loader: @cordisjs/plugin-loader (entry options) * - slots: the client runtime SlotsService * - effect: the DSH-vendored cordis lifecycle helper @@ -97,12 +98,36 @@ export interface SidebarSessionsService { getSnapshot(): SidebarSessionList subscribe(fn: () => void): () => void } + /** + * Resolve an Agent-scoped context view for one session (mirror of the + * runtime ISessions.scope) — the ticket `ctx.conversation.input.for` + * requires to reach that session's composer. + */ + scope(id: string): Context | undefined +} + +/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */ +export interface SidebarSessionInput { + /** The live input store (draft read for append). */ + state: { + getSnapshot(): { draft: string } + } + /** Replace the draft text (the input machine's single public write path). */ + setDraft(text: string): void +} + +/** The conversation service face (mirror of ui-conversation's IConversation). */ +export interface SidebarConversation { + input: { + for(actx: Context): SidebarSessionInput + } } declare module 'cordis' { interface Context { httpServer: SidebarHttpServer sessions: SidebarSessionStore & SidebarSessionsService + conversation: SidebarConversation loader: SidebarLoader slots: SidebarSlotsService /** diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index fe3ebbe..2005d73 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -7,6 +7,7 @@ import { type SidebarState, type SplitNode, } from '../src/client/state.ts' import { extOf, languageKeyForExt } from '../src/client/lang.ts' +import { relativeTo } from '../src/client/paths.ts' import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts' import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts' @@ -318,6 +319,25 @@ describe('persisted state sanitization', () => { }) }) +describe('path helpers', () => { + it('derives relative paths under the cwd (and "." for the cwd itself)', () => { + expect(relativeTo('/Users/me/code', '/Users/me/code/src/main.ts')).toBe('src/main.ts') + expect(relativeTo('/Users/me/code', '/Users/me/code')).toBe('.') + expect(relativeTo('/Users/me/code/', '/Users/me/code/src/a/b.ts')).toBe('src/a/b.ts') + }) + + it('falls back to the path unchanged when it lies outside the cwd', () => { + expect(relativeTo('/Users/me/code', '/Users/other/x.ts')).toBe('/Users/other/x.ts') + expect(relativeTo('/Users/me/code', '/Users/me/codex/y.ts')).toBe('/Users/me/codex/y.ts') + }) + + it('handles windows roots and mixed separators', () => { + expect(relativeTo('C:\\Users\\me', 'C:\\Users\\me\\src\\a.ts')).toBe('src/a.ts') + expect(relativeTo('C:\\Users\\me', 'C:/Users/me/src/a.ts')).toBe('src/a.ts') + expect(relativeTo('C:\\Users\\me\\', 'C:\\Users\\me')).toBe('.') + }) +}) + describe('editor language mapping', () => { it('derives extensions from paths', () => { expect(extOf('/a/b/main.tsx')).toBe('tsx')