From 069c754877b383b33a22ba9562b13784cb4f97fa Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Mon, 10 Aug 2026 22:29:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Git=20=E9=9D=A2=E6=9D=BF=E7=9C=9F?= =?UTF-8?q?=E6=AD=A3=E7=9A=84=20diff=20=E6=98=BE=E7=A4=BA=20+=20VSCode=20?= =?UTF-8?q?=E5=BC=8F=E5=8E=86=E5=8F=B2=E4=B8=8E=E5=8F=B3=E9=94=AE=E9=AB=98?= =?UTF-8?q?=E7=BA=A7=E6=93=8D=E4=BD=9C=EF=BC=8Cv0.3.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 真正的统一 diff 显示:hunk 头 + 双行号 + 红绿着色,长行换行无横向滚动,untracked 全新增视图 - 点击变更/历史自动打开独立 diff tab(首次在下半栏新增分栏,同变更去重聚焦,临时页签刷新不恢复) - 历史行:分支徽章 + 作者/相对时间,懒加载分页(20 条/页 + 加载更多) - 右键菜单:文件行(打开/暂存/放弃更改/复制路径)、历史行(复制哈希/信息/还原/捡取),破坏性操作确认弹窗 - 修复暂存/未暂存分类错误(' M' 被误标为已暂存导致 diff 恒空) - 修复 git 相对路径解析(会话在仓库子目录时 diff/discard/show/read 可用) - host 新增 git.commit-diff / git.discard / git.revert / git.cherry-pick 路由与 log 分页 --- README.md | 8 +- dsh.plugin.json | 2 +- package.json | 2 +- src/client/DiffTab.tsx | 110 ++++++++++ src/client/DiffView.tsx | 266 ++++++++++++++++++++++++ src/client/GitView.tsx | 381 +++++++++++++++++++++++++++++----- src/client/Sidebar.tsx | 25 ++- src/client/api.ts | 28 ++- src/client/icons.tsx | 10 + src/client/locales.ts | 70 +++++++ src/client/sidebar.module.css | 234 ++++++++++++++++++++- src/client/split-pane.tsx | 8 +- src/client/state.ts | 65 +++++- src/git.ts | 56 ++++- src/index.ts | 51 ++++- tests/smoke.spec.ts | 162 ++++++++++++++- tests/unit.spec.ts | 227 +++++++++++++++++++- 17 files changed, 1610 insertions(+), 95 deletions(-) create mode 100644 src/client/DiffTab.tsx create mode 100644 src/client/DiffView.tsx diff --git a/README.md b/README.md index b55675a..f86edc7 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,13 @@ dsh registry enable dsh-external/dsh-better-sidebar ### 🌿 Git 面板 -- status 暂存/未暂存分组、按文件 diff(HEAD vs 工作区/暂存区)、stage/unstage(单个与全部)、commit、分支切换、提交历史 +- **真正的 diff 显示**:解析 `git diff` 统一格式——`@@` hunk 头、旧/新双行号对齐、上下文/删除/新增行红绿着色(DSH 语义色 token),长行自动换行、无横向滚动;二进制文件显示提示、超长 diff 折叠展开、untracked 文件渲染为全新增视图 +- **点击即开 diff tab(VSCode 式)**:点击变更文件行或历史提交行,自动打开独立 diff tab——首次打开时当前栏纵向一分为二、diff 默认落在**下半栏**,之后新 diff 堆叠进同一栏;同一变更(路径+暂存侧 / 提交)只存在一个实例,重复点击聚焦已有 tab;diff tab 为临时页签(刷新后不恢复,同 VSCode diff 编辑器),带手动刷新按钮 +- **VSCode 式历史**:提交行 = 短哈希 + 主题,第二行 = 分支/标签徽章 + 作者 · 相对时间;**懒加载分页**(每页 20 条,底部「加载更多」,避免长历史挤爆面板) +- **右键高级操作**: + - 文件行:在编辑器中打开 / 暂存 / 取消暂存 / 放弃更改(确认弹窗)/ 复制相对·绝对路径 + - 历史行:查看提交差异 / 复制短哈希 / 复制完整哈希 / 复制提交信息 / 还原此提交(revert)/ 捡取此提交(cherry-pick),破坏性操作均有确认弹窗 +- 暂存/未暂存按 git XY 状态精确分组(`MM` 双区显示)、stage/unstage(单个与全部)、commit(Ctrl+Enter)、分支切换 ### 🧩 Subagent 页面 diff --git a/dsh.plugin.json b/dsh.plugin.json index b977154..315fb0c 100644 --- a/dsh.plugin.json +++ b/dsh.plugin.json @@ -1,6 +1,6 @@ { "id": "dsh-external/dsh-better-sidebar", - "version": "0.2.0", + "version": "0.3.0", "main": "./lib/index.js", "description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离", "engines": { diff --git a/package.json b/package.json index 13eacf6..91c82e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-better-sidebar", - "version": "0.2.0", + "version": "0.3.0", "description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.", "private": true, "type": "module", diff --git a/src/client/DiffTab.tsx b/src/client/DiffTab.tsx new file mode 100644 index 0000000..72827f3 --- /dev/null +++ b/src/client/DiffTab.tsx @@ -0,0 +1,110 @@ +/** + * The diff tab: one change opened from the git panel, like VSCode's diff + * editor. A worktree ref loads the file's unified diff (`git diff`, staged or + * not; untracked files — which git diff never covers — render as a full-file + * addition from their content), a commit ref loads the commit's full patch + * (`git.show`-style). The header carries a refresh button because the tab + * stays mounted while the git panel's staging/discard operations change the + * very content it shows. + */ +import { useCallback, useEffect, useState } from 'react' +import { IconRefreshOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import type { SessionScope } from './api.ts' +import { api } from './api.ts' +import type { SidebarDiffRef } from './state.ts' +import { DiffView } from './DiffView.tsx' +import { t } from './locales.ts' +import css from './sidebar.module.css' + +/** The loaded diff surface (untracked content rendered as a full addition). */ +interface DiffData { + diff: string + untracked?: string +} + +export function DiffTab(props: { sessionId: string; cwd: string | undefined; diff: SidebarDiffRef }) { + const { sessionId, cwd, diff } = props + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [data, setData] = useState(null) + const [tick, setTick] = useState(0) + + const refresh = useCallback((): void => { setTick(value => value + 1) }, []) + + useEffect(() => { + let cancelled = false + const scope: SessionScope = { sessionId, cwd } + setLoading(true) + setError(null) + setData(null) + const load = async (): Promise => { + try { + if (diff.kind === 'commit') { + const result = await api.gitCommitDiff(scope, diff.hashFull) + if (!cancelled) setData({ diff: result.diff }) + return + } + let result = await api.gitDiff(scope, diff.path, diff.staged) + if (result.diff === '') { + // The requested side is empty — try the OTHER side once: the ref + // may predate the staged-flag fix, or the change moved sides (a + // file staged after its tab opened). Both sides empty means the + // file genuinely has no text changes. + const other = await api.gitDiff(scope, diff.path, !diff.staged) + if (other.diff !== '') result = other + } + if (result.diff !== '') { + if (!cancelled) setData({ diff: result.diff }) + return + } + // Empty diff: an untracked file (git diff never lists it) falls back + // to a full-file addition; anything else is a genuine no-text-change. + if (diff.untracked === true && !diff.staged) { + const text = await api.fsRead(scope, diff.path) + if (!cancelled) { + setData(text.kind === 'text' ? { diff: '', untracked: text.content } : { diff: '' }) + } + return + } + if (!cancelled) setData({ diff: '' }) + } catch (reason) { + if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason)) + } finally { + if (!cancelled) setLoading(false) + } + } + void load() + return () => { cancelled = true } + }, [sessionId, cwd, diff, tick]) + + return ( +
+
+ + {diff.kind === 'worktree' ? diff.path : `${diff.hash} ${diff.subject}`} + + +
+ {loading &&
{t('loading')}
} + {!loading && error !== null &&
{t('diffLoadError')}: {error}
} + {!loading && error === null && data !== null && ( + <> + {data.untracked !== undefined + ? + : } + {data.diff === '' && data.untracked === undefined && ( +
{t('diffEmpty')}
+ )} + + )} +
+ ) +} diff --git a/src/client/DiffView.tsx b/src/client/DiffView.tsx new file mode 100644 index 0000000..0edd3fc --- /dev/null +++ b/src/client/DiffView.tsx @@ -0,0 +1,266 @@ +/** + * The real diff surface for the git panel: parses the host's unified diff + * text (`git diff` / `git show`) and renders it VSCode-style — per-file + * sections with hunks (`@@ -a,b +c,d @@` headers), old/new line-number + * gutters, and aligned context / deleted / added rows colored through the + * DSH tokens. Untracked files produce no `git diff` output, so the caller + * can pass the file content to render as a full-file addition instead. + * + * The parser is a pure function (`parseUnifiedDiff`) so the interesting + * cases are unit-tested without a DOM. + */ +import { useMemo, useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { t } from './locales.ts' +import css from './sidebar.module.css' + +/** One rendered diff line. */ +export interface DiffLine { + kind: 'ctx' | 'del' | 'add' | 'meta' + /** The line content without its diff marker ('' for the no-newline marker). */ + text: string + /** Old-side line number (null for pure additions / metadata). */ + oldNum: number | null + /** New-side line number (null for pure deletions / metadata). */ + newNum: number | null +} + +/** One parsed hunk. */ +export interface DiffHunk { + /** The old-side start line (`-a[,b]`). */ + oldStart: number + /** The new-side start line (`+c[,d]`). */ + newStart: number + /** The section text after the trailing `@@` (may be empty). */ + header: string + lines: DiffLine[] +} + +/** One parsed file section of a unified diff. */ +export interface DiffFile { + /** The `---` path verbatim ('/dev/null' for a new file). */ + oldPath: string + /** The `+++` path verbatim ('/dev/null' for a deleted file). */ + newPath: string + /** The file changed with binary content: no hunks to draw. */ + binary: boolean + hunks: DiffHunk[] +} + +/** The parsed unified diff. */ +export interface ParsedDiff { + files: DiffFile[] +} + +/** Parse the hunk header `@@ -a[,b] +c[,d] @@ section` (section may contain '@@'). */ +function parseHunkHeader(line: string): { oldStart: number; newStart: number; header: string } | null { + const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/.exec(line) + if (match === null) return null + return { oldStart: Number(match[1]), newStart: Number(match[3]), header: match[5] ?? '' } +} + +/** + * Parse `git diff --no-color` output into file sections and hunks. Rows + * outside a file section (leading noise) and metadata rows between the + * `diff --git`/`---`/`+++` headers and the first hunk (index lines, mode + * changes, rename/similarity lines) are skipped; a section that never + * reaches a hunk (a mode/rename-only change) stays hunkless so the caller + * can still draw its path. + */ +export function parseUnifiedDiff(text: string): ParsedDiff { + const files: DiffFile[] = [] + let current: DiffFile | null = null + let inHunk = false + let hunk: DiffHunk | null = null + let oldNum = 0 + let newNum = 0 + const flushHunk = (): void => { + if (current !== null && hunk !== null) current.hunks.push(hunk) + hunk = null + inHunk = false + } + for (const raw of text.split('\n')) { + if (raw.startsWith('diff --git ')) { + flushHunk() + current = { oldPath: '', newPath: '', binary: false, hunks: [] } + files.push(current) + continue + } + if (current === null) continue + if (raw.startsWith('Binary files ') || raw === 'GIT binary patch') { + flushHunk() + current.binary = true + continue + } + if (raw.startsWith('--- ')) { + flushHunk() + current.oldPath = raw.slice(4) + continue + } + if (raw.startsWith('+++ ')) { + current.newPath = raw.slice(4) + continue + } + const header = parseHunkHeader(raw) + if (header !== null) { + flushHunk() + hunk = { oldStart: header.oldStart, newStart: header.newStart, header: header.header, lines: [] } + oldNum = header.oldStart + newNum = header.newStart + inHunk = true + continue + } + if (!inHunk || hunk === null) continue + const marker = raw[0] + if (marker === '\\') { + // `\ No newline at end of file`: metadata attached to the previous row. + hunk.lines.push({ kind: 'meta', text: raw.slice(1), oldNum: null, newNum: null }) + continue + } + if (marker === ' ') { + hunk.lines.push({ kind: 'ctx', text: raw.slice(1), oldNum, newNum }) + oldNum += 1 + newNum += 1 + } else if (marker === '-') { + hunk.lines.push({ kind: 'del', text: raw.slice(1), oldNum, newNum: null }) + oldNum += 1 + } else if (marker === '+') { + hunk.lines.push({ kind: 'add', text: raw.slice(1), oldNum: null, newNum }) + newNum += 1 + } else { + // Not a diff line (a hunk can never contain one): stop the hunk. + flushHunk() + } + } + flushHunk() + return { files } +} + +/** Build the untracked-file shape: one file, one hunk of pure additions. */ +function untrackedFile(path: string, content: string): DiffFile { + const lines: DiffLine[] = [] + const body = content.endsWith('\n') ? content.slice(0, -1) : content + if (body !== '') { + let num = 1 + for (const line of body.split('\n')) { + lines.push({ kind: 'add', text: line, oldNum: null, newNum: num }) + num += 1 + } + } + return { oldPath: '/dev/null', newPath: `b/${path}`, binary: false, hunks: [{ oldStart: 0, newStart: 1, header: '', lines }] } +} + +/** Strip the `a/` / `b/` prefix git puts on diff paths (not on /dev/null). */ +function displayPath(path: string): string { + if (path === '/dev/null') return path + if (path.startsWith('a/') || path.startsWith('b/')) return path.slice(2) + return path +} + +/** The file header badge: added / deleted / renamed / binary ('' for a plain edit). */ +function fileTag(file: DiffFile): string | null { + if (file.binary) return t('diffBinary') + if (file.oldPath === '/dev/null') return t('diffAdded') + if (file.newPath === '/dev/null') return t('diffDeleted') + const oldPath = displayPath(file.oldPath) + const newPath = displayPath(file.newPath) + if (oldPath !== newPath) return t('diffRenamed') + return null +} + +/** Cap the flattened rows like DiffBlock: head + tail, expand button between. */ +const MAX_DIFF_ROWS = 500 + +export interface DiffViewProps { + /** Unified diff text (`git.diff` or `git.commit-diff` payloads). */ + diff: string + /** Untracked-file content: when present, renders as a full-file addition instead of parsing. */ + untrackedPath?: string + untrackedContent?: string +} + +export function DiffView({ diff, untrackedPath, untrackedContent }: DiffViewProps) { + const parsed = useMemo(() => { + if (untrackedPath !== undefined) { + return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] } + } + return parseUnifiedDiff(diff) + }, [diff, untrackedPath, untrackedContent]) + const [expanded, setExpanded] = useState(false) + + // Flatten into display rows so the cap can slice a single list. + const rows = useMemo(() => { + const out: Array<{ key: string; file: DiffFile; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = [] + parsed.files.forEach((file, fileIndex) => { + out.push({ key: `f${fileIndex}`, file, type: 'path' }) + if (file.binary) return + file.hunks.forEach((hunk, hunkIndex) => { + out.push({ key: `f${fileIndex}h${hunkIndex}`, file, type: 'hunk', hunk }) + hunk.lines.forEach((line, lineIndex) => { + out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, type: 'line', hunk, line }) + }) + }) + }) + return out + }, [parsed]) + + const hidden = rows.length - MAX_DIFF_ROWS + const capped = hidden > 0 && !expanded + const headLines = Math.ceil(MAX_DIFF_ROWS / 2) + const tailLines = MAX_DIFF_ROWS - headLines + const head = capped ? rows.slice(0, headLines) : rows + const tail = capped ? rows.slice(rows.length - tailLines) : [] + + if (rows.length === 0) return null + + const renderRow = (row: (typeof rows)[number]): ReactNode => { + if (row.type === 'path') { + const tag = fileTag(row.file) + const from = displayPath(row.file.oldPath) + const to = displayPath(row.file.newPath) + return ( +
+ {to} + {from !== to && ← {from}} + {tag !== null && {tag}} +
+ ) + } + if (row.type === 'hunk') { + const hunk = row.hunk! + return ( +
+ @@ -{hunk.oldStart},{hunk.lines.filter(l => l.oldNum !== null).length} +{hunk.newStart},{hunk.lines.filter(l => l.newNum !== null).length} @@ + {hunk.header !== '' && {hunk.header}} +
+ ) + } + const line = row.line! + const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx + return ( +
+ {line.kind === 'meta' + ? {line.text} + : ( + <> + {line.oldNum ?? ''} + {line.newNum ?? ''} + {line.text} + + )} +
+ ) + } + + return ( +
+ {head.map(renderRow)} + {hidden > 0 && ( + + )} + {tail.map(renderRow)} +
+ ) +} diff --git a/src/client/GitView.tsx b/src/client/GitView.tsx index 8940d87..272476f 100644 --- a/src/client/GitView.tsx +++ b/src/client/GitView.tsx @@ -1,19 +1,23 @@ /** - * The source-control panel: status list (staged vs unstaged), per-file diff - * through the shared DiffBlock (old = HEAD/index content, new = worktree or - * index content), stage/unstage, commit with a message box, branch list with - * switch, and recent history. Refresh is manual + on mount/focus (no file - * watcher — KISS). + * The source-control panel: status list (staged vs unstaged), stage/unstage, + * commit with a message box, branch switch, and a VSCode-like history — rows + * carry branch decorations, author and relative time. Clicking a changed + * file or a history row opens a dedicated diff TAB (see {@link DiffTab}), + * placed below the git pane on first use. File rows and history rows open a + * right-click context menu with advanced operations (open in editor, discard, + * revert, cherry-pick, copy paths/hashes). Refresh is manual + on mount/ + * focus (no file watcher — KISS). */ -import { useCallback, useEffect, useState, type ReactNode } from 'react' -import clsx from 'clsx' +import { useCallback, useEffect, useState, type MouseEvent, type ReactNode } from 'react' import { - DiffBlock, IconBranchOutline16, IconRefreshOutline16, IconTrashOutline16, - Input, + Button, IconBranchOutline16, IconCodeOutline16, IconCopyOutline16, IconRefreshOutline16, + IconTrashOutline16, Input, Menu, Modal, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts' import { api } from './api.ts' -import { t } from './locales.ts' +import { relativeTo } from './paths.ts' +import { relativeTime, t } from './locales.ts' +import type { SidebarTab } from './state.ts' import css from './sidebar.module.css' /** The XY status letters a row badge shows (X = index, Y = worktree). */ @@ -25,8 +29,63 @@ function badgeOf(entry: GitStatusEntry): string { return '?' } -export function GitView(props: { scope: SessionScope }) { - const { scope } = props +/** 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 last path segment (tab title for a file's diff). */ +function baseName(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} + +/** 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 +} + +/** 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 function GitView(props: { + scope: SessionScope + onOpenFile: (path: string) => void + /** Open a diff tab (the shell places it below the git pane on first use). */ + onOpenDiff: (tab: SidebarTab) => void +}) { + const { scope, onOpenFile, onOpenDiff } = props const [status, setStatus] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -35,12 +94,16 @@ export function GitView(props: { scope: SessionScope }) { const [commitMsg, setCommitMsg] = useState('') const [busy, setBusy] = useState(false) const [commitError, setCommitError] = useState(null) + /** Whether the history was fully paged (a batch shorter than LOG_BATCH). */ + const [logEnded, setLogEnded] = useState(false) + const [logLoadingMore, setLogLoadingMore] = useState(false) - // Selected diff: path + side (staged) + the two text sides for DiffBlock. - const [diffPath, setDiffPath] = useState(null) - const [diffStaged, setDiffStaged] = useState(false) - const [diffOld, setDiffOld] = useState(null) - const [diffNew, setDiffNew] = useState('') + /** 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(null) const refresh = useCallback(async (): Promise => { setLoading(true) @@ -49,11 +112,13 @@ export function GitView(props: { scope: SessionScope }) { const [statusResult, branchResult, logResult] = await Promise.all([ api.gitStatus(scope), api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })), - api.gitLog(scope).catch(() => [] as GitLogEntry[]), + // The first history page only; the rest arrives via "load more". + api.gitLog(scope, LOG_BATCH, 0).catch(() => [] as GitLogEntry[]), ]) setStatus(statusResult) setBranchNames(branchResult.names) setLogEntries(logResult) + setLogEnded(logResult.length < LOG_BATCH) } catch (reason) { setError(reason instanceof Error ? reason.message : String(reason)) } finally { @@ -63,23 +128,39 @@ export function GitView(props: { scope: SessionScope }) { useEffect(() => { void refresh() }, [refresh]) - /** Load the diff sides for one entry: HEAD content vs worktree/index content. */ - const selectDiff = async (entry: GitStatusEntry, staged: boolean): Promise => { - setDiffPath(entry.path) - setDiffStaged(staged) - setDiffOld(null) - setDiffNew('') - const oldPromise = api.gitShow(scope, 'HEAD', entry.path) - .then(result => result.content) - .catch(() => null) - const newPromise = staged - ? api.gitShow(scope, ':', entry.path).then(result => result.content ?? '').catch(() => '') - : api.fsRead(scope, entry.path) - .then(result => (result.kind === 'text' ? result.content : '')) - .catch(() => '') - const [oldText, newText] = await Promise.all([oldPromise, newPromise]) - setDiffOld(oldText) - setDiffNew(newText) + /** Append the next history page (lazy: only when the user asks for more). */ + const loadMoreLog = async (): Promise => { + if (logLoadingMore || logEnded) return + setLogLoadingMore(true) + try { + const next = await api.gitLog(scope, LOG_BATCH, logEntries.length) + setLogEntries(entries => [...entries, ...next]) + if (next.length < LOG_BATCH) setLogEnded(true) + } catch (reason) { + setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`) + } finally { + setLogLoadingMore(false) + } + } + + /** The diff tab for one changed file (one tab per path+side; same id = focused). */ + const openWorktreeDiff = (entry: GitStatusEntry, staged: boolean): void => { + onOpenDiff({ + id: `diff:w:${staged ? 's' : 'u'}:${entry.path}`, + type: 'diff', + title: baseName(entry.path), + diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry) }, + }) + } + + /** The diff tab for one commit (one tab per commit). */ + const openCommitDiff = (entry: GitLogEntry): void => { + onOpenDiff({ + id: `diff:c:${entry.hashFull}`, + type: 'diff', + title: `${entry.hash} ${entry.subject}`, + diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject }, + }) } const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise => { @@ -87,7 +168,6 @@ export function GitView(props: { scope: SessionScope }) { try { if (staged) await api.gitUnstage(scope, entry.path) else await api.gitStage(scope, entry.path) - setDiffPath(null) await refresh() } finally { setBusy(false) @@ -99,7 +179,6 @@ export function GitView(props: { scope: SessionScope }) { try { if (staged) await api.gitUnstage(scope) else await api.gitStage(scope) - setDiffPath(null) await refresh() } finally { setBusy(false) @@ -114,7 +193,6 @@ export function GitView(props: { scope: SessionScope }) { try { await api.gitCommit(scope, message) setCommitMsg('') - setDiffPath(null) await refresh() } catch (reason) { setCommitError(reason instanceof Error ? reason.message : String(reason)) @@ -129,7 +207,6 @@ export function GitView(props: { scope: SessionScope }) { setCommitError(null) try { await api.gitCheckout(scope, branch) - setDiffPath(null) await refresh() } catch (reason) { setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`) @@ -138,18 +215,51 @@ export function GitView(props: { scope: SessionScope }) { } } - const stagedEntries = (status?.entries ?? []).filter(entry => badgeOf(entry) !== '?') - const unstagedEntries = (status?.entries ?? []).filter(entry => badgeOf(entry) === '?') + /** 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 = diffPath === entry.path && diffStaged === staged return ( -
+
- {diffPath !== null && ( -
- -
- )} -
{t('history')}
{logEntries.map(entry => ( -
- {entry.hash} - {entry.subject} +
{ openCommitDiff(entry) }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + openCommitDiff(entry) + } + }} + onContextMenu={(event) => { openHistoryMenu(event, entry) }} + > + + {entry.hash} + {entry.subject} + + + {refNames(entry.refs).map(ref => ( + {ref} + ))} + {entry.author} · {relativeTime(entry.date)} +
))} + {!logEnded && ( + + )}
+ + {/* + The one shared file-row context menu, positioned at the right-click + cursor (portal so the panel's overflow clip cannot crop it). + */} + { setFileMenu(null) }} + items={[ + { id: 'open', label: t('openEditor'), icon: }, + fileMenu?.staged === true + ? { id: 'stage', label: t('unstage'), icon: } + : { id: 'stage', label: t('stage'), icon: }, + ...(fileMenu !== null && !isUntracked(fileMenu.entry) + ? [{ id: 'discard', label: t('discard'), icon: , danger: true }] + : []), + { type: 'separator', id: 'sep1' }, + { id: 'relative', label: t('copyRelative'), icon: }, + { id: 'absolute', label: t('copyAbsolute'), icon: }, + ]} + onSelect={(id) => { + const target = fileMenu + if (target === null) return + setFileMenu(null) + if (id === 'open') { + onOpenFile(target.entry.path) + 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(scope, target.entry.path), + }) + return + } + if (id === 'relative') { + copy(relativeTo(scope.cwd ?? '', target.entry.path)) + return + } + if (id === 'absolute') copy(target.entry.path) + }} + portal + align="start" + getAnchorRect={() => (fileMenu === null ? null : new DOMRect(fileMenu.x, fileMenu.y, 0, 0))} + anchor={} + /> + + {/* The shared history-row context menu. */} + { setHistoryMenu(null) }} + items={[ + { id: 'view', label: t('viewCommitDiff') }, + { id: 'copyShort', label: t('copyShortHash'), icon: }, + { id: 'copyFull', label: t('copyFullHash'), icon: }, + { id: 'copySubject', label: t('copySubject'), icon: }, + { 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') { + openCommitDiff(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(scope, target.entry.hashFull), + }) + return + } + if (id === 'cherryPick') { + runConfirmed({ + title: t('cherryPickTitle'), + description: t('cherryPickDesc', { subject: target.entry.subject }), + confirmLabel: t('cherryPickCommit'), + onConfirm: () => api.gitCherryPick(scope, target.entry.hashFull), + }) + } + }} + portal + align="start" + getAnchorRect={() => (historyMenu === null ? null : new DOMRect(historyMenu.x, historyMenu.y, 0, 0))} + anchor={} + /> + + {/* Destructive actions land here first: Cancel / Confirm. */} + { setConfirm(null) }} + title={confirm?.title ?? ''} + closeLabel={t('cancel')} + footer={( + <> + + + + )} + > +

{confirm?.description}

+
)}
diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index 629007b..77bdab8 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -15,7 +15,7 @@ import clsx from 'clsx' import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context, SidebarConversation, SidebarSessionList } from '../context-types.ts' import { - agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab, + agentUuidOf, allLeaves, closeTab, isAgentTabId, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openDiffTab, openTab, reconcileAgentTerminals, resizeSplit, setWidth, toggleExpanded, togglePanel, TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode, @@ -29,6 +29,7 @@ import { ExplorerView } from './ExplorerView.tsx' import { EditorView } from './EditorView.tsx' import { TerminalView } from './TerminalView.tsx' import { GitView } from './GitView.tsx' +import { DiffTab } from './DiffTab.tsx' import { SubagentView } from './SubagentView.tsx' import { detectNewDirectSubagent } from './subagent-detect.ts' import { t } from './locales.ts' @@ -54,8 +55,10 @@ function TabContent(props: { visible: boolean /** Fired before a topology node jumps to its child session (see Sidebar). */ onSubagentJump: (childSessionId: string) => void + /** Open a diff tab from the git panel (placement handled by the store). */ + onOpenDiff: (tab: SidebarTab) => void }) { - const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump } = props + const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store, visible, onSubagentJump, onOpenDiff } = props const scope = { sessionId, cwd } switch (tab.type) { case 'explorer': @@ -70,7 +73,17 @@ function TabContent(props: { /> ) case 'git': - return + return ( + { openSidebarFile(ctx, store, sessionId, path) }} + onOpenDiff={onOpenDiff} + /> + ) + case 'diff': + return tab.diff === undefined + ? null + : case 'terminal': return case 'editor': @@ -390,9 +403,10 @@ export function Sidebar(props: { ctx: Context; store: SidebarStore }) { * Render one tab's content. `active` (from the workbench) tells whether * this tab is the active one in its pane; combined with the panel's * open/closed state it gates live views (the Subagent topology pauses its - * polling while the page is not actually visible). + * polling while the page is not actually visible). The pane id travels + * with the tab so diff tabs can split below their source pane. */ - const renderTab = (tab: SidebarTab, active: boolean) => ( + const renderTab = (tab: SidebarTab, active: boolean, paneId: string) => ( { subagentJumpRef.current = childSessionId }} + onOpenDiff={(diffTab) => { store.reduce(s => openDiffTab(s, paneId, diffTab)) }} /> ) diff --git a/src/client/api.ts b/src/client/api.ts index 172ba40..5583565 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -40,10 +40,16 @@ export interface GitStatusResult { /** One git log row. */ export interface GitLogEntry { + /** Short hash (7+ chars, display). */ hash: string + /** Full 40-char hash (advanced operations). */ + hashFull: string subject: string author: string + /** ISO 8601 author date (`%ai`). */ date: string + /** Ref decorations (--decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */ + refs: string } /** Text read result. */ @@ -100,8 +106,6 @@ export const api = { call('git.status', scopePayload(scope, {}), signal), gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) => call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal), - gitShow: (scope: SessionScope, rev: string, path: string, signal?: AbortSignal) => - call<{ content: string | null }>('git.show', scopePayload(scope, { rev, path }), signal), gitStage: (scope: SessionScope, path?: string) => call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })), gitUnstage: (scope: SessionScope, path?: string) => @@ -112,8 +116,24 @@ export const api = { call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal), gitCheckout: (scope: SessionScope, branch: string) => call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })), - gitLog: (scope: SessionScope, signal?: AbortSignal) => - call('git.log', scopePayload(scope, {}), signal), + /** Recent commit history, lazily pageable (skip/count; defaults 0/30). */ + gitLog: (scope: SessionScope, count?: number, skip?: number, signal?: AbortSignal) => + call('git.log', scopePayload(scope, { + ...(count !== undefined ? { count } : {}), + ...(skip !== undefined ? { skip } : {}), + }), signal), + /** Full patch text of one commit (diff display for the history rows). */ + gitCommitDiff: (scope: SessionScope, hash: string, signal?: AbortSignal) => + call<{ diff: string }>('git.commit-diff', scopePayload(scope, { hash }), signal), + /** Discard the worktree changes of one file (the index is untouched). */ + gitDiscard: (scope: SessionScope, path: string) => + call<{ ok: true }>('git.discard', scopePayload(scope, { path })), + /** Revert one commit onto the current branch. */ + gitRevert: (scope: SessionScope, hash: string) => + call<{ ok: true }>('git.revert', scopePayload(scope, { hash })), + /** Cherry-pick one commit onto the current branch. */ + gitCherryPick: (scope: SessionScope, hash: string) => + call<{ ok: true }>('git.cherry-pick', scopePayload(scope, { hash })), /** Release a terminal's process immediately (tab closed; the WS close frame * may be unreachable while the socket is down, so the host also accepts * this explicit route). */ diff --git a/src/client/icons.tsx b/src/client/icons.tsx index ff2cad9..e9b2b90 100644 --- a/src/client/icons.tsx +++ b/src/client/icons.tsx @@ -22,6 +22,15 @@ export const IconTerminalOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** Diff glyph in the app's outline style: a file frame with a plus and a minus row. */ +export const IconDiffOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + +) + /** The one icon per tab type (shared by tabs, the + menu, and pane cards). */ export function tabTypeIcon(type: TabType, size = 16): ReactNode { switch (type) { @@ -30,5 +39,6 @@ export function tabTypeIcon(type: TabType, size = 16): ReactNode { case 'terminal': return case 'subagent': return case 'editor': return + case 'diff': return } } diff --git a/src/client/locales.ts b/src/client/locales.ts index cffef70..87f057a 100644 --- a/src/client/locales.ts +++ b/src/client/locales.ts @@ -54,6 +54,34 @@ const zh = { changes: '变更', staged: '已暂存', unstaged: '未暂存', + cancel: '取消', + diffEmpty: '没有文本差异', + diffLoadError: '加载差异失败', + diffBinary: '二进制', + diffAdded: '新增', + diffDeleted: '删除', + diffRenamed: '重命名', + diffExpand: '展开其余 {count} 行', + diffCollapse: '收起', + discard: '放弃更改', + discardTitle: '放弃更改', + discardDesc: '将丢弃「{path}」的工作区修改(不可恢复)。', + viewCommitDiff: '查看提交差异', + copyShortHash: '复制短哈希', + copyFullHash: '复制完整哈希', + copySubject: '复制提交信息', + revertCommit: '还原此提交', + revertTitle: '还原此提交', + revertDesc: '将在当前分支创建一个反转「{subject}」的新提交。', + cherryPickCommit: '捡取此提交', + cherryPickTitle: '捡取此提交', + cherryPickDesc: '将「{subject}」的更改应用到当前分支。', + timeJustNow: '刚刚', + timeMinutesAgo: '{n} 分钟前', + timeHoursAgo: '{n} 小时前', + timeYesterday: '昨天', + loadMore: '加载更多', + historyLoadError: '加载更多历史失败', produced: '本次产出', producedOpen: '在侧边栏中打开', disconnected: '终端连接断开,重连中…', @@ -158,6 +186,34 @@ const en: Record = { changes: 'Changes', staged: 'Staged', unstaged: 'Unstaged', + cancel: 'Cancel', + diffEmpty: 'No text changes', + diffLoadError: 'Failed to load diff', + diffBinary: 'Binary', + diffAdded: 'Added', + diffDeleted: 'Deleted', + diffRenamed: 'Renamed', + diffExpand: 'Expand {count} more rows', + diffCollapse: 'Collapse', + discard: 'Discard changes', + discardTitle: 'Discard changes', + discardDesc: 'This discards the worktree changes of "{path}" (not recoverable).', + viewCommitDiff: 'View commit diff', + copyShortHash: 'Copy short hash', + copyFullHash: 'Copy full hash', + copySubject: 'Copy subject', + revertCommit: 'Revert commit', + revertTitle: 'Revert commit', + revertDesc: 'Create a new commit on the current branch that reverts "{subject}".', + cherryPickCommit: 'Cherry-pick commit', + cherryPickTitle: 'Cherry-pick commit', + cherryPickDesc: 'Apply the changes of "{subject}" to the current branch.', + timeJustNow: 'just now', + timeMinutesAgo: '{n} min ago', + timeHoursAgo: '{n} h ago', + timeYesterday: 'yesterday', + loadMore: 'Load more', + historyLoadError: 'Failed to load more history', produced: 'Produced', producedOpen: 'Open in sidebar', disconnected: 'Terminal disconnected, reconnecting…', @@ -233,3 +289,17 @@ export function t(key: CopyKey, params?: Record): strin export function isZh(): boolean { return typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh') } + +/** Format an ISO 8601 author date relative to now (刚刚 / N 分钟前 / N 小时前 / 昨天 / date). */ +export function relativeTime(iso: string): string { + const then = Date.parse(iso) + if (Number.isNaN(then)) return iso + const seconds = Math.floor((Date.now() - then) / 1000) + if (seconds < 60) return t('timeJustNow') + if (seconds < 3600) return t('timeMinutesAgo', { n: Math.floor(seconds / 60) }) + if (seconds < 86400) return t('timeHoursAgo', { n: Math.floor(seconds / 3600) }) + if (seconds < 172800) return t('timeYesterday') + const date = new Date(then) + const pad = (value: number): string => String(value).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css index 8b99208..3ed2a10 100644 --- a/src/client/sidebar.module.css +++ b/src/client/sidebar.module.css @@ -122,6 +122,7 @@ .panelBody { flex: 1; min-height: 0; + min-width: 0; display: flex; } @@ -1051,9 +1052,13 @@ .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 { @@ -1189,6 +1194,167 @@ padding: 8px; } +/* The diff tab (a change opened from the git panel, like VSCode's diff + editor): header + scrollable diff body, no horizontal panning. */ +.gitDiffTab { + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + flex-direction: column; + overflow-y: auto; + overflow-x: hidden; +} + +.gitDiffTabHeader { + flex: none; + display: flex; + align-items: center; + gap: 8px; + height: 36px; + padding: 0 8px 0 12px; + border-bottom: 1px solid var(--dsw-alias-border-l1); +} + +.gitDiffTabTitle { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font: var(--dsw-font-xxs-strong-12); + color: var(--dsw-alias-label-primary); +} + +/* One changed file's section: path header, hunk headers, aligned lines. */ +.gitDiffFile { + display: flex; + align-items: baseline; + gap: 6px; + padding: 8px 2px 2px; +} + +.gitDiffFilePath { + font: var(--dsw-font-xxs-strong-12); + color: var(--dsw-alias-label-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.gitDiffFileOld { + flex: none; + font: var(--dsw-font-xxxs-11); + color: var(--dsw-alias-label-tertiary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 40%; +} + +.gitDiffFileTag { + flex: none; + padding: 0 6px; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 999px; + font: var(--dsw-font-xxxs-strong-11); + color: var(--dsw-alias-label-secondary); +} + +.gitDiffHunk { + display: flex; + gap: 8px; + padding: 3px 2px; + font: var(--dsw-font-markdown-code-block-small); + color: var(--dsw-alias-label-tertiary); +} + +.gitDiffHunkHeader { + flex: none; + color: var(--dsw-alias-label-secondary); +} + +.gitDiffHunkSection { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* One aligned diff line: old gutter / new gutter / code. Lines WRAP instead + of scrolling horizontally (the tab-page rule: fill the width adaptively), + with the long-token fallback breaking anywhere a space never comes. */ +.gitDiffLine { + display: flex; + align-items: stretch; + font: var(--dsw-font-markdown-code-block-small); + line-height: 20px; + white-space: pre-wrap; + overflow-wrap: anywhere; + min-width: 0; +} + +.gitDiffNum { + flex: none; + width: 36px; + padding-right: 8px; + text-align: right; + color: var(--dsw-alias-label-tertiary); + user-select: none; +} + +.gitDiffCode { + flex: 1; + min-width: 0; + overflow: visible; +} + +.gitDiffCtx { + color: var(--dsw-alias-label-primary); +} + +.gitDiffDel { + color: var(--dsw-alias-state-error-primary); + background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); +} + +.gitDiffAdd { + color: var(--dsw-alias-state-success-primary); + background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); +} + +.gitDiffMeta { + padding-left: 2px; +} + +.gitDiffMetaText { + font: var(--dsw-font-xxxs-11); + font-style: italic; + color: var(--dsw-alias-label-tertiary); +} + +.gitDiffExpand { + display: block; + width: 100%; + margin: 4px 0; + border: none; + background: transparent; + font: var(--dsw-font-xxs-12); + color: var(--dsw-alias-brand-primary); + cursor: pointer; + text-align: center; +} + +.gitDiffExpand:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.gitConfirmDesc { + margin: 0; + font: var(--dsw-font-s-14); + color: var(--dsw-alias-label-primary); + white-space: pre-wrap; +} + .gitCommit { display: flex; gap: 6px; @@ -1223,12 +1389,26 @@ 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); +} + +.gitLogLine1 { display: flex; align-items: baseline; gap: 8px; - padding: 3px 12px; - font: var(--dsw-font-xxs-12); + min-width: 0; } .gitLogHash { @@ -1237,15 +1417,62 @@ 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; +} + /* ── Intercepted produced-files row ────────────────────────────────────── */ .producedRow { @@ -1307,6 +1534,9 @@ .gitRowMain:focus-visible, .gitLink:focus-visible, .gitCommitButton:focus-visible, +.gitLogRow:focus-visible, +.gitLogMore:focus-visible, +.gitDiffExpand:focus-visible, .terminalRetry:focus-visible, .editorModeButton:focus-visible, .editorDownloadLink:focus-visible, diff --git a/src/client/split-pane.tsx b/src/client/split-pane.tsx index 15e67d7..bdd20fd 100644 --- a/src/client/split-pane.tsx +++ b/src/client/split-pane.tsx @@ -122,7 +122,7 @@ function LeafView(props: { newTabOptions: NewTabOption[] actions: WorkbenchActions onNewTab: (optionId: string) => void - renderTab: (tab: SidebarTab, active: boolean) => ReactNode + renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode }) { const { leaf, newTabOptions, actions, onNewTab, renderTab } = props const [dropZone, setDropZone] = useState(null) @@ -196,7 +196,7 @@ function LeafView(props: { key={tab.id} className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)} > - {renderTab(tab, tab.id === activeTab?.id)} + {renderTab(tab, tab.id === activeTab?.id, leaf.id)}
))} @@ -214,7 +214,7 @@ function NodeView(props: { newTabOptions: NewTabOption[] actions: WorkbenchActions onNewTab: (optionId: string) => void - renderTab: (tab: SidebarTab, active: boolean) => ReactNode + renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode }) { const { node, state, newTabOptions, actions, onNewTab, renderTab } = props if (node.kind === 'leaf') { @@ -264,7 +264,7 @@ export function Workbench(props: { newTabOptions: NewTabOption[] actions: WorkbenchActions onNewTab: (optionId: string) => void - renderTab: (tab: SidebarTab, active: boolean) => ReactNode + renderTab: (tab: SidebarTab, active: boolean, paneId: string) => ReactNode }) { const { state, newTabOptions, actions, onNewTab, renderTab } = props return ( diff --git a/src/client/state.ts b/src/client/state.ts index a2c4143..be76828 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -11,14 +11,21 @@ */ import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts' -export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent' +export type TabType = 'explorer' | 'git' | 'editor' | 'terminal' | 'subagent' | 'diff' -/** One open tab. `path` carries the file (editor) or is absent (explorer/git). */ +/** What a diff tab shows: a worktree/index change of one path, or one commit's full patch. */ +export type SidebarDiffRef = + | { kind: 'worktree'; path: string; staged: boolean; untracked?: boolean } + | { kind: 'commit'; hash: string; hashFull: string; subject: string } + +/** One open tab. `path` carries the file (editor) or is absent (explorer/git); + * `diff` carries the change a diff tab shows. */ export interface SidebarTab { id: string type: TabType title: string path?: string + diff?: SidebarDiffRef } /** A tab group. */ @@ -320,6 +327,13 @@ export function openTab(state: SidebarState, tab: SidebarTab): SidebarState { if (existing !== undefined) return activateTab(state, leaf.id, existing.id) } } + if (tab.type === 'diff') { + // One tab per change: the same path+side (or commit) focuses its instance. + for (const leaf of allLeaves(state.splits)) { + const existing = leaf.tabs.find(candidate => candidate.type === 'diff' && candidate.id === tab.id) + if (existing !== undefined) return activateTab(state, leaf.id, existing.id) + } + } return { ...state, activePane: targetId, @@ -358,6 +372,38 @@ export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState return { ...state, splits: splitLeafAt(state.splits, paneId, dir) } } +/** + * Open a diff tab the VSCode way: an existing instance of the same change is + * focused wherever it lives; otherwise the tab joins the first pane that + * already holds diff tabs (diff panes are sticky — repeated clicks stack + * there); on the FIRST diff of a layout the source pane splits vertically so + * the diff lands in a fresh pane below it ("默认在下半栏新增一个"). + * @returns the new state, with the diff pane active. + */ +export function openDiffTab(state: SidebarState, sourcePaneId: string, tab: SidebarTab): SidebarState { + const existingLeaf = leafWithTab(state.splits, tab.id) + if (existingLeaf !== undefined) return activateTab(state, existingLeaf.id, tab.id) + const diffLeaf = allLeaves(state.splits).find(leaf => leaf.tabs.some(candidate => candidate.type === 'diff')) + if (diffLeaf !== undefined) { + return { + ...state, + activePane: diffLeaf.id, + splits: mapLeaf(state.splits, diffLeaf.id, (leaf) => { + leaf.tabs = [...leaf.tabs, tab] + leaf.active = tab.id + }), + } + } + // First diff: split the source pane, the diff tab in the new LOWER leaf. + // (A stale sourcePaneId — its pane closed meanwhile — degrades to the + // regular open path instead of dropping the tab into an orphaned leaf.) + if (!allLeaves(state.splits).some(leaf => leaf.id === sourcePaneId)) { + return openTab(state, tab) + } + const result = insertLeafAt(state.splits, sourcePaneId, 'col', tab, false) + return { ...state, splits: result.node, activePane: result.leafId } +} + /** Toggle the panel open/closed (opening restores the previous layout). */ export function togglePanel(state: SidebarState): SidebarState { return { ...state, panelOpen: !state.panelOpen } @@ -563,10 +609,19 @@ function sanitizeNode(node: unknown, seen: Set, reid: Map if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined + // Diff tabs are ephemeral by nature (like VSCode's diff editors): + // they never survive a reload, so a stale ref (e.g. a wrong staged + // side persisted by an older build) cannot resurface as a dead tab + // showing "no text changes" after every refresh. + if (candidate.type === 'diff') { + droppedDiff = true + continue + } if ( candidate.type !== 'explorer' && candidate.type !== 'git' && candidate.type !== 'editor' && candidate.type !== 'terminal' @@ -582,8 +637,10 @@ function sanitizeNode(node: unknown, seen: Set, reid: Map tab.id === active)) return undefined - return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active } + // An active pointer into a dropped diff tab is expected after the drop; + // any other missing active is structural corruption → reset the state. + if (active !== null && !tabs.some(tab => tab.id === active) && !droppedDiff) return undefined + return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active: active !== null && tabs.some(tab => tab.id === active) ? active : null } } if (record.kind === 'split') { if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined diff --git a/src/git.ts b/src/git.ts index f5e44b2..0a735d1 100644 --- a/src/git.ts +++ b/src/git.ts @@ -27,10 +27,16 @@ export interface GitStatusResult { /** One `git log` row. */ export interface GitLogEntry { + /** Short hash (7+ chars, display). */ hash: string + /** Full 40-char hash (advanced operations: revert / cherry-pick). */ + hashFull: string subject: string author: string + /** ISO 8601 author date (`%ai`), e.g. `2024-01-01 10:00:00 +0800`. */ date: string + /** Ref decorations (`%D` with --decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */ + refs: string } /** One git failure (stderr text as the message). */ @@ -65,14 +71,21 @@ export function parsePorcelainZ(output: string): GitStatusEntry[] { return entries } -/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai` rows. */ +/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D` rows. */ export function parseLogLines(output: string): GitLogEntry[] { const rows: GitLogEntry[] = [] for (const line of output.split('\n')) { if (line === '') continue - const [hash, subject, author, date] = line.split('\x1f') + const [hash, subject, author, date, hashFull, refs] = line.split('\x1f') if (hash === undefined || subject === undefined) continue - rows.push({ hash, subject, author: author ?? '', date: date ?? '' }) + rows.push({ + hash, + subject, + author: author ?? '', + date: date ?? '', + hashFull: hashFull ?? hash, + refs: refs ?? '', + }) } return rows } @@ -118,6 +131,12 @@ export async function isGitRepo(cwd: string): Promise { } } +/** The repository top level containing `cwd` (`git rev-parse --show-toplevel`). */ +export async function repoRoot(cwd: string): Promise { + const out = await runGit(cwd, ['rev-parse', '--show-toplevel']) + return out.trim() +} + /** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */ export async function currentBranch(cwd: string): Promise { const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']) @@ -173,9 +192,12 @@ export async function checkout(cwd: string, branch: string): Promise { await runGit(cwd, ['checkout', branch]) } -/** Recent commit history (newest first). */ -export async function log(cwd: string, count = 30): Promise { - const raw = await runGit(cwd, ['log', `-n ${count}`, '--pretty=format:%h%x1f%s%x1f%an%x1f%ai']) +/** Recent commit history (newest first), lazily pageable via skip/count. */ +export async function log(cwd: string, count = 30, skip = 0): Promise { + const raw = await runGit(cwd, [ + 'log', '-n', String(count), '--skip', String(skip), '--decorate=short', + '--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D', + ]) return parseLogLines(raw) } @@ -190,3 +212,25 @@ export async function show(cwd: string, rev: string, path: string): Promise { + return runGit(cwd, ['show', '--no-ext-diff', '--no-color', '--format=', '-m', '--first-parent', hash]) +} + +/** Discard the worktree changes of one path (`git checkout -- `; the index is untouched). */ +export async function discard(cwd: string, path: string): Promise { + await runGit(cwd, ['checkout', '--', path]) +} + +/** Revert one commit onto the current branch with an auto-generated message. */ +export async function revert(cwd: string, hash: string): Promise { + await runGit(cwd, ['revert', '--no-edit', hash]) +} + +/** Cherry-pick one commit onto the current branch. */ +export async function cherryPick(cwd: string, hash: string): Promise { + await runGit(cwd, ['cherry-pick', hash]) +} diff --git a/src/index.ts b/src/index.ts index 4ce6b3e..0c5ddda 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,7 +12,7 @@ * processes are keyed by session. */ import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises' -import { basename, dirname, extname } from 'node:path' +import { basename, dirname, extname, isAbsolute, join } from 'node:path' import type { IncomingMessage } from 'node:http' import { WebSocket, WebSocketServer } from 'ws' import type { Context } from './context-types.ts' @@ -97,6 +97,19 @@ function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): stri return process.cwd() } +/** + * Resolve a path that a git command reported — `git status`/`git diff` + * print paths RELATIVE TO THE REPO TOP LEVEL, which may sit above the + * session cwd (a session inside a subdirectory of a repository). Absolute + * paths pass through; relative ones join the repo root (falling back to the + * cwd when the root cannot be resolved, e.g. a bare directory). + */ +async function resolveGitPath(cwd: string, raw: string): Promise { + if (isAbsolute(raw)) return requireAbsolute(raw) + const root = await git.repoRoot(cwd).catch(() => cwd) + return requireAbsolute(join(root, raw)) +} + /** Text read of a file with the size cap; binary detection via NUL probe. */ async function readText(path: string, readLimit: number): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> { const info = await stat(path).catch((error: unknown) => { @@ -165,7 +178,9 @@ function buildApi( }, 'fs.read': async (payload) => { const { cwd } = cwdOf(payload) - const path = requireAbsolute(requireString(payload, 'path')) + // Relative paths are git-derived (status/diff report repo-root-relative + // names; the untracked diff view reads the file through this route). + const path = await resolveGitPath(cwd, requireString(payload, 'path')) const { content, truncated, binary, size } = await readText(path, resolved.readLimit) if (binary) return { kind: 'binary', size, truncated } return { kind: 'text', content, truncated } @@ -192,7 +207,7 @@ function buildApi( 'git.diff': async (payload) => { const { cwd } = cwdOf(payload) const record = payload as { path?: unknown; staged?: unknown } - const path = record.path === undefined ? undefined : requireAbsolute(requireString(payload, 'path')) + const path = record.path === undefined ? undefined : await resolveGitPath(cwd, requireString(payload, 'path')) return { diff: await git.diff(cwd, path, record.staged === true) } }, 'git.stage': async (payload) => { @@ -226,11 +241,37 @@ function buildApi( }, 'git.log': async (payload) => { const { cwd } = cwdOf(payload) - return git.log(cwd) + const record = payload as { count?: unknown; skip?: unknown } + const count = typeof record.count === 'number' && Number.isInteger(record.count) && record.count > 0 + ? record.count + : undefined + const skip = typeof record.skip === 'number' && Number.isInteger(record.skip) && record.skip >= 0 + ? record.skip + : undefined + return git.log(cwd, count, skip) + }, + 'git.commit-diff': async (payload) => { + const { cwd } = cwdOf(payload) + return { diff: await git.commitDiff(cwd, requireString(payload, 'hash')) } + }, + 'git.discard': async (payload) => { + const { cwd } = cwdOf(payload) + await git.discard(cwd, await resolveGitPath(cwd, requireString(payload, 'path'))) + return { ok: true } + }, + 'git.revert': async (payload) => { + const { cwd } = cwdOf(payload) + await git.revert(cwd, requireString(payload, 'hash')) + return { ok: true } + }, + 'git.cherry-pick': async (payload) => { + const { cwd } = cwdOf(payload) + await git.cherryPick(cwd, requireString(payload, 'hash')) + return { ok: true } }, 'git.show': async (payload) => { const { cwd } = cwdOf(payload) - const path = requireAbsolute(requireString(payload, 'path')) + const path = await resolveGitPath(cwd, requireString(payload, 'path')) const rev = requireString(payload, 'rev') return { content: await git.show(cwd, rev, path) } }, diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 82c9ec5..4a11829 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -4,8 +4,10 @@ * actual repository, and a real directory listing. Runs with `pnpm test`. */ import { describe, expect, it } from 'vitest' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { resolve as resolvePath } from 'node:path' +import { join, resolve as resolvePath } from 'node:path' import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings' import { apply, mediaTypeForPath } from '../src/index.ts' import * as git from '../src/git.ts' @@ -75,6 +77,31 @@ describe('host plugin smoke', () => { expect(branches.names).toContain(branches.current) }) + it('enriches the log (full hash + refs) and renders commit diffs', async () => { + const cwd = process.cwd() + const log = await git.log(cwd) + const first = log[0]! + expect(first.hashFull).toMatch(/^[0-9a-f]{40}$/) + expect(typeof first.refs).toBe('string') + const patch = await git.commitDiff(cwd, first.hashFull) + expect(patch).toContain('diff --git') + }) + + it('pages the log lazily with skip/count', async () => { + const cwd = process.cwd() + const first = await git.log(cwd, 5, 0) + expect(first).toHaveLength(5) + const second = await git.log(cwd, 5, 5) + expect(second).toHaveLength(5) + // The pages are disjoint windows over the same ordered history. + expect(first[0]!.hashFull).not.toBe(second[0]!.hashFull) + const all = await git.log(cwd, 10, 0) + expect(all.slice(0, 5)).toEqual(first) + expect(all.slice(5)).toEqual(second) + // A skip past the end returns an empty page (the lazy loader's stop sign). + expect(await git.log(cwd, 5, 10_000)).toEqual([]) + }) + it('pty manager releases the quota on close and respawns after exit', async () => { const manager = new PtyManager(defaultShell(), 3) try { @@ -165,6 +192,110 @@ describe('host plugin smoke', () => { }) }) +/** + * Destructive git operations (discard / revert / cherry-pick) run against a + * throwaway repository under the OS temp dir — never the plugin repo. The + * fixture's commit identity comes from the GIT_AUTHOR / GIT_COMMITTER + * environment variables, confined to the fixture process: no git config is + * touched anywhere (the plugin never sets an identity, and neither does its + * test fixture). + */ +describe('git destructive operations (scratch repository)', () => { + const FIXTURE_IDENTITY = { + GIT_AUTHOR_NAME: 'dsh-better-sidebar-test', + GIT_AUTHOR_EMAIL: 'test@dsh.invalid', + GIT_COMMITTER_NAME: 'dsh-better-sidebar-test', + GIT_COMMITTER_EMAIL: 'test@dsh.invalid', + } + + const gitRun = (cwd: string, args: string[]): string => { + const result = spawnSync('git', ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args], { + encoding: 'utf8', + env: { ...process.env, ...FIXTURE_IDENTITY }, + }) + if (result.status !== 0) { + throw new Error(result.stderr || `git ${args[0] ?? ''} exited with ${String(result.status)}`) + } + return result.stdout + } + + /** A fresh repo on branch `main` with one committed file `a.txt`. */ + const makeScratchRepo = (): string => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-git-')) + gitRun(dir, ['init', '-q']) + gitRun(dir, ['checkout', '-q', '-b', 'main']) + writeFileSync(join(dir, 'a.txt'), 'one\ntwo\nthree\n') + gitRun(dir, ['add', '-A']) + gitRun(dir, ['commit', '-q', '-m', 'base']) + return dir + } + + it('discard restores the worktree file from the index (staged changes kept)', async () => { + const dir = makeScratchRepo() + try { + // Unstaged-only changes: fully reverts to the committed content. + writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n') + await git.discard(dir, 'a.txt') + expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n') + // Staged changes: the worktree snaps back to the STAGED content and + // the index is untouched (`git checkout -- ` restores from the + // index — VSCode's "Discard Changes" semantics). + writeFileSync(join(dir, 'a.txt'), 'one\nCHANGED\nthree\n') + gitRun(dir, ['add', '-A']) + await git.discard(dir, 'a.txt') + expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\nCHANGED\nthree\n') + const staged = await git.diff(dir, 'a.txt', true) + expect(staged).toContain('-two') + expect(staged).toContain('+CHANGED') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('revert creates a revert commit', async () => { + const dir = makeScratchRepo() + try { + writeFileSync(join(dir, 'a.txt'), 'one\nTWO\nthree\n') + gitRun(dir, ['add', '-A']) + gitRun(dir, ['commit', '-q', '-m', 'change']) + const featureHash = (await git.log(dir))[0]!.hashFull + await git.revert(dir, featureHash) + expect(readFileSync(join(dir, 'a.txt'), 'utf8')).toBe('one\ntwo\nthree\n') + expect((await git.log(dir))[0]!.subject).toBe('Revert "change"') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('cherry-pick applies a commit from another branch', async () => { + const dir = makeScratchRepo() + try { + gitRun(dir, ['checkout', '-q', '-b', 'feature']) + writeFileSync(join(dir, 'b.txt'), 'feature work\n') + gitRun(dir, ['add', '-A']) + gitRun(dir, ['commit', '-q', '-m', 'feature work']) + const featureHash = (await git.log(dir))[0]!.hashFull + gitRun(dir, ['checkout', '-q', 'main']) + await git.cherryPick(dir, featureHash) + expect(readFileSync(join(dir, 'b.txt'), 'utf8')).toBe('feature work\n') + expect((await git.log(dir))[0]!.subject).toBe('feature work') + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) + + it('reports a failing destructive operation as a GitCommandError', async () => { + const dir = makeScratchRepo() + try { + // An unknown revision fails before touching anything. + await expect(git.revert(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow() + await expect(git.cherryPick(dir, 'deadbeef00000000000000000000000000000000')).rejects.toThrow() + } finally { + rmSync(dir, { recursive: true, force: true }) + } + }) +}) + describe('session cwd resolution over the API route', () => { interface CtxOverrides { sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined } @@ -251,6 +382,35 @@ describe('session cwd resolution over the API route', () => { const missing = await invoke(route, 'pty.close', { sessionId: 's-pty' }) expect(missing.ok).toBe(false) }) + + it('git.diff resolves repo-relative paths (session in a subdirectory)', async () => { + // The plugin repo's status paths are relative to the repo top level + // (e.g. `src/git.ts`); a session whose cwd sits inside the repo must + // still load per-file diffs instead of failing with "not an absolute + // path". The session header points INTO the repository. + const route = mount({ + sessions: { + get: () => ({ header: { cwd: join(process.cwd(), 'src') } }), + }, + }) + const result = await invoke(route, 'git.diff', { sessionId: 's-sub', path: 'src/git.ts', staged: false }) + expect(result.ok).toBe(true) + const value = result as unknown as { ok: boolean; value?: { diff: string } } + expect(typeof value.value?.diff).toBe('string') + }) + + it('fs.read resolves repo-relative paths (untracked diff fallback)', async () => { + const route = mount({ + sessions: { + get: () => ({ header: { cwd: join(process.cwd(), 'src') } }), + }, + }) + const result = await invoke(route, 'fs.read', { sessionId: 's-sub', path: 'src/git.ts' }) + expect(result.ok).toBe(true) + const value = result as unknown as { ok: boolean; value?: { kind: string; content: string } } + expect(value.value?.kind).toBe('text') + expect(value.value?.content).toContain('runGit') + }) }) describe('side card settings routes', () => { diff --git a/tests/unit.spec.ts b/tests/unit.spec.ts index b061f77..b4a9fa9 100644 --- a/tests/unit.spec.ts +++ b/tests/unit.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts' import { parseLogLines, parsePorcelainZ } from '../src/git.ts' +import { parseUnifiedDiff } from '../src/client/DiffView.tsx' import { activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState, - moveTab, moveTabToEdge, openTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded, - type SidebarState, type SplitNode, + moveTab, moveTabToEdge, openDiffTab, openTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded, + type SidebarState, type SidebarTab, type SplitNode, } from '../src/client/state.ts' import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts' import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts' @@ -89,12 +90,125 @@ describe('git parsing', () => { ]) }) - it('parses log rows with unit separators', () => { - const rows = parseLogLines('abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\n') + it('parses log rows with unit separators (full hash + refs)', () => { + const rows = parseLogLines( + 'abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\x1fabc1234def5678abc1234def5678abc1234def5678\x1fHEAD -> main, origin/main\n' + + 'def5678\x1fSecond subject\x1fBob\x1f2024-01-02 10:00:00 +0800\x1fdef5678abc1234def5678abc1234def5678abc1234\x1f\n', + ) expect(rows).toEqual([ - { hash: 'abc1234', subject: 'First subject', author: 'Alice', date: '2024-01-01 10:00:00 +0800' }, + { + hash: 'abc1234', + subject: 'First subject', + author: 'Alice', + date: '2024-01-01 10:00:00 +0800', + hashFull: 'abc1234def5678abc1234def5678abc1234def5678', + refs: 'HEAD -> main, origin/main', + }, + { + hash: 'def5678', + subject: 'Second subject', + author: 'Bob', + date: '2024-01-02 10:00:00 +0800', + hashFull: 'def5678abc1234def5678abc1234def5678abc1234', + refs: '', + }, ]) }) + + it('parses a multi-file unified diff with aligned line numbers', () => { + const diff = [ + 'diff --git a/src/a.ts b/src/a.ts', + 'index 1234567..89abcde 100644', + '--- a/src/a.ts', + '+++ b/src/a.ts', + '@@ -1,4 +1,5 @@ section with @@ inside', + ' line1', + '-line2', + '+line2b', + ' context', + '+trailing', + 'diff --git a/README.md b/README.md', + 'new file mode 100644', + 'index 0000000..1234567', + '--- /dev/null', + '+++ b/README.md', + '@@ -0,0 +1,2 @@', + '+hello', + '+world', + '', + ].join('\n') + const parsed = parseUnifiedDiff(diff) + expect(parsed.files).toHaveLength(2) + const first = parsed.files[0]! + expect(first.oldPath).toBe('a/src/a.ts') + expect(first.newPath).toBe('b/src/a.ts') + expect(first.binary).toBe(false) + expect(first.hunks).toHaveLength(1) + expect(first.hunks[0]!.oldStart).toBe(1) + expect(first.hunks[0]!.newStart).toBe(1) + expect(first.hunks[0]!.header).toBe(' section with @@ inside') + expect(first.hunks[0]!.lines).toEqual([ + { kind: 'ctx', text: 'line1', oldNum: 1, newNum: 1 }, + { kind: 'del', text: 'line2', oldNum: 2, newNum: null }, + { kind: 'add', text: 'line2b', oldNum: null, newNum: 2 }, + { kind: 'ctx', text: 'context', oldNum: 3, newNum: 3 }, + { kind: 'add', text: 'trailing', oldNum: null, newNum: 4 }, + ]) + const second = parsed.files[1]! + expect(second.oldPath).toBe('/dev/null') + expect(second.hunks[0]!.lines[0]).toEqual({ kind: 'add', text: 'hello', oldNum: null, newNum: 1 }) + expect(second.hunks[0]!.lines[1]).toEqual({ kind: 'add', text: 'world', oldNum: null, newNum: 2 }) + }) + + it('parses binary, deletion and no-newline markers', () => { + const diff = [ + 'diff --git a/img.png b/img.png', + 'index 111..222 100644', + 'Binary files a/img.png and b/img.png differ', + 'diff --git a/gone.ts b/gone.ts', + 'deleted file mode 100644', + '--- a/gone.ts', + '+++ /dev/null', + '@@ -1,2 +0,0 @@', + '-one', + '-two', + '\\ No newline at end of file', + '', + ].join('\n') + const parsed = parseUnifiedDiff(diff) + expect(parsed.files).toHaveLength(2) + expect(parsed.files[0]!.binary).toBe(true) + expect(parsed.files[0]!.hunks).toHaveLength(0) + const gone = parsed.files[1]! + expect(gone.newPath).toBe('/dev/null') + expect(gone.hunks[0]!.lines).toEqual([ + { kind: 'del', text: 'one', oldNum: 1, newNum: null }, + { kind: 'del', text: 'two', oldNum: 2, newNum: null }, + { kind: 'meta', text: ' No newline at end of file', oldNum: null, newNum: null }, + ]) + }) + + it('keeps mode/rename-only sections hunkless', () => { + const parsed = parseUnifiedDiff([ + 'diff --git a/run.sh b/run.sh', + 'old mode 100644', + 'new mode 100755', + 'diff --git a/old.ts b/new.ts', + 'similarity index 90%', + 'rename from old.ts', + 'rename to new.ts', + '', + ].join('\n')) + expect(parsed.files).toHaveLength(2) + expect(parsed.files[0]!.oldPath).toBe('') + expect(parsed.files[0]!.hunks).toHaveLength(0) + expect(parsed.files[1]!.hunks).toHaveLength(0) + }) + + it('parses an empty or junk diff into no files', () => { + expect(parseUnifiedDiff('').files).toEqual([]) + expect(parseUnifiedDiff('no diff here\n').files).toEqual([]) + }) }) describe('sidebar state', () => { @@ -119,6 +233,109 @@ describe('sidebar state', () => { expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1']) }) + const diffTab = (id: string): SidebarTab => ({ + id, + type: 'diff', + title: id, + diff: { kind: 'worktree', path: 'src/a.ts', staged: false }, + }) + + it('first diff splits the source pane vertically (diff below)', () => { + const s = state() + const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } + const withGit = openTab(s, gitTab) + const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id + const after = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts')) + expect(after.splits.kind).toBe('split') + const split = after.splits as { dir: string; children: { kind: string; tabs?: SidebarTab[]; id: string }[] } + expect(split.dir).toBe('col') + expect(split.children).toHaveLength(2) + // The source stays on TOP (first child), the diff lands in the new bottom leaf. + expect(split.children[0]!.id).toBe(sourcePane) + expect(split.children[1]!.tabs?.map(tab => tab.id)).toEqual(['diff:w:u:src/a.ts']) + expect(after.activePane).toBe(split.children[1]!.id) + }) + + it('reopening the same diff focuses its existing tab', () => { + const s = state() + const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } + const withGit = openTab(s, gitTab) + const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id + const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts')) + const second = openDiffTab(first, sourcePane, diffTab('diff:w:u:src/a.ts')) + // No new panes, no duplicate tabs. + expect(second.splits.kind).toBe('split') + const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] } + const allTabs = split.children.flatMap(child => child.tabs ?? []) + expect(allTabs.filter(tab => tab.type === 'diff')).toHaveLength(1) + }) + + it('subsequent diffs stack into the existing diff pane', () => { + const s = state() + const gitTab = { id: 'git', type: 'git' as const, title: 'Git' } + const withGit = openTab(s, gitTab) + const sourcePane = (withGit.splits as { kind: 'leaf'; id: string }).id + const first = openDiffTab(withGit, sourcePane, diffTab('diff:w:u:src/a.ts')) + const second = openDiffTab(first, sourcePane, diffTab('diff:c:abc1234def5678abc1234def5678abc1234def5678')) + // Still one split: the second diff joins the bottom leaf instead of splitting again. + expect(second.splits.kind).toBe('split') + const split = second.splits as { children: { kind: string; tabs?: SidebarTab[] }[] } + const diffLeaves = split.children.filter(child => child.tabs?.some(tab => tab.type === 'diff')) + expect(diffLeaves).toHaveLength(1) + expect(diffLeaves[0]!.tabs?.map(tab => tab.id)).toEqual([ + 'diff:w:u:src/a.ts', + 'diff:c:abc1234def5678abc1234def5678abc1234def5678', + ]) + }) + + it('openDiffTab degrades to a regular open when the source pane is gone', () => { + const s = state() + const after = openDiffTab(s, 'pane:gone', diffTab('diff:w:u:src/a.ts')) + expect(after.splits.kind).toBe('leaf') + expect((after.splits as { tabs: SidebarTab[] }).tabs.map(tab => tab.id)).toContain('diff:w:u:src/a.ts') + }) + + it('sanitize drops diff tabs (ephemeral, like VSCode diff editors)', () => { + const valid = sanitizeState({ + panelOpen: true, + width: 400, + nextTerminal: 1, + activePane: 'pane:1', + expanded: [], + splits: { + kind: 'leaf', + id: 'pane:1', + active: 'd1', + tabs: [ + { id: 'explorer-tab', type: 'explorer', title: 'Explorer' }, + { id: 'd1', type: 'diff', title: 'a.ts', diff: { kind: 'worktree', path: 'src/a.ts', staged: false } }, + ], + }, + }) + expect(valid?.splits.kind).toBe('leaf') + const tabs = (valid?.splits as { tabs: SidebarTab[] }).tabs + expect(tabs.map(tab => tab.id)).toEqual(['explorer-tab']) + // The dropped diff tab was the active one: the leaf falls back to a null + // active instead of resetting the whole state. + expect((valid?.splits as { active: string | null }).active).toBeNull() + // A leaf of ONLY diff tabs survives as an empty pane (welcome cards). + const onlyDiff = sanitizeState({ + panelOpen: true, + width: 400, + nextTerminal: 1, + activePane: 'pane:1', + expanded: [], + splits: { + kind: 'leaf', + id: 'pane:1', + active: 'd1', + tabs: [{ id: 'd1', type: 'diff', title: 'a.ts' }], + }, + }) + expect(onlyDiff?.splits.kind).toBe('leaf') + expect((onlyDiff?.splits as { tabs: SidebarTab[] }).tabs).toEqual([]) + }) + it('dedupes the single-instance subagent tab (focuses instead of duplicating)', () => { let s = state() s = openTab(s, { id: 'subagent', type: 'subagent', title: 'Subagents' })