feat(sidebar): 文件页与文件变动页 UIUX 重构 + 全局组件抽取

文件页(FileTree/TreePanel)
- Ctrl/Cmd 多选 + Shift 按可见行序连选(Esc/点空白/右键收敛),批量条支持复制路径、批量删除(共享 ConfirmDialog + 顺序 fsRemove)
- Git 变更着色:行名按状态上色 + M/A/D/U 徽标,目录按 dirHasChanges 着色,非仓库静默降级(数据来自共享 git 状态存储)
- 新建文件夹:fs.mkdir 路由 + mkdirWorkspaceEntry + 内联输入行(Enter/Esc/blur/IME 守卫)
- 性能:FileRow/DirRow memo(含反向验证)、搜索 hidden 停车不再卸载树、level 拉取世代守卫 + AbortController、fs.tree truncated 提示、use-dir-watch 入参 memo
- 拖拽上传视觉 token 化(硬编码色清零);可见性下传后隐藏页签停止轮询
- 右键「打开方式」改为复用 DSH 自带 open-in-app(宿主探测的关联应用 + 文件管理器显示)

文件变动页(changes/diff)
- 36px 头(宿主 SegmentedControl)+ 单一 ChangeRow + SectionHeader 分组 + 吸底提交条单状态行 + 历史独立空态
- 修掉:双份空态、暂存失败 unhandled rejection、手动刷新被丢、仓库切换刷新两次、worktree 清单缺 repoRoot、工作树被自动切走、预览持旧快照、恢复高度未 clamp、session ops 无谓轮询
- DiffPane/DiffTab 共用 use-git-diff;修掉 DiffPane 的 untracked 预览从不渲染

全局组件与宿主接入
- 新增 src/client/ui(IconButton/Chip/SectionHeader/Notice/ConfirmDialog/StatusBadge + 共享 git 状态存储)
- 新增 src/client/open-in-app.ts(宿主 open-in-app 客户端适配),删除自研 open-with 全链路与 open.external 路由
- 客户端 inject 补 remote / remote.session:缺声明时 ctx.get('remote').session 会抛出并把整个面板打成错误条(真实挂载 lane 抓到)
- 词典:+25 键 / -20 openWith* / -noChanges,20 份同步

验证:typecheck / lint / test(126 文件 1322 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过(含 tab sweep 与 perf lane)
This commit is contained in:
Menghuan1918
2026-09-28 00:27:31 +08:00
parent 606339f81e
commit 36fbc5ac7e
76 changed files with 6961 additions and 3944 deletions
+122 -62
View File
@@ -2,27 +2,33 @@
* The unified changes tab: one tab, two lenses on "what changed?" — Git
* (repository truth: staged/unstaged files, commit box, history) and the
* session round (agent truth: every file the model read, wrote, or edited).
* Both lenses preview their selections in a shared resizable bottom pane
* ({@link DiffPane}); git targets expand into the dedicated diff tab docked
* in the workbench's diff pane. The active lens and the pane height persist
* in the tab's meta, so the tab
* reopens exactly where it was left.
* A 36px header carries the host {@link SegmentedControl} and the current
* lens's refresh action; both lenses preview their selections in a shared
* resizable bottom pane ({@link DiffPane}), and git targets expand into the
* dedicated diff tab docked in the workbench's diff pane. The active lens and
* the pane height persist in the tab's meta, so the tab reopens exactly where
* it was left.
*
* The session events ride the host's `changes.ops` route (the client
* runtime exposes no event-log face): the tab pulls the delta past its
* cursor while visible, folds it into ops, and publishes the op count to a
* module-level cache the tab-strip badge reads.
* The session events ride the host's `changes.ops` route (the client runtime
* exposes no event-log face): the tab pulls the delta past its cursor and
* folds it into ops, and publishes the op count to a module-level cache the
* tab-strip badge reads. The pull is one catch-up whenever the tab becomes
* visible and then a cadence ONLY while the session lens is on screen — the
* git lens has no use for the session event log (and the shared git-status
* store owns its own poller).
*/
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { IconRefreshOutlineRegular, SegmentedControl } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarSessionEvent } from '../../context-types.ts'
import type { TabComponentProps } from '../service.ts'
import { t } from '../locales.ts'
import { api } from '../api.ts'
import { usePolling } from '../use-polling.ts'
import type { SidebarDiffRef } from '../state.ts'
import { IconButton, Notice } from '../ui/index.ts'
import { GitLens } from './GitLens.tsx'
import { SessionLens } from './SessionLens.tsx'
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import { DiffPane, clampPaneHeight, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import { extractFileOps, knownContentBefore, type FileOp } from './ops.ts'
import css from './changes.module.css'
@@ -51,18 +57,28 @@ interface ChangesMeta {
previewH?: number
}
/** What the user selected to preview: a git ref, or the IDENTITY of a session
* op. Holding the callId (not the op object) is what makes the pane follow
* the op as it settles: every render re-derives the op from the latest fold,
* so a running call turns into its result without the user re-clicking. */
type PreviewSelection =
| { kind: 'git'; ref: SidebarDiffRef }
| { kind: 'op'; path: string; callId: string }
export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }: TabComponentProps) {
const meta = (tab.meta ?? {}) as ChangesMeta
const [lens, setLens] = useState<Lens>(meta.lens === 'session' ? 'session' : 'git')
const [preview, setPreview] = useState<ChangesPreview | null>(null)
const [paneHeight, setPaneHeight] = useState<number>(
typeof meta.previewH === 'number' && meta.previewH >= 140 ? meta.previewH : PANE_HEIGHT_DEFAULT,
)
const [selection, setSelection] = useState<PreviewSelection | null>(null)
const [paneHeight, setPaneHeight] = useState<number>(() => clampPaneHeight(
typeof meta.previewH === 'number' ? meta.previewH : PANE_HEIGHT_DEFAULT,
))
/** The header refresh action for the Git lens (the lens owns its own queue). */
const [gitRefresh, setGitRefresh] = useState(0)
// ── Session-event accumulation: one pull on mount, then a 2.5s delta
// poll while visible (paused otherwise; the next visible tick catches
// up). The cursor is the last delivered seq, so each poll ships only
// what the accumulator lacks. ────────────────────────────────────────
// ── Session-event accumulation: one catch-up pull when the tab becomes
// visible, then a 2.5s delta poll while the SESSION lens is on screen.
// The cursor is the last delivered seq, so each poll ships only what the
// accumulator lacks. ───────────────────────────────────────────────────
const eventsRef = useRef<readonly SidebarSessionEvent[]>([])
// The fold of eventsRef as of the last poll. extractFileOps parses every
// accumulated tool/call (up to EVENTS_CAP events); running it once per
@@ -72,13 +88,30 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
const opsRef = useRef<readonly FileOp[]>([])
const seqRef = useRef(0)
const pollGen = useRef(0)
/** One delta request in flight at a time (the poller is fixed-interval, so a
* slow host would otherwise run two pulls over the same cursor and append
* the same delta twice). A pull that arrives while one is running is a
* no-op: the in-flight answer already carries everything up to now. */
const pullingRef = useRef(false)
const [opsError, setOpsError] = useState(false)
const [tick, setTick] = useState(0)
const pull = useCallback(async (): Promise<void> => {
const pull = useCallback(async (signal?: AbortSignal): Promise<void> => {
const generation = pollGen.current
if (pullingRef.current) return
pullingRef.current = true
try {
const { events, lastSeq } = await api.changesOps(scope, seqRef.current)
if (generation !== pollGen.current) return
const { events, lastSeq } = await api.changesOps(scope, seqRef.current, signal)
// An aborted poll belongs to a torn-down run: its answer must never
// publish state (the transport may deliver it anyway).
if (generation !== pollGen.current || signal?.aborted === true) return
// Nothing new: KEEP the previous fold's identity. Re-folding an
// unchanged window produced a fresh array every 2.5s, which defeated
// every downstream memo — the session lens re-encoded each op body into
// a Blob to size it, on every idle tick.
if (events.length === 0 && lastSeq <= seqRef.current && opsRef.current.length > 0) {
setOpsError(false)
return
}
if (events.length > 0) {
const merged = [...eventsRef.current, ...events]
eventsRef.current = merged.length > EVENTS_CAP ? merged.slice(merged.length - EVENTS_CAP) : merged
@@ -92,7 +125,9 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
} catch {
// Offline / route unavailable: keep the last fold; surface it inline
// only while nothing has ever loaded.
if (generation === pollGen.current) setOpsError(true)
if (generation === pollGen.current && signal?.aborted !== true) setOpsError(true)
} finally {
pullingRef.current = false
}
// Granular scope fields: the scope object's identity churns, only its
// sessionId / cwd fields gate the poll target.
@@ -105,15 +140,24 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
seqRef.current = 0
setOpsError(false)
}, [scope.sessionId])
// One pull on every input change — mount, scope change, and visibility
// flip all re-pull (a hidden tab still catches up on the flip); only the
// poll CADENCE below is gated by visibility.
useEffect(() => { void pull() }, [visible, pull])
usePolling(visible, pull, { intervalMs: 2_500 })
// One catch-up whenever the tab becomes visible (the badge count follows the
// tab being opened); the CADENCE below runs only while this lens is showing.
useEffect(() => { if (visible) void pull() }, [visible, pull])
usePolling(visible && lens === 'session', pull, { intervalMs: 2_500 })
// tick only forces the re-render; the fold reads the ref directly.
void tick
const ops = opsRef.current
/** The preview target of the current selection, re-derived every render so
* an op that settled (or fell out of the window) is reflected immediately. */
const previewTarget = useMemo<ChangesPreview | null>(() => {
if (selection === null) return null
if (selection.kind === 'git') return selection
const op = ops.find(candidate => candidate.callId === selection.callId)
if (op === undefined) return null
return { kind: 'op', path: selection.path, op, prior: knownContentBefore(ops, selection.path, op) }
}, [selection, ops])
/** Persist a meta patch onto the tab (lens choice, pane height). */
const patchMeta = (patch: ChangesMeta): void => {
ctx.get('betterSidebar')?.updateTab(tab.id, {
@@ -127,21 +171,29 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
patchMeta({ lens: next })
}
/** Preview one git change (worktree file or commit) from the Git lens. */
const previewGit = (ref: SidebarDiffRef): void => {
setPreview({ kind: 'git', ref })
/** The header's refresh action: the Git lens reloads through its own queue
* (a click while a refresh is in flight is queued, not dropped), the
* session lens re-pulls its event delta. */
const refreshCurrent = (): void => {
if (lens === 'git') setGitRefresh(value => value + 1)
else void pull()
}
/** Preview one session op (with its best-effort prior content snapshot). */
/** Preview one git change (worktree file or commit) from the Git lens. */
const previewGit = (ref: SidebarDiffRef): void => {
setSelection({ kind: 'git', ref })
}
/** Preview one session op by callId (the tab re-derives it each render). */
const previewOp = (path: string, op: FileOp): void => {
setPreview({ kind: 'op', path, op, prior: knownContentBefore(ops, path, op) })
setSelection({ kind: 'op', path, callId: op.callId })
}
/** Expand the current git preview into the dedicated diff tab, docked
* into the workbench's diff pane. */
const expandPreview = (): void => {
if (preview?.kind !== 'git') return
onOpenDiff?.(diffTabOf(preview.ref))
if (previewTarget?.kind !== 'git') return
onOpenDiff?.(diffTabOf(previewTarget.ref))
}
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
@@ -152,27 +204,23 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
return (
<div className={css.root}>
<div className={css.lensBar}>
<div className={css.lensSwitch} role="group" aria-label={t('changes')}>
<button
type="button"
className={css.lensButton}
data-active={lens === 'git' ? 'true' : undefined}
aria-pressed={lens === 'git'}
onClick={() => { chooseLens('git') }}
>
{t('changesGitLens')}
</button>
<button
type="button"
className={css.lensButton}
data-active={lens === 'session' ? 'true' : undefined}
aria-pressed={lens === 'session'}
onClick={() => { chooseLens('session') }}
>
{t('changesSessionLens')}
</button>
</div>
<div className={css.head}>
<SegmentedControl
id={`changes-lens-${tab.id}`}
value={lens}
options={[
{ value: 'git', label: t('changesGitLens') },
{ value: 'session', label: t('changesSessionLens') },
]}
onChange={chooseLens}
label={t('changes')}
/>
<IconButton
className={css.headAction}
icon={<IconRefreshOutlineRegular />}
label={t('refresh')}
onClick={refreshCurrent}
/>
</div>
{lens === 'git'
? (
@@ -180,9 +228,10 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
scope={scope}
store={store}
visible={visible}
refreshTick={gitRefresh}
onOpenFile={onOpenFile ?? (() => { /* no-op */ })}
onPreview={previewGit}
selectedRef={preview !== null && preview.kind === 'git' ? preview.ref : null}
selectedRef={previewTarget !== null && previewTarget.kind === 'git' ? previewTarget.ref : null}
/>
)
: (
@@ -190,20 +239,31 @@ export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpen
ops={ops}
loadError={opsError && ops.length === 0}
onPreview={previewOp}
selectedCallId={preview !== null && preview.kind === 'op' ? preview.op.callId : null}
selectedCallId={selection !== null && selection.kind === 'op' ? selection.callId : null}
/>
)}
{preview !== null && (
{previewTarget !== null && (
<DiffPane
key={previewKey(preview)}
target={preview}
key={previewKey(previewTarget)}
target={previewTarget}
scope={scope}
height={paneHeight}
onHeightCommit={(height) => { setPaneHeight(height); patchMeta({ previewH: height }) }}
onClose={() => { setPreview(null) }}
onClose={() => { setSelection(null) }}
onExpand={expandPreview}
/>
)}
{selection !== null && selection.kind === 'op' && previewTarget === null && (
<div className={css.paneGone}>
<Notice kind="warn">{t('changesOpGone')}</Notice>
<IconButton
size="sm"
icon={<IconRefreshOutlineRegular size={14} />}
label={t('changesClosePreview')}
onClick={() => { setSelection(null) }}
/>
</div>
)}
</div>
)
}