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