feat: explorer copy-path & @-reference actions; icons on tabs and + menu

- Tabs, the + menu, and empty-pane cards carry per-type icons (folder /
  branch / terminal / code file); a terminal glyph joins the icon set
- Hovering an explorer row reveals an @文件 button at the far right that
  appends @<relative path> to the composer draft (session-scoped input
  service, space-separated append)
- Right-clicking a row opens a context menu: copy relative / absolute
  path (writeClipboard + transient in-row "copied" label)
- Rows become div role=button (Enter/Space) to host the inner button;
  relativeTo helper unit-tested
This commit is contained in:
Menghuan1918
2026-08-08 23:46:14 +08:00
parent 88b08de957
commit 6e36a99c88
11 changed files with 306 additions and 26 deletions
+3
View File
@@ -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**
- 空分栏不再显示空提示,而是**可打开类型卡片**(点击即开)
+127 -10
View File
@@ -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 `@<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).
*/
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 `@<relative path>` 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<Record<string, LevelData>>({})
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<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)
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 <span className={css.explorerCopied}>{t('copied')}</span>
}
return (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(entry.path)
}}
>
{t('referenceFile')}
</button>
)
}
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 (
<div key={entry.path}>
<button
type="button"
<div
role="button"
tabIndex={0}
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
style={{ paddingLeft: depth * 22 + 6 }}
onClick={() => { onToggle(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onToggle(entry.path)
}
}}
onContextMenu={(event) => { openRowMenu(event, entry.path) }}
>
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
<span className={css.explorerName}>{entry.name}</span>
</button>
{rowActions(entry)}
</div>
{isOpen && renderLevel(entry.path, depth + 1)}
</div>
)
}
return (
<button
<div
key={entry.path}
type="button"
role="button"
tabIndex={0}
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
style={{ paddingLeft: depth * 22 + 6 }}
title={entry.path}
onClick={() => { onOpenFile(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onOpenFile(entry.path)
}
}}
onContextMenu={(event) => { openRowMenu(event, entry.path) }}
>
<IconCodeOutline16 size={14} />
<span className={css.explorerName}>{entry.name}</span>
</button>
{rowActions(entry)}
</div>
)
})
}
@@ -133,14 +205,59 @@ export function ExplorerView(props: {
<div className={css.explorerEmpty}>{t('noSession')}</div>
) : (
<>
<div className={css.explorerRow} style={{ paddingLeft: 6 }}>
<div
className={css.explorerRow}
style={{ paddingLeft: 6 }}
onContextMenu={(event) => { openRowMenu(event, root) }}
>
<IconFolderOpen16 size={14} />
<span className={css.explorerName}>{baseName(root)}</span>
{copiedPath === root
? <span className={css.explorerCopied}>{t('copied')}</span>
: (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(root)
}}
>
{t('referenceFile')}
</button>
)}
</div>
{data[root] !== undefined && renderLevel(root, 1)}
</>
)}
</div>
{/*
The one shared context menu, positioned at the right-click cursor
(portal so the explorer's overflow clip cannot crop it).
*/}
<Menu
open={rowMenu !== null}
onClose={() => { setRowMenu(null) }}
items={[
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
]}
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={<span />}
/>
</div>
)
}
+28 -3
View File
@@ -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 `@<relative path>` 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) => (
<TabContent
tab={tab}
@@ -221,6 +245,7 @@ export function Sidebar(props: { ctx: Context }) {
cwd={cwd}
expanded={state.expanded}
onToggleDir={(path) => { sidebarStore.reduce(s => toggleExpanded(s, path)) }}
onReferenceFile={referenceInChat}
ctx={ctx}
/>
)
+5
View File
@@ -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)}
<span className={css.tabTitle}>{tab.title}</span>
<button
type="button"
@@ -135,6 +139,7 @@ export function TabBar(props: {
id: option.id,
label: option.label,
...(option.disabled === true ? { disabled: true } : {}),
...(option.icon !== undefined ? { icon: option.icon } : {}),
}))}
onSelect={(id) => {
onNewTab(id)
+33
View File
@@ -0,0 +1,33 @@
/**
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
* icon library has none) and the per-tab-type icon mapping shared by the
* tab strip, the + menu, and the empty-pane cards.
*/
import type { ReactNode } from 'react'
import {
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TabType } from './state.ts'
/**
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
* a rounded frame with a prompt chevron and underscore cursor.
*/
export const IconTerminalOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M4.5 6.25 6.75 8 4.5 9.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.5 10.4h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
/** The one icon per tab type (shared by tabs, the + menu, and pane cards). */
export function tabTypeIcon(type: TabType, size = 16): ReactNode {
switch (type) {
case 'explorer': return <IconFolderOpen16 size={size} />
case 'git': return <IconBranchOutline16 size={size} />
case 'terminal': return <IconTerminalOutline16 size={size} />
case 'editor': return <IconCodeOutline16 size={size} />
}
}
+6
View File
@@ -66,6 +66,9 @@ const zh = {
newFile: '新文件',
openEditor: '打开编辑器',
gitDetail: '查看变更详情',
referenceFile: '@文件',
copyRelative: '复制相对地址',
copyAbsolute: '复制绝对地址',
}
const en: Record<keyof typeof zh, string> = {
@@ -129,6 +132,9 @@ const en: Record<keyof typeof zh, string> = {
newFile: 'New file',
openEditor: 'Open editor',
gitDetail: 'View change details',
referenceFile: '@file',
copyRelative: 'Copy relative path',
copyAbsolute: 'Copy absolute path',
}
/** Translate a copy key in the browser's language (zh → zh, else en). */
+23
View File
@@ -0,0 +1,23 @@
/**
* Path projection helpers shared by the explorer rows: a path relative to
* the session cwd (for the @-reference button and "copy relative path").
* The fs-tree joins with '/' even on Windows, so both separators normalize
* to '/' before comparison.
*/
/**
* The path relative to the session's working directory.
* @param cwd - the explorer root (absolute).
* @param path - an absolute entry path from the fs-tree.
* @returns the relative path with '/' separators ('.' for the cwd itself),
* or `path` unchanged when it lies outside the cwd.
*/
export function relativeTo(cwd: string, path: string): string {
const base = cwd.replace(/[\\/]+$/, '')
const norm = (value: string): string => value.replace(/\\/g, '/')
const nBase = norm(base)
const nPath = norm(path)
if (nPath === nBase) return '.'
if (nPath.startsWith(`${nBase}/`)) return nPath.slice(nBase.length + 1)
return path
}
+34
View File
@@ -502,6 +502,39 @@
text-overflow: ellipsis;
}
/* The hover-revealed @-reference button at the row's far right (and the
transient "copied" label that replaces it after a copy). Hidden rows
keep the name at full width; hovering or focusing the row reveals it. */
.explorerRef {
flex: none;
display: none;
align-items: center;
height: 20px;
padding: 0 8px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 999px;
background: var(--dsw-alias-bg-layer-2);
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-strong-11);
cursor: pointer;
}
.explorerRef:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.explorerRow:hover .explorerRef,
.explorerRow:focus-within .explorerRef {
display: inline-flex;
}
.explorerCopied {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.explorerError {
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
@@ -998,6 +1031,7 @@
.tabBarPlus:focus-visible,
.paneCard:focus-visible,
.explorerRow:focus-visible,
.explorerRef:focus-visible,
.gitRowMain:focus-visible,
.gitLink:focus-visible,
.gitCommitButton:focus-visible,
+2 -13
View File
@@ -12,12 +12,10 @@
import { Fragment, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import {
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
import type { DropZone } from './state.ts'
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
import { tabTypeIcon } from './icons.tsx'
import css from './sidebar.module.css'
/** Actions the workbench needs (bound to the store by the sidebar shell). */
@@ -82,15 +80,6 @@ function zoneAt(event: React.DragEvent, pane: HTMLElement): DropZone {
}
/** The icon of one openable type card (mirror of the + menu options). */
function optionIcon(optionId: string): ReactNode {
switch (optionId) {
case 'explorer': return <IconFolderOpen16 size={16} />
case 'git': return <IconBranchOutline16 size={16} />
case 'terminal': return <IconCodeOutline16 size={16} />
default: return <IconCodeOutline16 size={16} />
}
}
/**
* An empty pane's welcome cards: the openable types as cards, clicked to
* open (instead of a bare "this pane is empty" message).
@@ -111,7 +100,7 @@ function PaneEmptyCards(props: {
title={option.label}
onClick={() => { onNewTab(option.id) }}
>
{optionIcon(option.id)}
{option.icon ?? tabTypeIcon(option.id as SidebarTab['type'], 16)}
<span>{option.label}</span>
</button>
))}
+25
View File
@@ -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
/**
+20
View File
@@ -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')