feat!: 合并 Git 面板与文件追踪为统一「文件变动」tab(双视角 + 统一 diff 栈)

在 PR #471(file-trace)基础上的演进:

- 合并 tab:git tab(id 保留,兼容持久化布局)升级为「文件变动」双视角
  tab——Git 视角(原 GitView 全部能力:staged/unstaged/提交/历史/worktree·
  子仓库选择/右键菜单/确认弹窗)+ 本轮文件视角(原 file-trace:会话事件
  日志实时折叠的模型读写编辑记录,按文件分组、新增类型筛选 chips、相对
  时间);file-trace tab 移除(未随任何版本发布)。
- 统一 diff 组件:新 src/client/diff/ 模块(rows.ts 引擎 + DiffRows/
  DiffFiles 渲染器 + highlight.ts 语法着色)——git diff 与会话操作 diff
  共用一套渲染:mod 改写配对(del-run/add-run 对半标蓝)、行内字符级
  高亮(修复了旧实现旧侧失配不高亮的问题,改为行对象身份键)、上下文
  hunk 折叠、git hunk 间未输出区间按行号范围生成不可展开折叠、语法着色
  色相落位 diff 三色空档、+n −m 统计 chips、行底色 tint;DiffTab 改用
  该栈(git diff tab 从此也有高亮)。
- 交互:两视角共用底部可拖拽预览面板(点击文件/提交/操作即预览,不再
  直接开 diff tab;面板可刷新、可键盘/拖拽调高、git 目标可一键展开为
  独立 diff tab);视角选择与面板高度持久化在 tab.meta;git 行 XY 徽章
  按状态字母着色;选中行高亮。
- i18n:fileTrace* 十键重命名为 changes* 前缀,git 键值更新为「文件
  变动」,新增 7 键(视角/筛选/面板操作),全部 21 个词典同步。
- 结构:src/client/changes/(ChangesTab/GitLens/SessionLens/DiffPane/
  ops)+ src/client/diff/;GitView/DiffView/filetrace/ 删除;git 样式
  从 sidebar.module.css 迁至 changes.module.css。
- 测试:builtins 8→7(git tab 承接 badge pin)、diff-rows(unified
  转换/mod 配对/间隙折叠/统计)、changes-tab(壳交互 jsdom)、
  diff-files-collapse(原 DiffView 折叠行为钉住)、changes-ops(原
  filetrace 数据层)、git-view-worktree 迁移至 GitLens;全量 1122 通过。
- 文档:README ×2 功能矩阵合并、external-plugin-guide §4.4 与内置清单
  7 tab、mount e2e 菜单标题同步。
This commit is contained in:
Menghuan1918
2026-08-31 01:31:22 +08:00
parent 9255040489
commit 24a3813c63
53 changed files with 2693 additions and 1792 deletions
+127
View File
@@ -0,0 +1,127 @@
/**
* 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 can expand into the dedicated diff tab.
* The active lens and the pane height persist in the tab's meta, so the tab
* reopens exactly where it was left.
*/
import { useState } from 'react'
import type { TabComponentProps } from '../service.ts'
import { t } from '../locales.ts'
import type { SidebarDiffRef } from '../state.ts'
import { GitLens } from './GitLens.tsx'
import { SessionLens } from './SessionLens.tsx'
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import 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
type Lens = 'git' | 'session'
/** The persisted tab meta (JSON-serializable; rides the layout). */
interface ChangesMeta {
lens?: Lens
previewH?: number
}
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,
)
/** 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 })
}
/** Preview one git change (worktree file or commit) from the Git lens. */
const previewGit = (ref: SidebarDiffRef): void => {
setPreview({ kind: 'git', ref })
}
/** Preview one session op (with its best-effort prior content snapshot). */
const previewOp = (path: string, op: FileOp, prior: string | undefined): void => {
setPreview({ kind: 'op', path, op, prior })
}
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.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>
{lens === 'git'
? (
<GitLens
scope={scope}
store={store}
visible={visible}
onOpenFile={onOpenFile ?? (() => { /* no-op */ })}
onPreview={previewGit}
selectedRef={preview !== null && preview.kind === 'git' ? preview.ref : null}
/>
)
: (
<SessionLens
ctx={ctx}
scope={scope}
visible={visible}
onPreview={previewOp}
selectedCallId={preview !== null && preview.kind === 'op' ? preview.op.callId : null}
/>
)}
{preview !== null && (
<DiffPane
key={previewKey(preview)}
target={preview}
scope={scope}
height={paneHeight}
onHeightCommit={(height) => { setPaneHeight(height); patchMeta({ previewH: height }) }}
onClose={() => { setPreview(null) }}
onExpand={() => {
if (preview.kind === 'git') onOpenDiff?.(diffTabOf(preview.ref))
}}
/>
)}
</div>
)
}
+313
View File
@@ -0,0 +1,313 @@
/**
* The changes tab's shared bottom preview pane: one selected target — a git
* worktree change, a commit patch, or a session file op — rendered through
* the unified diff stack (the same one the diff tab uses). Git targets load
* on demand (refreshable, with the untracked full-addition fallback); op
* targets are pure snapshots (diff / read view / error text). The pane is
* resizable by drag (clamped; the height commits to the tab's persisted
* meta on release) and by keyboard; git targets can expand into a dedicated
* diff tab via the shell.
*/
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from '../api.ts'
import { api } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../produced-files.ts'
import type { SidebarDiffRef, SidebarTab } from '../state.ts'
import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
import { parseReadLines, type FileOp } from './ops.ts'
import css from './changes.module.css'
import diffCss from '../diff/diff.module.css'
/** The drag handle height clamp (px) and keyboard-resize step. */
const HEIGHT_MIN = 140
const HEIGHT_STEP = 24
/** What the pane is showing right now. */
export type ChangesPreview =
| { kind: 'git'; ref: SidebarDiffRef }
| { kind: 'op'; path: string; op: FileOp; prior?: string }
/** Diff material for one op snapshot: an edit reconstructs the full file
* from the window's known prior content when possible (hunk-style context);
* a write with unknown prior content renders all-added. */
function diffOf(op: FileOp, prior: string | undefined): readonly DiffRow[] {
if (op.kind === 'read') return []
if (op.kind === 'edit' && op.edit !== undefined) {
const { oldString, newString } = op.edit
if (prior !== undefined && prior.includes(oldString)) {
const newFile = prior.replace(oldString, newString)
return diffLines(prior, newFile)
}
return diffLines(oldString, newString)
}
if (op.kind === 'write') {
const content = op.content ?? ''
const old = prior !== undefined && prior !== content ? prior : undefined
return diffLines(old ?? '', content)
}
return []
}
/** The diff tab a git preview expands into (the shell owns placement). */
export function diffTabOf(ref: SidebarDiffRef): SidebarTab {
if (ref.kind === 'worktree') {
return {
id: `diff:w:${encodeURIComponent(ref.worktree ?? '')}:${ref.staged ? 's' : 'u'}:${ref.path}`,
type: 'diff',
title: baseName(ref.path),
diff: ref,
}
}
return {
id: `diff:c:${encodeURIComponent(ref.worktree ?? '')}:${ref.hashFull}`,
type: 'diff',
title: `${ref.hash} ${ref.subject}`,
diff: ref,
}
}
export interface DiffPaneProps {
target: ChangesPreview
scope: SessionScope
/** The persisted pane height (px); drag commits a new one upwards. */
height: number
onHeightCommit: (height: number) => void
onClose: () => void
/** Expand the current git target into a dedicated diff tab. */
onExpand: () => void
}
export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExpand }: DiffPaneProps) {
// ── Git target loading (mirrors the diff tab: staged-side fallback, the
// untracked full-addition fallback, refresh by tick). ─────────────────
const [tick, setTick] = useState(0)
const [loading, setLoading] = useState(target.kind === 'git')
const [error, setError] = useState<string | null>(null)
const [diffText, setDiffText] = useState<string | null>(null)
const [untracked, setUntracked] = useState<string | undefined>(undefined)
const gitRef = target.kind === 'git' ? target.ref : null
useEffect(() => {
if (gitRef === null) return
let cancelled = false
const paneScope: SessionScope = {
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}
setLoading(true)
setError(null)
setDiffText(null)
setUntracked(undefined)
const load = async (): Promise<void> => {
try {
if (gitRef.kind === 'commit') {
const result = await api.gitCommitDiff(paneScope, gitRef.hashFull, gitRef.worktree)
if (!cancelled) setDiffText(result.diff)
return
}
let result = await api.gitDiff(paneScope, gitRef.path, gitRef.staged, gitRef.worktree)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once (the change
// may have moved sides after the preview target was minted).
const other = await api.gitDiff(paneScope, gitRef.path, !gitRef.staged, gitRef.worktree)
if (other.diff !== '') result = other
}
if (result.diff !== '') {
if (!cancelled) setDiffText(result.diff)
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition from its content.
if (gitRef.untracked === true && !gitRef.staged) {
const text = await api.fsRead(paneScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, gitRef.path))
if (!cancelled && text.kind === 'text') {
setDiffText('')
setUntracked(text.content)
}
return
}
if (!cancelled) setDiffText('')
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [gitRef, scope.sessionId, scope.cwd, tick])
// ── Op target material (pure snapshots; the prior content came with the
// target so a running op shows what is already known). ────────────────
const op = target.kind === 'op' ? target.op : null
const prior = target.kind === 'op' ? target.prior : undefined
const opLang = useMemo(() => (target.kind === 'op' ? langOfPath(target.path) : undefined), [target])
const opRows = useMemo(() => (op === null ? [] : diffOf(op, prior)), [op, prior])
const opSegments = useMemo(() => buildDiffSegments(opRows), [opRows])
const opStats = useMemo(() => diffStats(opSegments), [opSegments])
const opReadLines = useMemo(
() => (op?.kind === 'read' && op.read !== undefined ? parseReadLines(op.read) : []),
[op],
)
// Header stats for git targets come off the parsed patch text.
const gitStats = useMemo(() => {
if (target.kind !== 'git' || diffText === null || diffText === '') return null
let added = 0
let deleted = 0
for (const file of parseUnifiedDiff(diffText).files) {
const stats = diffStats(unifiedSegments(file))
added += stats.added
deleted += stats.deleted
}
return { added, deleted }
}, [target, diffText])
// ── Resize: drag the top handle; commit on release (persisted by the
// shell). Arrow keys resize by a step for keyboard users. ────────────
const [dragHeight, setDragHeight] = useState<number | null>(null)
const paneHeight = dragHeight ?? height
const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
const dragOrigin = useRef<{ y: number; h: number } | null>(null)
const onHandleDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
event.preventDefault()
dragOrigin.current = { y: event.clientY, h: paneHeight }
const onMove = (ev: PointerEvent): void => {
if (dragOrigin.current === null) return
setDragHeight(clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY)))
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
dragOrigin.current = null
setDragHeight(current => {
if (current !== null) onHeightCommit(current)
return null
})
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
const title = target.kind === 'op'
? target.path
: target.ref.kind === 'worktree' ? target.ref.path : `${target.ref.hash} ${target.ref.subject}`
const stats = gitStats ?? (target.kind === 'op' && op !== null && op.kind !== 'read' && !op.isError ? opStats : null)
return (
<div className={css.diffPane} style={{ height: paneHeight }}>
<div
className={css.dragHandle}
role="separator"
aria-orientation="horizontal"
aria-label={t('changesResizePreview')}
tabIndex={0}
onPointerDown={onHandleDown}
onKeyDown={(event) => {
if (event.key === 'ArrowUp') { event.preventDefault(); onHeightCommit(clamp(paneHeight + HEIGHT_STEP)) }
if (event.key === 'ArrowDown') { event.preventDefault(); onHeightCommit(clamp(paneHeight - HEIGHT_STEP)) }
}}
/>
<div className={css.diffHead}>
{target.kind === 'op' && (
<span className={css.diffKind} data-kind={target.op.kind}>
{t(target.op.kind === 'read' ? 'changesRead' : target.op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
)}
{target.kind === 'git' && target.ref.kind === 'worktree' && (
<span className={css.diffKind} data-kind="git">{target.ref.staged ? t('staged') : t('unstaged')}</span>
)}
{target.kind === 'git' && target.ref.kind === 'commit' && (
<span className={css.diffKind} data-kind="git">{target.ref.hash}</span>
)}
<span className={css.diffPath} title={title}>{title}</span>
{stats !== null && (stats.added > 0 || stats.deleted > 0) && (
<span className={css.diffStats}>
{stats.added > 0 && <span className={diffCss.statAdd}>+{String(stats.added)}</span>}
{stats.deleted > 0 && <span className={diffCss.statDel}>−{String(stats.deleted)}</span>}
</span>
)}
{target.kind === 'git' && (
<>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
disabled={loading}
onClick={() => { setTick(value => value + 1) }}
>
<IconRefreshOutline16 size={14} />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('changesOpenDiffTab')}
title={t('changesOpenDiffTab')}
onClick={onExpand}
>
<IconRightUpOutline16 size={14} />
</button>
</>
)}
<button
type="button"
className={css.iconButton}
aria-label={t('changesClosePreview')}
title={t('changesClosePreview')}
onClick={onClose}
>
<IconCloseOutline16 size={14} />
</button>
</div>
{target.kind === 'op' && op !== null && op.isError
? (
<div className={css.paneBody}>
<div className={css.readError} role="alert">
{op.errorText ?? t('changesError')}
</div>
</div>
)
: target.kind === 'op' && op !== null && op.kind === 'read'
? (
<div className={css.paneBody}>
<ReadRows lines={opReadLines} lang={opLang} />
</div>
)
: target.kind === 'op'
? (
<div className={css.paneBody}>
{op !== null && op.kind === 'write'
&& prior === undefined
&& <div className={css.priorUnknown}>{t('changesPriorUnknown')}</div>}
<DiffRows key={target.op.callId} segments={opSegments} lang={opLang} />
</div>
)
: loading
? <div className={css.paneBody}><div className={css.gitPlaceholder}>{t('loading')}</div></div>
: error !== null
? <div className={css.paneBody}><div className={css.gitError}>{t('diffLoadError')}: {error}</div></div>
: (
<div className={css.paneBody}>
{diffText !== null && diffText !== '' && (
<DiffFiles
diff={diffText}
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
untrackedContent={untracked}
/>
)}
{diffText === '' && untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
</div>
)}
</div>
)
}
+734
View File
@@ -0,0 +1,734 @@
/**
* The Git lens of the changes tab: repository truth — status list (staged vs
* unstaged), stage/unstage, commit with a message box, branch switch, and a
* VSCode-like history with branch decorations, author and relative time.
* Clicking a changed file or a history row previews it in the tab's shared
* bottom pane (see {@link DiffPane}); rows carry right-click context menus
* with advanced operations (open in editor, discard, revert, cherry-pick,
* copy paths/hashes). Refresh is manual + on mount/focus. While visible it
* polls lightweight porcelain state so model-authored file changes appear
* without a manual refresh. Everything here is the former standalone git
* panel, re-homed as a lens.
*/
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import {
Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16,
IconTrashOutline16, Input, Menu, Modal, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, GitWorktree, SessionScope } from '../api.ts'
import { api } from '../api.ts'
import { baseName, isWithinWorkspace, relativeTo } from '../paths.ts'
import { resolveSidebarPath } from '../produced-files.ts'
import { relativeTime, t } from '../locales.ts'
import type { SidebarDiffRef, SidebarStore } from '../state.ts'
import css from './changes.module.css'
/** The XY status letters a row badge shows (X = index, Y = worktree). */
function badgeOf(entry: GitStatusEntry): string {
const index = entry.xy[0]
if (index !== undefined && index !== ' ' && index !== '?') return index
const worktree = entry.xy[1]
if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree
return '?'
}
/** Whether the entry carries STAGED (index) changes — the X letter is set. */
function isStagedEntry(entry: GitStatusEntry): boolean {
const index = entry.xy[0]
return index !== undefined && index !== ' ' && index !== '?'
}
/** Whether the entry carries UNSTAGED (worktree) changes — the Y letter is set
* (untracked `??` counts as unstaged: it is a worktree-only change). A file
* with both letters set ('MM') lands in BOTH sections. */
function isUnstagedEntry(entry: GitStatusEntry): boolean {
if (entry.xy === '??') return true
const worktree = entry.xy[1]
return worktree !== undefined && worktree !== ' ' && worktree !== '?'
}
/** Whether the entry is untracked (`??`): git diff never includes it. */
function isUntracked(entry: GitStatusEntry): boolean {
return badgeOf(entry) === '?'
}
/** The ref names of one log row's decorations (`HEAD -> main` → `main`), deduped. */
function refNames(refs: string): string[] {
return [...new Set(
refs
.split(',')
.map(ref => ref.trim())
.filter(ref => ref !== '')
.map(ref => (ref.includes(' -> ') ? ref.slice(ref.indexOf(' -> ') + 4) : ref))
.map(ref => (ref.startsWith('tag: ') ? ref.slice(5) : ref)),
)]
}
/** The pending destructive action (discard / revert / cherry-pick), gated by a confirm modal. */
interface ConfirmState {
title: string
description: string
confirmLabel: string
onConfirm: () => Promise<unknown>
}
/** History batch size: the log loads lazily in pages so a long history never
* floods the panel at once (the end of the log is reached by paging). */
const LOG_BATCH = 20
export interface GitLensProps {
scope: SessionScope
/** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */
store: SidebarStore
onOpenFile: (path: string) => void
/** Preview one change in the shared bottom pane (worktree or commit ref). */
onPreview: (ref: SidebarDiffRef) => void
/** The ref currently previewed (row highlight); null when the pane is closed. */
selectedRef: SidebarDiffRef | null
/** Poll only while the tab is actually visible. */
visible: boolean
}
export function GitLens(props: GitLensProps) {
const { scope, store, onOpenFile, onPreview, selectedRef, visible } = props
const [status, setStatus] = useState<GitStatusResult | null>(null)
const [worktrees, setWorktrees] = useState<GitWorktree[]>([])
const [selectedWorktree, setSelectedWorktree] = useState<string | undefined>()
const [repoRoot, setRepoRoot] = useState<string | undefined>(undefined)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [branchNames, setBranchNames] = useState<string[]>([])
const [logEntries, setLogEntries] = useState<GitLogEntry[]>([])
const [commitMsg, setCommitMsg] = useState('')
const [busy, setBusy] = useState(false)
const [commitError, setCommitError] = useState<string | null>(null)
/** Whether the history was fully paged (a batch shorter than LOG_BATCH). */
const [logEnded, setLogEnded] = useState(false)
const [logLoadingMore, setLogLoadingMore] = useState(false)
/** The open file-row context menu (cursor position for the portaled Menu). */
const [fileMenu, setFileMenu] = useState<{ entry: GitStatusEntry; staged: boolean; x: number; y: number } | null>(null)
/** The open history-row context menu. */
const [historyMenu, setHistoryMenu] = useState<{ entry: GitLogEntry; x: number; y: number } | null>(null)
/** The pending destructive action awaiting confirmation. */
const [confirm, setConfirm] = useState<ConfirmState | null>(null)
const refreshInFlight = useRef(false)
/** Monotonic request id: a manual worktree switch invalidates any older poll
* before it can publish state from the previous checkout. */
const refreshGeneration = useRef(0)
const worktreeChosenByUser = useRef(false)
/** selectedWorktree read inside refresh without re-creating the callback:
* avoids a spurious full refresh on every auto-select (the very state
* change refresh writes back via setSelectedWorktree would recreate the
* callback and re-trigger the mount effect — an N→N+1 fetch loop). */
const chosenPathRef = useRef<string | undefined>(undefined)
useEffect(() => { chosenPathRef.current = selectedWorktree }, [selectedWorktree])
const gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot }
/** Publish a complete checkout-derived view. Status, branch choices and
* history are one consistency unit: never mix rows from two worktrees. */
const refreshTarget = useCallback(async (
target: string | undefined,
options: { loading: boolean; generation: number },
): Promise<void> => {
if (options.loading) setLoading(true)
setError(null)
try {
const [statusResult, branchResult, logResult] = await Promise.all([
api.gitStatus(gitScope, target),
api.gitBranch(gitScope, target).catch(() => ({ current: '', names: [] as string[] })),
api.gitLog(gitScope, LOG_BATCH, 0, target).catch(() => [] as GitLogEntry[]),
])
if (options.generation !== refreshGeneration.current) return
setStatus(statusResult)
if (statusResult.root !== undefined && statusResult.root !== repoRoot) setRepoRoot(statusResult.root)
setBranchNames(branchResult.names)
setLogEntries(logResult)
setLogEnded(logResult.length < LOG_BATCH)
} catch (reason) {
if (options.generation === refreshGeneration.current) {
setError(reason instanceof Error ? reason.message : String(reason))
}
} finally {
if (options.loading && options.generation === refreshGeneration.current) setLoading(false)
}
}, [scope.sessionId, scope.cwd, repoRoot])
const refresh = useCallback(async (silent = false): Promise<void> => {
if (refreshInFlight.current) return
refreshInFlight.current = true
let generation = refreshGeneration.current
try {
const listed = await api.gitWorktrees(scope)
if (generation !== refreshGeneration.current) return
setWorktrees(listed)
const selectedStillExists = listed.some(entry => entry.path === chosenPathRef.current)
let target = selectedStillExists ? chosenPathRef.current : listed.find(entry => entry.current)?.path
// DSH and other coding agents commonly create one linked checkout while
// the session remains rooted at the clean primary checkout. Select that
// checkout automatically only when the choice is unambiguous.
const current = listed.find(entry => entry.current)
const dirtyLinked = listed.filter(entry => !entry.current && entry.changes > 0)
if (!worktreeChosenByUser.current) {
target = (current?.changes ?? 0) === 0 && dirtyLinked.length === 1
? dirtyLinked[0]!.path
: current?.path
}
const targetChanged = target !== chosenPathRef.current
if (targetChanged) {
// Changing the automatically selected checkout invalidates any direct
// target refresh that may still be resolving for the previous one.
generation = refreshGeneration.current += 1
chosenPathRef.current = target
setSelectedWorktree(target)
// Remove rows owned by the previous checkout immediately: keeping them
// interactive while the target changes could apply a destructive action
// to the new checkout with stale history from the old one.
setStatus(null)
setBranchNames([])
setLogEntries([])
setLogEnded(false)
setLogLoadingMore(false)
}
// A poll may update status alone only while staying on the same checkout.
// Any automatic selection change refreshes the complete derived view.
if (silent && !targetChanged) {
const statusResult = await api.gitStatus(gitScope, target)
if (generation === refreshGeneration.current) setStatus(statusResult)
return
}
await refreshTarget(target, { loading: !silent, generation })
} catch (reason) {
if (generation === refreshGeneration.current) {
setError(reason instanceof Error ? reason.message : String(reason))
if (!silent) setLoading(false)
}
} finally {
refreshInFlight.current = false
}
}, [scope.sessionId, scope.cwd, refreshTarget])
useEffect(() => {
refreshGeneration.current += 1
refreshInFlight.current = false
worktreeChosenByUser.current = false
chosenPathRef.current = undefined
setSelectedWorktree(undefined)
}, [scope.sessionId, scope.cwd])
useEffect(() => { void refresh() }, [refresh])
/** A user choice invalidates any older poll and atomically refreshes every
* checkout-derived surface before destructive history actions can run. */
const chooseWorktree = (target: string): void => {
worktreeChosenByUser.current = true
chosenPathRef.current = target
setSelectedWorktree(target)
setStatus(null)
setBranchNames([])
setLogEntries([])
setLogEnded(false)
setLogLoadingMore(false)
const generation = refreshGeneration.current += 1
void refreshTarget(target, { loading: true, generation })
}
/** Switching the selected child repository must invalidate every
* target-derived surface (status/history/log) before the asynchronous
* refresh resolves; otherwise stale rows remain actionable while their
* handlers already address the new repository. Mirrors chooseWorktree. */
const chooseRepo = (target: string): void => {
setRepoRoot(target)
setStatus(null)
setBranchNames([])
setLogEntries([])
setLogEnded(false)
setLogLoadingMore(false)
// Re-list worktrees for the selected child (a workspace container's
// own worktree list is empty); keep the current linked-checkout choice
// unless it does not belong to the new repository.
const generation = refreshGeneration.current += 1
void refreshTarget(chosenPathRef.current ?? '', { loading: true, generation })
}
useEffect(() => {
if (!visible) return
const timer = window.setInterval(() => { void refresh(true) }, 2_000)
return () => { window.clearInterval(timer) }
}, [visible, refresh])
/** Append the next history page (lazy: only when the user asks for more). */
const loadMoreLog = async (): Promise<void> => {
if (logLoadingMore || logEnded) return
const generation = refreshGeneration.current
const target = chosenPathRef.current
setLogLoadingMore(true)
try {
const next = await api.gitLog(gitScope, LOG_BATCH, logEntries.length, target)
// A worktree switch clears the old history and increments generation.
// Never append a late page from that checkout into the new one.
if (generation !== refreshGeneration.current || target !== chosenPathRef.current) return
setLogEntries(entries => [...entries, ...next])
if (next.length < LOG_BATCH) setLogEnded(true)
} catch (reason) {
if (generation === refreshGeneration.current && target === chosenPathRef.current) {
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
}
} finally {
if (generation === refreshGeneration.current && target === chosenPathRef.current) setLogLoadingMore(false)
}
}
/** The preview ref for one changed file (one ref per path+side). */
const worktreeRefOf = (entry: GitStatusEntry, staged: boolean): SidebarDiffRef => ({
kind: 'worktree',
path: entry.path,
staged,
untracked: isUntracked(entry),
worktree: selectedWorktree,
repoRoot,
})
/** The preview ref for one commit. */
const commitRefOf = (entry: GitLogEntry): SidebarDiffRef => ({
kind: 'commit',
hash: entry.hash,
hashFull: entry.hashFull,
subject: entry.subject,
worktree: selectedWorktree,
repoRoot,
})
/** Whether a worktree row is the one currently previewed. */
const isPreviewedWorktree = (entry: GitStatusEntry, staged: boolean): boolean => {
if (selectedRef === null || selectedRef.kind !== 'worktree') return false
return selectedRef.path === entry.path && selectedRef.staged === staged
&& (selectedRef.worktree ?? '') === (selectedWorktree ?? '')
}
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(gitScope, entry.path, selectedWorktree)
else await api.gitStage(gitScope, entry.path, selectedWorktree)
await refresh()
} finally {
setBusy(false)
}
}
const stageAll = async (staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(gitScope, undefined, selectedWorktree)
else await api.gitStage(gitScope, undefined, selectedWorktree)
await refresh()
} finally {
setBusy(false)
}
}
const commit = async (): Promise<void> => {
const message = commitMsg.trim()
if (message === '' || busy) return
setBusy(true)
setCommitError(null)
try {
await api.gitCommit(gitScope, message, selectedWorktree)
setCommitMsg('')
await refresh()
} catch (reason) {
setCommitError(reason instanceof Error ? reason.message : String(reason))
} finally {
setBusy(false)
}
}
const checkout = async (branch: string): Promise<void> => {
if (branch === status?.branch || busy) return
setBusy(true)
setCommitError(null)
try {
await api.gitCheckout(gitScope, branch, selectedWorktree)
await refresh()
} catch (reason) {
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
} finally {
setBusy(false)
}
}
/** Run one destructive operation after the confirm modal, then refresh. */
const runConfirmed = (confirmState: ConfirmState): void => {
setConfirm({ ...confirmState, onConfirm: async () => {
setBusy(true)
setCommitError(null)
try {
await confirmState.onConfirm()
await refresh()
} catch (reason) {
setCommitError(reason instanceof Error ? reason.message : String(reason))
} finally {
setBusy(false)
}
} })
}
/** Copy `text` to the clipboard (best-effort; no visual feedback needed — the menu closes). */
const copy = (text: string): void => {
void writeClipboard(text)
}
const openFileMenu = (event: MouseEvent, entry: GitStatusEntry, staged: boolean): void => {
event.preventDefault()
event.stopPropagation()
setFileMenu({ entry, staged, x: event.clientX, y: event.clientY })
}
const openHistoryMenu = (event: MouseEvent, entry: GitLogEntry): void => {
event.preventDefault()
event.stopPropagation()
setHistoryMenu({ entry, x: event.clientX, y: event.clientY })
}
const stagedEntries = (status?.entries ?? []).filter(isStagedEntry)
const unstagedEntries = (status?.entries ?? []).filter(isUnstagedEntry)
const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
const selected = isPreviewedWorktree(entry, staged)
return (
<div
key={`${staged ? 's' : 'u'}:${entry.path}`}
className={css.gitRow}
data-selected={selected ? 'true' : undefined}
>
<button
type="button"
className={css.gitRowMain}
title={entry.path}
onClick={() => { onPreview(worktreeRefOf(entry, staged)) }}
onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
>
<span className={css.gitBadge} data-letter={badgeOf(entry)}>{badgeOf(entry)}</span>
<span className={css.gitName}>{entry.path}</span>
</button>
<button
type="button"
className={css.iconButton}
aria-label={staged ? t('unstage') : t('stage')}
title={staged ? t('unstage') : t('stage')}
disabled={busy}
onClick={() => { void stageEntry(entry, staged) }}
>
{staged ? <IconTrashOutline16 /> : <IconBranchOutline16 />}
</button>
</div>
)
}
return (
<div className={css.git}>
{worktrees.length > 1 && (
<div className={css.gitWorktreeRow}>
<span className={css.gitWorktreeLabel}>{t('worktree')}</span>
<select
className={css.gitBranchSelect}
value={selectedWorktree ?? ''}
title={selectedWorktree}
disabled={busy}
onChange={(event) => { chooseWorktree(event.target.value) }}
>
{worktrees.map(entry => (
<option key={entry.path} value={entry.path}>
{entry.branch} · {baseName(entry.path)} ({entry.changes})
</option>
))}
</select>
</div>
)}
<div className={css.gitHeader}>
{(status?.repositories?.length ?? 0) > 1 && (
<select
className={css.gitBranchSelect}
value={repoRoot ?? ''}
title={repoRoot}
onChange={(event) => { chooseRepo(event.target.value) }}
disabled={busy}
>
{status!.repositories!.map(root => <option key={root} value={root}>{baseName(root)}</option>)}
</select>
)}
<select
className={css.gitBranchSelect}
value={status?.branch ?? ''}
onChange={(event) => { void checkout(event.target.value) }}
disabled={busy || (status !== null && !status.isRepo)}
>
{(status?.branch ?? '') !== '' && <option value={status!.branch}>{status!.branch}</option>}
{branchNames.filter(name => name !== status?.branch).map(name => <option key={name} value={name}>{name}</option>)}
</select>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { void refresh() }}
>
<IconRefreshOutline16 size={14} />
</button>
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
{!loading && error !== null && <div className={css.gitError}>{error}</div>}
{!loading && status !== null && !status.isRepo && (
<div className={css.gitPlaceholder}>{t('notRepo')}</div>
)}
{status !== null && status.isRepo && (
<>
{status.truncated === true && (
<div className={css.gitEmpty}>{t('statusTruncated')}</div>
)}
<div className={css.gitSection}>
<div className={css.gitSectionHeader}>
<span>{t('staged')} ({stagedEntries.length})</span>
{stagedEntries.length > 0 && (
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(true) }}>
{t('unstageAll')}
</button>
)}
</div>
{stagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
{stagedEntries.map(entry => renderEntry(entry, true))}
</div>
<div className={css.gitSection}>
<div className={css.gitSectionHeader}>
<span>{t('unstaged')} ({unstagedEntries.length})</span>
{unstagedEntries.length > 0 && (
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(false) }}>
{t('stageAll')}
</button>
)}
</div>
{unstagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
{unstagedEntries.map(entry => renderEntry(entry, false))}
</div>
<div className={css.gitCommit}>
<Input
className={css.gitCommitInput}
placeholder={t('commitPlaceholder')}
value={commitMsg}
disabled={busy}
onChange={(event) => { setCommitMsg(event.target.value); setCommitError(null) }}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') void commit()
}}
/>
<button
type="button"
className={css.gitCommitButton}
disabled={busy || commitMsg.trim() === '' || stagedEntries.length === 0}
onClick={() => { void commit() }}
>
{t('commit')}
</button>
</div>
{commitError !== null && <div className={css.gitError}>{commitError}</div>}
<div className={css.gitSection}>
<div className={css.gitSectionHeader}><span>{t('history')}</span></div>
{logEntries.map(entry => (
<div
key={entry.hashFull}
role="button"
tabIndex={0}
className={css.gitLogRow}
data-selected={selectedRef?.kind === 'commit' && selectedRef.hashFull === entry.hashFull ? 'true' : undefined}
title={`${entry.author} · ${entry.date}\n${entry.hashFull}`}
onClick={() => { onPreview(commitRefOf(entry)) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onPreview(commitRefOf(entry))
}
}}
onContextMenu={(event) => { openHistoryMenu(event, entry) }}
>
<span className={css.gitLogLine1}>
<span className={css.gitLogHash}>{entry.hash}</span>
<span className={css.gitLogSubject}>{entry.subject}</span>
</span>
<span className={css.gitLogLine2}>
{refNames(entry.refs).map(ref => (
<span key={ref} className={css.gitLogRef}>{ref}</span>
))}
<span className={css.gitLogMeta}>{entry.author} · {relativeTime(entry.date)}</span>
</span>
</div>
))}
{!logEnded && (
<button
type="button"
className={css.gitLogMore}
disabled={logLoadingMore || busy}
onClick={() => { void loadMoreLog() }}
>
{logLoadingMore ? t('loading') : t('loadMore')}
</button>
)}
</div>
{/*
The one shared file-row context menu, positioned at the right-click
cursor (portal so the panel's overflow clip cannot crop it).
*/}
<Menu
open={fileMenu !== null}
onClose={() => { setFileMenu(null) }}
items={[
// A linked worktree outside the session workspace cannot be
// opened in the editor while the host's workspace fence is
// armed: it rejects every path under that checkout. Hide the
// action for that checkout so the menu does not offer a no-op
// that confuses the user; with the fence disarmed (the
// `workspaceFence` pref) the open is allowed through.
...(fileMenu !== null && (store.getPrefs().workspaceFence === false || isWithinWorkspace(scope.cwd ?? '', resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, fileMenu.entry.path)))
? [{ id: 'open', label: t('openEditor'), icon: <IconCodeOutline16 size={14} /> }]
: []),
fileMenu?.staged === true
? { id: 'stage', label: t('unstage'), icon: <IconTrashOutline16 size={14} /> }
: { id: 'stage', label: t('stage'), icon: <IconBranchOutline16 size={14} /> },
...(fileMenu !== null && !isUntracked(fileMenu.entry)
? [{ id: 'discard', label: t('discard'), icon: <IconTrashOutline16 size={14} />, danger: true }]
: []),
{ type: 'separator', id: 'sep1' },
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
]}
onSelect={(id) => {
const target = fileMenu
if (target === null) return
setFileMenu(null)
if (id === 'open') {
const resolved = resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path)
// Defense-in-depth: the menu hides this action when the
// resolved path escapes the session workspace, but a
// racing repo switch could still reach here with a path
// the host would reject. No-op in that case — unless the
// workspace fence is disarmed by pref.
if (store.getPrefs().workspaceFence !== false && !isWithinWorkspace(scope.cwd ?? '', resolved)) return
onOpenFile(resolved)
return
}
if (id === 'stage') {
void stageEntry(target.entry, target.staged)
return
}
if (id === 'discard') {
runConfirmed({
title: t('discardTitle'),
description: t('discardDesc', { path: target.entry.path }),
confirmLabel: t('discard'),
onConfirm: () => api.gitDiscard(gitScope, target.entry.path, selectedWorktree),
})
return
}
if (id === 'relative') {
copy(relativeTo(repoRoot ?? selectedWorktree ?? scope.cwd ?? '', target.entry.path))
return
}
if (id === 'absolute') copy(resolveSidebarPath(repoRoot ?? selectedWorktree ?? scope.cwd, target.entry.path))
}}
portal
align="start"
getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))}
anchor={<span />}
/>
{/* The shared history-row context menu. */}
<Menu
open={historyMenu !== null}
onClose={() => { setHistoryMenu(null) }}
items={[
{ id: 'view', label: t('viewCommitDiff') },
{ id: 'copyShort', label: t('copyShortHash'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copyFull', label: t('copyFullHash'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'copySubject', label: t('copySubject'), icon: <IconCopyOutline16 size={14} /> },
{ type: 'separator', id: 'sep2' },
{ id: 'revert', label: t('revertCommit'), danger: true },
{ id: 'cherryPick', label: t('cherryPickCommit'), danger: true },
]}
onSelect={(id) => {
const target = historyMenu
if (target === null) return
setHistoryMenu(null)
if (id === 'view') {
onPreview(commitRefOf(target.entry))
return
}
if (id === 'copyShort') {
copy(target.entry.hash)
return
}
if (id === 'copyFull') {
copy(target.entry.hashFull)
return
}
if (id === 'copySubject') {
copy(target.entry.subject)
return
}
if (id === 'revert') {
runConfirmed({
title: t('revertTitle'),
description: t('revertDesc', { subject: target.entry.subject }),
confirmLabel: t('revertCommit'),
onConfirm: () => api.gitRevert(gitScope, target.entry.hashFull, selectedWorktree),
})
return
}
if (id === 'cherryPick') {
runConfirmed({
title: t('cherryPickTitle'),
description: t('cherryPickDesc', { subject: target.entry.subject }),
confirmLabel: t('cherryPickCommit'),
onConfirm: () => api.gitCherryPick(gitScope, target.entry.hashFull, selectedWorktree),
})
}
}}
portal
align="start"
getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))}
anchor={<span />}
/>
{/* Destructive actions land here first: Cancel / Confirm. */}
<Modal
open={confirm !== null}
onClose={() => { setConfirm(null) }}
title={confirm?.title ?? ''}
closeLabel={t('cancel')}
footer={(
<>
<Button variant="outline" onClick={() => { setConfirm(null) }}>{t('cancel')}</Button>
<Button
variant="primary"
disabled={busy}
onClick={() => {
const pending = confirm
if (pending === null) return
setConfirm(null)
void pending.onConfirm()
}}
>
{confirm?.confirmLabel ?? ''}
</Button>
</>
)}
>
<p className={css.gitConfirmDesc}>{confirm?.description}</p>
</Modal>
</>
)}
</div>
)
}
+127
View File
@@ -0,0 +1,127 @@
/**
* The session lens of the changes tab: agent truth — every file the model
* read, wrote, or edited in this session (folded from the session event
* log), grouped by file, newest first, with kind filters. Clicking an op
* previews it in the tab's shared bottom pane (see {@link DiffPane}): writes
* and edits as line diffs, reads as a line-numbered content view, failures
* as their real error text. Live `tool/call` / `tool/result` events append
* while the tab is visible (bounded; paused and caught up when hidden).
*/
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import type { SidebarSessionEvent } from '../../context-types.ts'
import type { Context } from '../../context-types.ts'
import type { SessionScope } from '../api.ts'
import { relativeTime, t } from '../locales.ts'
import { extractFileOps, groupByFile, knownContentBefore, type FileOp, type FileOpKind } from './ops.ts'
import { formatBytes } from '../diff/rows.ts'
import css from './changes.module.css'
/** Cap on accumulated events: the lens shows the recent window, not eternity. */
const EVENTS_CAP = 4000
/** The op-kind filter chips: 'all' or one concrete kind. */
type OpFilter = 'all' | FileOpKind
export interface SessionLensProps {
ctx: Context
scope: SessionScope
/** Preview one op in the shared bottom pane (with its best-effort prior content). */
onPreview: (path: string, op: FileOp, prior: string | undefined) => void
/** The op currently previewed (row highlight); null when the pane is closed. */
selectedCallId: string | null
/** Fold events only while the tab is actually visible. */
visible: boolean
}
export function SessionLens({ ctx, scope, onPreview, selectedCallId, visible }: SessionLensProps) {
// The event log snapshot seeds the lens once; live tool/call and
// tool/result events append (bounded) and re-extract while visible.
const eventsRef = useRef<readonly SidebarSessionEvent[] | null>(null)
if (eventsRef.current === null) {
eventsRef.current = ctx.sessions.get(scope.sessionId)?.events ?? []
}
const [tick, setTick] = useState(0)
useEffect(() => {
if (typeof ctx.on !== 'function') return
const off = ctx.on('session/event', (session: unknown, event: SidebarSessionEvent) => {
const sessionId = (session as { id?: unknown } | null)?.id
if (sessionId !== scope.sessionId) return
if (event.type !== 'tool/call' && event.type !== 'tool/result') return
const list = [...eventsRef.current ?? [], event]
eventsRef.current = list.length > EVENTS_CAP ? list.slice(list.length - EVENTS_CAP) : list
if (visible) setTick(value => value + 1)
})
return () => { off() }
}, [ctx, scope.sessionId, visible])
// Becoming visible again catches up on events appended while paused.
useEffect(() => { if (visible) setTick(value => value + 1) }, [visible])
const ops = useMemo(() => extractFileOps(eventsRef.current ?? []), [tick])
const [filter, setFilter] = useState<OpFilter>('all')
const filteredOps = useMemo(
() => (filter === 'all' ? ops : ops.filter(op => op.kind === filter)),
[ops, filter],
)
const groups = useMemo(() => groupByFile(filteredOps), [filteredOps])
const counts = useMemo(() => {
const map = new Map<FileOpKind, number>([['read', 0], ['write', 0], ['edit', 0]])
for (const op of ops) map.set(op.kind, (map.get(op.kind) ?? 0) + 1)
return map
}, [ops])
const chip = (value: OpFilter, label: string, count: number): ReactNode => (
<button
type="button"
key={value}
className={css.filterChip}
data-active={filter === value ? 'true' : undefined}
onClick={() => { setFilter(value) }}
aria-pressed={filter === value}
>
{label}{value !== 'all' ? ` ${String(count)}` : ''}
</button>
)
return (
<div className={css.session}>
<div className={css.filterRow} role="group" aria-label={t('changesSessionLens')}>
{chip('all', t('changesFilterAll'), ops.length)}
{chip('write', t('changesWrite'), counts.get('write') ?? 0)}
{chip('edit', t('changesEdit'), counts.get('edit') ?? 0)}
{chip('read', t('changesRead'), counts.get('read') ?? 0)}
</div>
<div className={css.sessionList}>
{ops.length === 0 && <div className={css.empty}>{t('changesSessionEmpty')}</div>}
{ops.length > 0 && filteredOps.length === 0 && <div className={css.empty}>{t('changesFilterEmpty')}</div>}
{[...groups.entries()].map(([path, fileOps]) => (
<div key={path} className={css.fileGroup}>
<div className={css.filePath} title={path}>{path}</div>
{fileOps.map(op => (
<button
type="button"
key={op.callId}
className={css.opRow}
data-op-kind={op.kind}
data-op-error={op.isError ? 'true' : undefined}
data-selected={selectedCallId === op.callId ? 'true' : undefined}
onClick={() => { onPreview(path, op, knownContentBefore(ops, path, op)) }}
>
<span className={css.opKind} data-kind={op.kind}>
{t(op.kind === 'read' ? 'changesRead' : op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
<span className={css.opTime}>{relativeTime(new Date(op.time).toISOString())}</span>
{op.running && <span className={css.opFlag}>{t('changesRunning')}</span>}
{op.isError && <span className={css.opFlagError}>{t('changesError')}</span>}
{op.kind !== 'read' && !op.isError && (
<span className={css.opSize}>
{formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
</span>
)}
</button>
))}
</div>
))}
</div>
</div>
)
}
+386
View File
@@ -0,0 +1,386 @@
/* The unified changes tab: lens switcher + Git lens + session lens + the
shared preview pane. Semantic --dsw-alias tokens only (skin contract).
The git lens styles moved here from sidebar.module.css when the git panel
became a lens of this tab; the generic placeholder/empty/error trio stays
duplicated in both modules (the diff tab still owns its copy). */
.root { display: flex; flex-direction: column; min-height: 0; height: 100%; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 12px; }
/* ── Lens switcher ─────────────────────────────────────────────────────── */
.lensBar { flex: none; display: flex; align-items: center; padding: 8px 8px 4px 12px; }
.lensSwitch { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; }
.lensButton { border: 0; border-radius: 6px; padding: 2px 10px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); cursor: pointer; }
.lensButton:hover { color: var(--dsw-alias-label-primary); }
.lensButton[data-active='true'] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); }
/* ── Git lens (the former git panel) ───────────────────────────────────── */
.git {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
/* Tab pages never scroll horizontally: content wraps or truncates to the
panel width instead of panning sideways. */
overflow-x: hidden;
}
.gitHeader {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
}
.gitWorktreeRow {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 0 12px;
}
.gitWorktreeLabel {
flex: none;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-11);
}
.gitBranchSelect {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 6px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.gitSection {
border-top: 1px solid var(--dsw-alias-border-l1);
}
.gitSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px 4px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-tertiary);
text-transform: uppercase;
}
.gitLink {
border: none;
background: transparent;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-brand-primary);
cursor: pointer;
padding: 0;
}
.gitLink:hover:not(:disabled) {
text-decoration: underline;
}
.gitLink:disabled {
opacity: 0.4;
cursor: default;
}
.gitRow {
display: flex;
align-items: center;
gap: 6px;
min-height: 34px;
margin: 0 6px;
padding: 0 8px;
border-radius: 8px;
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
.gitRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
}
.gitRowMain {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
padding: 3px 0;
cursor: pointer;
text-align: left;
}
.gitBadge {
flex: none;
width: 20px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font: var(--dsw-font-xxxs-strong-11);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
/* Status-letter tint: A added / D deleted / M·R modified / C·U conflicts. */
.gitBadge[data-letter='A'] { color: var(--dsw-alias-state-success-primary); }
.gitBadge[data-letter='D'] { color: var(--dsw-alias-state-error-primary); }
.gitBadge[data-letter='M'], .gitBadge[data-letter='R'] { color: var(--dsw-alias-state-business-primary); }
.gitBadge[data-letter='C'], .gitBadge[data-letter='U'] { color: var(--dsw-alias-state-warn-primary); }
.gitName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitEmpty {
padding: 4px 12px 8px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.gitPlaceholder {
padding: 16px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
text-align: center;
}
.gitError {
padding: 8px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
white-space: pre-wrap;
}
.gitConfirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
}
.gitCommit {
display: flex;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--dsw-alias-border-l1);
align-items: center;
}
.gitCommitInput {
flex: 1;
min-width: 0;
}
.gitCommitButton {
flex: none;
height: 26px;
padding: 0 12px;
border: none;
border-radius: 6px;
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-inverted);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
}
.gitCommitButton:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.gitCommitButton:disabled {
opacity: 0.45;
cursor: default;
}
/* A history row: line 1 = hash + subject, line 2 = ref badges + author/date
(badges live on the second line so they never crowd the subject). */
.gitLogRow {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 12px;
cursor: pointer;
border-radius: 8px;
}
.gitLogRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitLogRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
}
.gitLogLine1 {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.gitLogHash {
flex: none;
font: var(--dsw-font-markdown-code-block-small);
color: var(--dsw-alias-label-tertiary);
}
.gitLogLine2 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.gitLogRef {
flex: none;
padding: 0 5px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-brand-primary);
white-space: nowrap;
}
.gitLogSubject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitLogMeta {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.gitLogMore {
display: block;
width: calc(100% - 24px);
margin: 4px 12px 8px;
padding: 6px 0;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: transparent;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.gitLogMore:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.gitLogMore:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Session lens (this round's files) ─────────────────────────────────── */
.session { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.filterRow { flex: none; display: flex; gap: 4px; padding: 2px 8px 6px 12px; flex-wrap: wrap; }
.filterChip { border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 1px 8px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-11); cursor: pointer; }
.filterChip:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
.filterChip[data-active='true'] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); border-color: transparent; }
.sessionList { flex: 1; min-height: 0; overflow-y: auto; padding: 0 6px 10px; }
.empty { color: var(--dsw-alias-label-tertiary); padding: 24px 8px; text-align: center; }
.fileGroup { margin-bottom: 8px; }
.filePath { display: block; color: var(--dsw-alias-label-secondary); font-family: var(--ds-font-family-code, monospace); font-size: 11px; padding: 2px 4px; word-break: break-all; }
.opRow { display: flex; align-items: center; gap: 6px; width: 100%; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 3px 5px; cursor: pointer; text-align: left; }
.opRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.opRow[data-selected='true'] { background: var(--dsw-alias-interactive-bg-active); }
.opKind { border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); flex: none; }
.opKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.opKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
.opTime { color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; }
.opFlag { color: var(--dsw-alias-state-business-primary); font-size: 10px; flex: none; }
.opFlagError { color: var(--dsw-alias-state-error-primary); font-size: 10px; flex: none; }
.opSize { margin-left: auto; color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; }
/* ── Shared preview pane ───────────────────────────────────────────────── */
.diffPane { border-top: 1px solid var(--dsw-alias-border-l1); display: flex; flex-direction: column; flex: none; min-height: 0; }
.dragHandle { flex: none; height: 8px; cursor: ns-resize; position: relative; background: var(--dsw-alias-bg-base); }
.dragHandle::after { content: ''; position: absolute; left: calc(50% - 18px); top: 3px; width: 36px; height: 2px; border-radius: 1px; background: var(--dsw-alias-border-l2); }
.dragHandle:hover::after, .dragHandle:focus-visible::after { background: var(--dsw-alias-state-business-primary); }
.diffHead { display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
.diffKind { flex: none; border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
.diffKind[data-kind='git'] { color: var(--dsw-alias-brand-primary); }
.diffKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.diffKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
.diffPath { flex: 1; font-family: var(--ds-font-family-code, monospace); font-size: 11px; word-break: break-all; color: var(--dsw-alias-label-primary); min-width: 0; }
.diffStats { flex: none; display: inline-flex; gap: 5px; font-size: 10px; font-family: var(--ds-font-family-code, monospace); }
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
flex: none;
}
.iconButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.iconButton:disabled { opacity: 0.4; cursor: default; }
.paneBody { flex: 1; min-height: 0; overflow-y: auto; }
.readError { color: var(--dsw-alias-state-error-primary); border-left: 3px solid var(--dsw-alias-state-error-primary); border-radius: 0 6px 6px 0; margin: 6px 8px; padding: 6px 10px; white-space: pre-wrap; word-break: break-word; }
.priorUnknown { color: var(--dsw-alias-label-tertiary); font-size: 10px; padding: 3px 10px; }
/* ── Shared bits ───────────────────────────────────────────────────────── */
/* Mount fade for rows (the app's session-row reveal). */
@keyframes dsh-row-in {
from { opacity: 0; }
}
/* Keyboard focus rings match the shared sidebar convention. */
.gitLink:focus-visible, .gitRowMain:focus-visible, .gitLogRow:focus-visible,
.gitCommitButton:focus-visible, .gitLogMore:focus-visible, .gitBranchSelect:focus-visible,
.lensButton:focus-visible, .filterChip:focus-visible, .iconButton:focus-visible,
.dragHandle:focus-visible {
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
@media (prefers-reduced-motion: reduce) {
.gitRow { animation: none; }
}
+230
View File
@@ -0,0 +1,230 @@
/**
* File-operation extraction for the changes tab's session lens: folds the
* session's append-only event log (`tool/call` + `tool/result` pairs) into
* one op per file-touching call — the same semantics as the standalone
* dsh-file-trace plugin's Chat-view extraction, reading the authoritative
* event log instead (the repo's stated preference for cross-feature session
* data). Pure — no React, no DOM.
*/
import type { SidebarSessionEvent } from '../../context-types.ts'
/** The file-touching operation kinds the tracer distinguishes. */
export type FileOpKind = 'read' | 'write' | 'edit'
/** One extracted file operation. */
export interface FileOp {
/** Stable identity: the originating tool-call id. */
readonly callId: string
readonly kind: FileOpKind
/** Workspace-relative or absolute path exactly as the model spelled it. */
readonly path: string
/** Unix epoch ms of the call; the result time when only that is known. */
readonly time: number
/** True while the call has no result yet. */
readonly running: boolean
/** True when the result reported an error. */
readonly isError: boolean
/** The result's error text when isError; presented instead of the payload. */
readonly errorText?: string
/** For 'edit': the model's exact replacement payload. */
readonly edit?: { oldString: string; newString: string }
/** For 'write': the full new content. */
readonly content?: string
/** For 'read': the file content returned by the tool result. */
readonly read?: string
}
/** Tool names mapped to each op kind; unknown names are ignored. */
const READ_TOOLS = new Set(['read', 'view', 'see'])
const WRITE_TOOLS = new Set(['write', 'create'])
const EDIT_TOOLS = new Set(['edit', 'str_replace', 'str-replace-editor', 'multi-edit'])
/** Classify one tool name; undefined when the tool touches no file. */
function kindOf(name: string): FileOpKind | undefined {
if (READ_TOOLS.has(name)) return 'read'
if (WRITE_TOOLS.has(name)) return 'write'
if (EDIT_TOOLS.has(name)) return 'edit'
return undefined
}
/**
* Parse one tool-call arguments JSON body defensively: the payload is
* model-emitted wire data, so every field is checked before use.
*/
function parseArgs(argsRaw: string): Record<string, unknown> {
try {
const parsed: unknown = JSON.parse(argsRaw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
return parsed as Record<string, unknown>
} catch {
return {}
}
}
/** Extract the path field common to every file tool's arguments. */
function pathOf(args: Record<string, unknown>): string | undefined {
for (const key of ['file_path', 'path', 'filePath']) {
const value = args[key]
if (typeof value === 'string' && value.length > 0) return value
}
return undefined
}
/** The 'tool/result' message envelope inside a session event's data. */
interface ToolResultMessageLike {
source?: { kind?: unknown; callId?: unknown }
content?: unknown
}
/** One 'tool-result' content block (inner blocks carry the text). */
interface ToolResultBlockLike {
type?: unknown
content?: unknown
isError?: unknown
}
/** The finalized plain text of one tool result (inner text blocks joined). */
function resultText(message: ToolResultMessageLike): string | undefined {
if (!Array.isArray(message.content)) return undefined
const parts: string[] = []
for (const block of message.content) {
if (block === null || typeof block !== 'object') continue
const candidate = block as ToolResultBlockLike
if (candidate.type !== 'tool-result') continue
const inner = candidate.content
if (!Array.isArray(inner)) continue
for (const item of inner) {
if (item === null || typeof item !== 'object') continue
const textItem = item as { type?: unknown; text?: unknown }
if (textItem.type === 'text' && typeof textItem.text === 'string') parts.push(textItem.text)
}
}
return parts.length > 0 ? parts.join('\n') : undefined
}
/** Whether a tool result reported an error (the inner block's isError flag). */
function resultIsError(message: ToolResultMessageLike): boolean {
if (!Array.isArray(message.content)) return false
return message.content.some((block) => {
if (block === null || typeof block !== 'object') return false
return (block as ToolResultBlockLike).type === 'tool-result'
&& (block as ToolResultBlockLike).isError === true
})
}
/**
* Fold a session event log into the file operations it contains, newest
* first. A `tool/call` seeds a running op (payload from the model's
* arguments); its `tool/result` settles it (read content, error text).
* Calls dispatched through a host tool such as run_code appear as their own
* `tool/call` rows in the log, so nested file calls fold in naturally.
* @param events - the session's append-only event log (oldest → newest).
* @returns the ordered operation list.
*/
export function extractFileOps(events: readonly SidebarSessionEvent[]): FileOp[] {
const byCall = new Map<string, FileOp>()
for (const event of events) {
if (event.type === 'tool/call') {
const data = event.data as { name?: unknown; callId?: unknown; arguments?: unknown }
if (typeof data.name !== 'string' || typeof data.callId !== 'string') continue
const kind = kindOf(data.name)
if (kind === undefined) continue
const args = parseArgs(typeof data.arguments === 'string' ? data.arguments : '')
const path = pathOf(args)
if (path === undefined) continue
const base: FileOp = { callId: data.callId, kind, path, time: event.time, running: true, isError: false }
if (kind === 'edit') {
const oldString = args.old_string
const newString = args.new_string
byCall.set(data.callId, typeof oldString === 'string' && typeof newString === 'string'
? { ...base, edit: { oldString, newString } }
: base)
continue
}
if (kind === 'write') {
const content = args.content
byCall.set(data.callId, typeof content === 'string' && content.length > 0 ? { ...base, content } : base)
continue
}
byCall.set(data.callId, base)
} else if (event.type === 'tool/result') {
const message = (event.data as { message?: unknown }).message as ToolResultMessageLike | undefined
if (message === undefined) continue
const callId = message.source?.callId
if (typeof callId !== 'string') continue
const op = byCall.get(callId)
if (op === undefined) continue
const text = resultText(message)
const isError = resultIsError(message)
const patch: { running: boolean; isError: boolean; errorText?: string; read?: string; content?: string } = { running: false, isError }
if (text !== undefined && text.length > 0) {
if (isError) patch.errorText = text
else if (op.kind === 'read') patch.read = text
else if (op.kind === 'write' && op.content === undefined) patch.content = text
}
byCall.set(callId, { ...op, ...patch })
}
}
return [...byCall.values()].sort((a, b) => b.time - a.time)
}
/**
* Group operations by path, newest op first per file, files ordered by their
* most recent operation.
*/
export function groupByFile(ops: readonly FileOp[]): Map<string, FileOp[]> {
const groups = new Map<string, FileOp[]>()
for (const op of ops) {
const list = groups.get(op.path) ?? []
list.push(op)
groups.set(op.path, list)
}
const ordered = [...groups.entries()].sort((a, b) => b[1][0]!.time - a[1][0]!.time)
return new Map(ordered)
}
/**
* The last content known for a path before the given operation, synthesized
* from earlier ops: a write's payload is authoritative, an edit implies its
* old side. Best effort — a write with no known prior content diffs against
* nothing (all-added).
*/
export function knownContentBefore(ops: readonly FileOp[], path: string, before: FileOp): string | undefined {
const ofFile = ops.filter(op => op.path === path && op.time <= before.time && op !== before)
for (let i = ofFile.length - 1; i >= 0; i -= 1) {
const op = ofFile[i]!
if (op.kind === 'write' && op.content !== undefined) return op.content
if (op.kind === 'edit' && op.edit !== undefined && i === ofFile.length - 1) {
return op.edit.oldString
}
}
return undefined
}
/** One parsed read line: the file's own line number and its content text. */
export interface ReadLine { readonly line: number; readonly text: string }
/**
* Parse a DSH read result into file lines with their real line numbers:
* drops the <content> envelope and "(Showing lines ...)" note; recovers the
* "<n>: " prefix as the line number, falling back to sequential counting.
*/
export function parseReadLines(raw: string): ReadLine[] {
const contentMatch = raw.match(/<content>([\s\S]*?)<\/content>/)
const body = contentMatch ? contentMatch[1]! : raw
const result: ReadLine[] = []
let fallback = 1
for (const line of body.split('\n')) {
if (/^\s*\(Showing lines .*\)\s*$/.test(line)) continue
if (line.length === 0) continue
const match = line.match(/^\s*(\d+):\s?(.*)$/)
if (match !== null) {
result.push({ line: Number(match[1]), text: match[2] ?? '' })
fallback = Number(match[1]) + 1
} else {
result.push({ line: fallback, text: line })
fallback += 1
}
}
return result
}