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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user