用户追加三条需求,全部落地: 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 项、变动页树形层级)
271 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|