Files
apes-sidebar/src/client/changes/ChangesTab.tsx
T
Menghuan1918 b011be1e98 feat(sidebar): 打开方式双源并存 + 多选压缩下载 + 文件变动层级树
用户追加三条需求,全部落地:

1) 打开方式 = 宿主探测应用 + 插件自研 open-with(并存)
- 逐字恢复 src/client/open-with.ts / open-with-settings.tsx / plugin-settings.ts /
  src/open-external.ts / open.external 路由 / api.openExternal / editor descriptor
  的 settings.render,以及 20 个 openWith* 词条(19 份词典从 main 取回原文)
- 恢复 menu-flip.ts + layout.css 子菜单翻转规则(子菜单回来了)
- FileTree 菜单段(无标题行):宿主行 → 分隔线 → 插件 pinned + 「在应用中打开 >」
  子菜单(图钉 / SSH 后缀原样)→ 分隔线 → 宿主 reveal;宿主可用时过滤插件
  explorer 目标,宿主不可用时插件行照旧(SSH 远端场景保持可用)
- 死键清理:菜单去掉标题行后 openInApp 词条无引用,已从 21 份词典删除

2) 文件页多选右键「压缩并打包下载」
- 新增 src/zip.ts(自研 ZIP:方法 0/8 按收益二选一、UTF-8 bit 11、目录条目、
  自实现 CRC32、无第三方依赖)+ src/archive-route.ts(名净化 + 递归收集)+
  GET /sidebar/archive(同一道 workspace fence、目录递归跳过符号链接、
  10000 条目 / 256 MiB 上限)+ api.archiveUrl
- FileTree:多选 ≥2 项或单选目录时出现;fetch → blob → objectURL 下载,
  路由错误(超限 / fence / 非 GET)以 zipFailed 显示在错误条
- 修掉真实缺陷:central directory 只写 MS-DOS 位而 version-made-by 声称 Unix,
  Info-ZIP 会把每个文件解成 mode 000;改为 Unix 语义(0o100644 * 0x10000,
  << 16 会溢出 int32)。系统 unzip 复验:mode 644/755、unzip -t 无错

3) 文件变动页层级树 + 视觉打磨
- 新增 src/client/changes/change-tree.ts:单子目录链压缩成一行、目录先于文件、
  大小写不敏感排序、文件带 GitFileStatus、目录带下级变更数
- GitLens 两组各自渲染树:折叠状态按「组+路径」独立、默认展开;分组头吸顶 +
  计数胶囊;Stage all/Unstage all 改为带 aria-label 的 IconButton;选中 =
  interactive-bg-active + 2px 内嵌强调条;12px/级缩进 + l1 参考线;
  历史行 hash 徽标 / ref 胶囊 / 作者·时间右对齐

验证:typecheck / lint / test(134 文件 1422 通过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
真实宿主截图确认三条需求(多选+双源菜单+zip 项、变动页树形层级)
2026-09-28 18:08:21 +08:00

271 lines
12 KiB
TypeScript

/**
* 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).
* 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 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, 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, clampPaneHeight, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import { extractFileOps, knownContentBefore, type FileOp } from './ops.ts'
import css from './changes.module.css'
/** The default preview pane height (px) before the first drag. */
const PANE_HEIGHT_DEFAULT = 300
/** Cap on accumulated events: the lens shows the recent window, not eternity
* (the host enforces the same bound per response). */
const EVENTS_CAP = 4000
/** Live op count per session: the tab's poller writes, the badge reads (a
* badge cannot fetch — it must resolve synchronously during render). */
const opCounts = new Map<string, number>()
/** The session's traced-op count as of the last poll (undefined before the
* tab has ever pulled; 0 hides the badge pill). */
export function opCountOf(sessionId: string): number | undefined {
return opCounts.get(sessionId)
}
type Lens = 'git' | 'session'
/** The persisted tab meta (JSON-serializable; rides the layout). */
interface ChangesMeta {
lens?: Lens
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 [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 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
// poll and REUSING the result across renders (the render used to re-fold
// the whole window, twice per tick, and the fresh array defeated the
// downstream memo on every poll) keeps the 2.5s tick at one fold.
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 (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, 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
}
if (lastSeq > seqRef.current) seqRef.current = lastSeq
const folded = extractFileOps(eventsRef.current)
opsRef.current = folded
opCounts.set(scope.sessionId, folded.length)
setOpsError(false)
setTick(value => value + 1)
} catch {
// Offline / route unavailable: keep the last fold; surface it inline
// only while nothing has ever loaded.
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.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scope.sessionId, scope.cwd])
useEffect(() => {
pollGen.current += 1
eventsRef.current = []
opsRef.current = []
seqRef.current = 0
setOpsError(false)
}, [scope.sessionId])
// 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, {
meta: { ...(tab.meta as ChangesMeta | undefined ?? {}), ...patch },
})
}
const chooseLens = (next: Lens): void => {
if (next === lens) return
setLens(next)
patchMeta({ lens: next })
}
/** 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 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 => {
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 (previewTarget?.kind !== 'git') return
onOpenDiff?.(diffTabOf(previewTarget.ref))
}
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
? (target.ref.kind === 'worktree'
? `git:w:${target.ref.path}:${target.ref.staged ? 's' : 'u'}`
: `git:c:${target.ref.hashFull}`)
: `op:${target.op.callId}`
return (
<div className={css.root}>
<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'
? (
<GitLens
ctx={ctx}
scope={scope}
store={store}
visible={visible}
refreshTick={gitRefresh}
onOpenFile={onOpenFile ?? (() => { /* no-op */ })}
onPreview={previewGit}
selectedRef={previewTarget !== null && previewTarget.kind === 'git' ? previewTarget.ref : null}
/>
)
: (
<SessionLens
ops={ops}
loadError={opsError && ops.length === 0}
onPreview={previewOp}
selectedCallId={selection !== null && selection.kind === 'op' ? selection.callId : null}
/>
)}
{previewTarget !== null && (
<DiffPane
key={previewKey(previewTarget)}
target={previewTarget}
scope={scope}
height={paneHeight}
onHeightCommit={(height) => { setPaneHeight(height); patchMeta({ previewH: height }) }}
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>
)
}