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

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

- 合并 tab:git tab(id 保留,兼容持久化布局)升级为「文件变动」双视角
  tab——Git 视角(原 GitView 全部能力:staged/unstaged/提交/历史/worktree·
  子仓库选择/右键菜单/确认弹窗)+ 本轮文件视角(原 file-trace:会话事件
  日志实时折叠的模型读写编辑记录,按文件分组、新增类型筛选 chips、相对
  时间);file-trace tab 移除(未随任何版本发布)。
- 统一 diff 组件:新 src/client/diff/ 模块(rows.ts 引擎 + DiffRows/
  DiffFiles 渲染器 + highlight.ts 语法着色)——git diff 与会话操作 diff
  共用一套渲染:mod 改写配对(del-run/add-run 对半标蓝)、行内字符级
  高亮(修复了旧实现旧侧失配不高亮的问题,改为行对象身份键)、上下文
  hunk 折叠、git hunk 间未输出区间按行号范围生成不可展开折叠、语法着色
  色相落位 diff 三色空档、+n −m 统计 chips、行底色 tint;DiffTab 改用
  该栈(git diff tab 从此也有高亮)。
- 交互:两视角共用底部可拖拽预览面板(点击文件/提交/操作即预览,不再
  直接开 diff tab;面板可刷新、可键盘/拖拽调高、git 目标可一键展开为
  独立 diff tab);视角选择与面板高度持久化在 tab.meta;git 行 XY 徽章
  按状态字母着色;选中行高亮。
- i18n:fileTrace* 十键重命名为 changes* 前缀,git 键值更新为「文件
  变动」,新增 7 键(视角/筛选/面板操作),全部 21 个词典同步。
- 结构:src/client/changes/(ChangesTab/GitLens/SessionLens/DiffPane/
  ops)+ src/client/diff/;GitView/DiffView/filetrace/ 删除;git 样式
  从 sidebar.module.css 迁至 changes.module.css。
- 测试:builtins 8→7(git tab 承接 badge pin)、diff-rows(unified
  转换/mod 配对/间隙折叠/统计)、changes-tab(壳交互 jsdom)、
  diff-files-collapse(原 DiffView 折叠行为钉住)、changes-ops(原
  filetrace 数据层)、git-view-worktree 迁移至 GitLens;全量 1122 通过。
- 文档:README ×2 功能矩阵合并、external-plugin-guide §4.4 与内置清单
  7 tab、mount e2e 菜单标题同步。
This commit is contained in:
Menghuan1918
2026-08-31 01:31:22 +08:00
parent 9255040489
commit 24a3813c63
53 changed files with 2693 additions and 1792 deletions
+7 -6
View File
@@ -1,18 +1,19 @@
/**
* The diff tab: one change opened from the git panel, like VSCode's diff
* The diff tab: one change opened from the changes tab, 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.
* stays mounted while the changes tab's staging/discard operations change the
* very content it shows. Rendering goes through the shared {@link DiffFiles}
* renderer — the same one the changes tab's inline preview uses.
*/
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 { DiffFiles } from './diff/DiffFiles.tsx'
import { t } from './locales.ts'
import { resolveSidebarPath } from './produced-files.ts'
import css from './sidebar.module.css'
@@ -102,8 +103,8 @@ export function DiffTab(props: { sessionId: string; cwd: string | undefined; dif
{!loading && error === null && data !== null && (
<>
{data.untracked !== undefined
? <DiffView diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffView diff={data.diff} />}
? <DiffFiles diff="" untrackedPath={diff.kind === 'worktree' ? diff.path : ''} untrackedContent={data.untracked} />
: <DiffFiles diff={data.diff} />}
{data.diff === '' && data.untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
-305
View File
@@ -1,305 +0,0 @@
/**
* 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 { useEffect, 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
const TEST_PATH = /(^|\/)(?:__tests__|tests?|specs?|fixtures?|mocks?|snapshots?)(?:\/|$)|\.(?:test|spec)\.[^/]+$/i
const DOC_PATH = /(^|\/)(?:docs?|documentation)(?:\/|$)|(^|\/)(?:readme|changelog|contributing|license|authors|notice)(?:\.[^/]*)?$/i
const GENERATED_PATH = /(^|\/)(?:dist|build|coverage|generated|vendor|node_modules)(?:\/|$)|(^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|composer\.lock|cargo\.lock|poetry\.lock)$/i
const SOURCE_PATH = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts|py|pyw|rb|php|java|kt|kts|scala|go|rs|swift|c|h|cc|cpp|cxx|hpp|hh|hxx|cs|fs|fsx|vb|dart|lua|r|ex|exs|erl|hrl|clj|cljs|cljc|groovy|sh|bash|zsh|fish|ps1|sql|vue|svelte|astro|html|htm|css|scss|sass|less)$/i
/** Source files open by default; tests, docs, generated files and unknown types stay folded. */
function defaultExpandedFiles(files: DiffFile[]): Set<number> {
const expanded = new Set<number>()
files.forEach((file, index) => {
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
if (!file.binary && file.hunks.length > 0
&& !TEST_PATH.test(path) && !DOC_PATH.test(path) && !GENERATED_PATH.test(path)
&& SOURCE_PATH.test(path)) {
expanded.add(index)
}
})
return expanded
}
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<ParsedDiff>(() => {
if (untrackedPath !== undefined) {
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
}
return parseUnifiedDiff(diff)
}, [diff, untrackedPath, untrackedContent])
const [expanded, setExpanded] = useState(false)
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
// Flatten into display rows so the cap can slice a single list.
const rows = useMemo(() => {
const out: Array<{ key: string; file: DiffFile; fileIndex: number; type: 'path' | 'hunk' | 'line'; hunk?: DiffHunk; line?: DiffLine }> = []
parsed.files.forEach((file, fileIndex) => {
out.push({ key: `f${fileIndex}`, file, fileIndex, type: 'path' })
if (file.binary || !expandedFiles.has(fileIndex)) return
file.hunks.forEach((hunk, hunkIndex) => {
out.push({ key: `f${fileIndex}h${hunkIndex}`, file, fileIndex, type: 'hunk', hunk })
hunk.lines.forEach((line, lineIndex) => {
out.push({ key: `f${fileIndex}h${hunkIndex}l${lineIndex}`, file, fileIndex, type: 'line', hunk, line })
})
})
})
return out
}, [parsed, expandedFiles])
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)
const expandable = !row.file.binary && row.file.hunks.length > 0
const fileExpanded = expandedFiles.has(row.fileIndex)
return (
<button
key={row.key}
type="button"
className={css.gitDiffFile}
disabled={!expandable}
aria-expanded={expandable ? fileExpanded : undefined}
onClick={() => {
setExpandedFiles(current => {
const next = new Set(current)
if (next.has(row.fileIndex)) next.delete(row.fileIndex)
else next.add(row.fileIndex)
return next
})
}}
>
{expandable && <span aria-hidden="true" className={clsx(css.gitDiffFileChevron, fileExpanded && css.gitDiffFileChevronExpanded)}>›</span>}
<span className={css.gitDiffFilePath}>{to}</span>
{from !== to && <span className={css.gitDiffFileOld}>← {from}</span>}
{tag !== null && <span className={css.gitDiffFileTag}>{tag}</span>}
</button>
)
}
if (row.type === 'hunk') {
const hunk = row.hunk!
return (
<div key={row.key} className={css.gitDiffHunk}>
<span className={css.gitDiffHunkHeader}>@@ -{hunk.oldStart},{hunk.lines.filter(l => l.oldNum !== null).length} +{hunk.newStart},{hunk.lines.filter(l => l.newNum !== null).length} @@</span>
{hunk.header !== '' && <span className={css.gitDiffHunkSection}>{hunk.header}</span>}
</div>
)
}
const line = row.line!
const lineClass = line.kind === 'del' ? css.gitDiffDel : line.kind === 'add' ? css.gitDiffAdd : line.kind === 'meta' ? css.gitDiffMeta : css.gitDiffCtx
return (
<div key={row.key} className={clsx(css.gitDiffLine, lineClass)}>
{line.kind === 'meta'
? <span className={css.gitDiffMetaText}>{line.text}</span>
: (
<>
<span className={css.gitDiffNum}>{line.oldNum ?? ''}</span>
<span className={css.gitDiffNum}>{line.newNum ?? ''}</span>
<span className={css.gitDiffCode}>{line.text}</span>
</>
)}
</div>
)
}
return (
<div className={css.gitDiff}>
{head.map(renderRow)}
{hidden > 0 && (
<button type="button" className={css.gitDiffExpand} aria-expanded={expanded} onClick={() => { setExpanded(value => !value) }}>
{expanded ? t('diffCollapse') : t('diffExpand', { count: hidden })}
</button>
)}
{tail.map(renderRow)}
</div>
)
}
+1 -1
View File
@@ -3,7 +3,7 @@
* outside workspace`) is never shown as-is: the editor / file-tree error
* slots render the localized reason plus a one-click global off — the click
* flips the `workspaceFence` pref through the settings route, adopts the
* returned document into the store (so every prefs reader — GitView's open
* returned document into the store (so every prefs reader — the changes tab's open
* guard, the settings page — flips with it), and calls `onDisabled` so the
* caller retries the failed operation immediately.
*/
+1 -1
View File
@@ -88,7 +88,7 @@ export function TabBar(props: {
} = props
const [menuOpen, setMenuOpen] = useState(false)
// The tab right-click context menu: the target tab plus the cursor
// position (the portaled Menu anchors there, following GitView/FileTree).
// position (the portaled Menu anchors there, following the git lens/FileTree).
const [tabMenu, setTabMenu] = useState<{ tabId: string; x: number; y: number } | null>(null)
const [dragOver, setDragOver] = useState(false)
const listRef = useRef<HTMLDivElement>(null)
+26 -27
View File
@@ -1,14 +1,14 @@
/**
* The 8 built-in tab descriptors: the plugin registers its own pages
* (editor / git / subagent / sidechat / terminal / browser / diff /
* file-trace) through
* The 7 built-in tab descriptors: the plugin registers its own pages
* (editor / git — the unified changes tab / subagent / sidechat / terminal /
* browser / diff) through
* the same {@link BetterSidebarService} external plugins use — eating its
* own dogfood. The terminal descriptor owns its quota (`TERMINAL_LIMIT`)
* and mints `terminal:<uuid>` ids through `createTab`; the browser mints
* `browser:<n>` the same way (no quota). The editor IS the files window
* (the old standalone explorer merged into it).
*/
import { IconBranchOutline16, IconCodeOutline16, IconFolderOpen16, IconNewChatOutline16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconCodeOutline16, IconFolderOpen16, IconNewChatOutline16, IconPanelLeftOutline16, IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../../context-types.ts'
import { allLeaves, isAgentTabId, type SidebarState } from '../state.ts'
import { t } from '../locales.ts'
@@ -16,15 +16,14 @@ import { openSidebarFile } from '../intercept.tsx'
import { EditorHost } from '../EditorHost.tsx'
import { OpenWithSettings } from '../open-with-settings.tsx'
import { lazyChunkComponent } from '../lazy-chunk.tsx'
import { GitView } from '../GitView.tsx'
import { ChangesTab } from '../changes/ChangesTab.tsx'
import { extractFileOps } from '../changes/ops.ts'
import { DiffTab } from '../DiffTab.tsx'
import { SubagentView } from '../SubagentView.tsx'
import { consumeSidechatSeed, SideChatView, sidechatThreadIdOf } from '../SideChatView.tsx'
import { api } from '../api.ts'
import { BrowserView } from '../BrowserView.tsx'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16, IconHistoryOutline16 } from '../icons.tsx'
import { FileTraceTab } from '../filetrace/FileTraceTab.tsx'
import { extractFileOps } from '../filetrace/ops.ts'
import { IconTerminalOutline16, IconDiffOutline16, IconGlobeOutline16 } from '../icons.tsx'
import { TERMINAL_FONT_SIZE_MAX, TERMINAL_FONT_SIZE_MIN } from '../../prefs-shared.ts'
import type { ComponentType } from 'react'
import type { SessionScope } from '../api.ts'
@@ -142,18 +141,30 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
),
},
{
// The unified changes tab (id kept as 'git' so persisted layouts keep
// resolving): the Git lens is the former source-control panel; the
// session lens is the former file-trace tab (PR #471). Both preview
// through one shared diff stack, and the badge counts this session's
// file ops (live from the event log — the git status itself needs a
// fetch, so it stays out of the badge).
id: 'git',
title: () => t('git'),
icon: (size: number) => <IconBranchOutline16 size={size} />,
title: () => t('changes'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
order: 20,
single: true,
component: ({ ctx, store, scope, visible, onOpenDiff }) => (
<GitView
scope={scope}
badge: (ctx, scope) => {
const events = ctx.sessions.get(scope.sessionId)?.events
return events === undefined ? null : extractFileOps(events).length
},
component: ({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }) => (
<ChangesTab
ctx={ctx}
store={store}
scope={scope}
tab={tab}
visible={visible}
onOpenFile={(path) => { openSidebarFile(ctx, store, scope.sessionId, path) }}
onOpenDiff={onOpenDiff ?? (() => { /* no-op */ })}
onOpenDiff={onOpenDiff}
/>
),
},
@@ -338,21 +349,9 @@ export function builtinTabs(ctx: Context, options: BuiltinTabOptions = {}): read
}),
component: (props) => <BrowserView {...props} />,
},
{
id: 'file-trace',
title: () => t('fileTrace'),
icon: (size: number) => <IconHistoryOutline16 size={size} />,
order: 55,
single: true,
badge: (ctx, scope) => {
const events = ctx.sessions.get(scope.sessionId)?.events
return events === undefined ? null : extractFileOps(events).length
},
component: (props) => <FileTraceTab {...props} />,
},
{
id: 'diff',
title: () => t('git'),
title: () => t('changes'),
icon: (size: number) => <IconDiffOutline16 size={size} />,
order: -1,
hidden: true,
+127
View File
@@ -0,0 +1,127 @@
/**
* The unified changes tab: one tab, two lenses on "what changed?" — Git
* (repository truth: staged/unstaged files, commit box, history) and the
* session round (agent truth: every file the model read, wrote, or edited).
* Both lenses preview their selections in a shared resizable bottom pane
* ({@link DiffPane}); git targets can expand into the dedicated diff tab.
* The active lens and the pane height persist in the tab's meta, so the tab
* reopens exactly where it was left.
*/
import { useState } from 'react'
import type { TabComponentProps } from '../service.ts'
import { t } from '../locales.ts'
import type { SidebarDiffRef } from '../state.ts'
import { GitLens } from './GitLens.tsx'
import { SessionLens } from './SessionLens.tsx'
import { DiffPane, diffTabOf, type ChangesPreview } from './DiffPane.tsx'
import type { FileOp } from './ops.ts'
import css from './changes.module.css'
/** The default preview pane height (px) before the first drag. */
const PANE_HEIGHT_DEFAULT = 300
type Lens = 'git' | 'session'
/** The persisted tab meta (JSON-serializable; rides the layout). */
interface ChangesMeta {
lens?: Lens
previewH?: number
}
export function ChangesTab({ ctx, store, scope, tab, visible, onOpenFile, onOpenDiff }: TabComponentProps) {
const meta = (tab.meta ?? {}) as ChangesMeta
const [lens, setLens] = useState<Lens>(meta.lens === 'session' ? 'session' : 'git')
const [preview, setPreview] = useState<ChangesPreview | null>(null)
const [paneHeight, setPaneHeight] = useState<number>(
typeof meta.previewH === 'number' && meta.previewH >= 140 ? meta.previewH : PANE_HEIGHT_DEFAULT,
)
/** Persist a meta patch onto the tab (lens choice, pane height). */
const patchMeta = (patch: ChangesMeta): void => {
ctx.get('betterSidebar')?.updateTab(tab.id, {
meta: { ...(tab.meta as ChangesMeta | undefined ?? {}), ...patch },
})
}
const chooseLens = (next: Lens): void => {
if (next === lens) return
setLens(next)
patchMeta({ lens: next })
}
/** Preview one git change (worktree file or commit) from the Git lens. */
const previewGit = (ref: SidebarDiffRef): void => {
setPreview({ kind: 'git', ref })
}
/** Preview one session op (with its best-effort prior content snapshot). */
const previewOp = (path: string, op: FileOp, prior: string | undefined): void => {
setPreview({ kind: 'op', path, op, prior })
}
const previewKey = (target: ChangesPreview): string => target.kind === 'git'
? (target.ref.kind === 'worktree'
? `git:w:${target.ref.path}:${target.ref.staged ? 's' : 'u'}`
: `git:c:${target.ref.hashFull}`)
: `op:${target.op.callId}`
return (
<div className={css.root}>
<div className={css.lensBar}>
<div className={css.lensSwitch} role="group" aria-label={t('changes')}>
<button
type="button"
className={css.lensButton}
data-active={lens === 'git' ? 'true' : undefined}
aria-pressed={lens === 'git'}
onClick={() => { chooseLens('git') }}
>
{t('changesGitLens')}
</button>
<button
type="button"
className={css.lensButton}
data-active={lens === 'session' ? 'true' : undefined}
aria-pressed={lens === 'session'}
onClick={() => { chooseLens('session') }}
>
{t('changesSessionLens')}
</button>
</div>
</div>
{lens === 'git'
? (
<GitLens
scope={scope}
store={store}
visible={visible}
onOpenFile={onOpenFile ?? (() => { /* no-op */ })}
onPreview={previewGit}
selectedRef={preview !== null && preview.kind === 'git' ? preview.ref : null}
/>
)
: (
<SessionLens
ctx={ctx}
scope={scope}
visible={visible}
onPreview={previewOp}
selectedCallId={preview !== null && preview.kind === 'op' ? preview.op.callId : null}
/>
)}
{preview !== null && (
<DiffPane
key={previewKey(preview)}
target={preview}
scope={scope}
height={paneHeight}
onHeightCommit={(height) => { setPaneHeight(height); patchMeta({ previewH: height }) }}
onClose={() => { setPreview(null) }}
onExpand={() => {
if (preview.kind === 'git') onOpenDiff?.(diffTabOf(preview.ref))
}}
/>
)}
</div>
)
}
+313
View File
@@ -0,0 +1,313 @@
/**
* The changes tab's shared bottom preview pane: one selected target — a git
* worktree change, a commit patch, or a session file op — rendered through
* the unified diff stack (the same one the diff tab uses). Git targets load
* on demand (refreshable, with the untracked full-addition fallback); op
* targets are pure snapshots (diff / read view / error text). The pane is
* resizable by drag (clamped; the height commits to the tab's persisted
* meta on release) and by keyboard; git targets can expand into a dedicated
* diff tab via the shell.
*/
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'
import { IconCloseOutline16, IconRefreshOutline16, IconRightUpOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionScope } from '../api.ts'
import { api } from '../api.ts'
import { t } from '../locales.ts'
import { baseName } from '../paths.ts'
import { resolveSidebarPath } from '../produced-files.ts'
import type { SidebarDiffRef, SidebarTab } from '../state.ts'
import { DiffRows, ReadRows } from '../diff/DiffRows.tsx'
import { DiffFiles } from '../diff/DiffFiles.tsx'
import { langOfPath } from '../diff/highlight.ts'
import { buildDiffSegments, diffLines, diffStats, parseUnifiedDiff, unifiedSegments, type DiffRow } from '../diff/rows.ts'
import { parseReadLines, type FileOp } from './ops.ts'
import css from './changes.module.css'
import diffCss from '../diff/diff.module.css'
/** The drag handle height clamp (px) and keyboard-resize step. */
const HEIGHT_MIN = 140
const HEIGHT_STEP = 24
/** What the pane is showing right now. */
export type ChangesPreview =
| { kind: 'git'; ref: SidebarDiffRef }
| { kind: 'op'; path: string; op: FileOp; prior?: string }
/** Diff material for one op snapshot: an edit reconstructs the full file
* from the window's known prior content when possible (hunk-style context);
* a write with unknown prior content renders all-added. */
function diffOf(op: FileOp, prior: string | undefined): readonly DiffRow[] {
if (op.kind === 'read') return []
if (op.kind === 'edit' && op.edit !== undefined) {
const { oldString, newString } = op.edit
if (prior !== undefined && prior.includes(oldString)) {
const newFile = prior.replace(oldString, newString)
return diffLines(prior, newFile)
}
return diffLines(oldString, newString)
}
if (op.kind === 'write') {
const content = op.content ?? ''
const old = prior !== undefined && prior !== content ? prior : undefined
return diffLines(old ?? '', content)
}
return []
}
/** The diff tab a git preview expands into (the shell owns placement). */
export function diffTabOf(ref: SidebarDiffRef): SidebarTab {
if (ref.kind === 'worktree') {
return {
id: `diff:w:${encodeURIComponent(ref.worktree ?? '')}:${ref.staged ? 's' : 'u'}:${ref.path}`,
type: 'diff',
title: baseName(ref.path),
diff: ref,
}
}
return {
id: `diff:c:${encodeURIComponent(ref.worktree ?? '')}:${ref.hashFull}`,
type: 'diff',
title: `${ref.hash} ${ref.subject}`,
diff: ref,
}
}
export interface DiffPaneProps {
target: ChangesPreview
scope: SessionScope
/** The persisted pane height (px); drag commits a new one upwards. */
height: number
onHeightCommit: (height: number) => void
onClose: () => void
/** Expand the current git target into a dedicated diff tab. */
onExpand: () => void
}
export function DiffPane({ target, scope, height, onHeightCommit, onClose, onExpand }: DiffPaneProps) {
// ── Git target loading (mirrors the diff tab: staged-side fallback, the
// untracked full-addition fallback, refresh by tick). ─────────────────
const [tick, setTick] = useState(0)
const [loading, setLoading] = useState(target.kind === 'git')
const [error, setError] = useState<string | null>(null)
const [diffText, setDiffText] = useState<string | null>(null)
const [untracked, setUntracked] = useState<string | undefined>(undefined)
const gitRef = target.kind === 'git' ? target.ref : null
useEffect(() => {
if (gitRef === null) return
let cancelled = false
const paneScope: SessionScope = {
sessionId: scope.sessionId,
cwd: scope.cwd,
...(gitRef.repoRoot !== undefined ? { repoRoot: gitRef.repoRoot } : {}),
}
setLoading(true)
setError(null)
setDiffText(null)
setUntracked(undefined)
const load = async (): Promise<void> => {
try {
if (gitRef.kind === 'commit') {
const result = await api.gitCommitDiff(paneScope, gitRef.hashFull, gitRef.worktree)
if (!cancelled) setDiffText(result.diff)
return
}
let result = await api.gitDiff(paneScope, gitRef.path, gitRef.staged, gitRef.worktree)
if (result.diff === '') {
// The requested side is empty — try the OTHER side once (the change
// may have moved sides after the preview target was minted).
const other = await api.gitDiff(paneScope, gitRef.path, !gitRef.staged, gitRef.worktree)
if (other.diff !== '') result = other
}
if (result.diff !== '') {
if (!cancelled) setDiffText(result.diff)
return
}
// Empty diff: an untracked file (git diff never lists it) falls back
// to a full-file addition from its content.
if (gitRef.untracked === true && !gitRef.staged) {
const text = await api.fsRead(paneScope, resolveSidebarPath(gitRef.repoRoot ?? gitRef.worktree ?? scope.cwd, gitRef.path))
if (!cancelled && text.kind === 'text') {
setDiffText('')
setUntracked(text.content)
}
return
}
if (!cancelled) setDiffText('')
} catch (reason) {
if (!cancelled) setError(reason instanceof Error ? reason.message : String(reason))
} finally {
if (!cancelled) setLoading(false)
}
}
void load()
return () => { cancelled = true }
}, [gitRef, scope.sessionId, scope.cwd, tick])
// ── Op target material (pure snapshots; the prior content came with the
// target so a running op shows what is already known). ────────────────
const op = target.kind === 'op' ? target.op : null
const prior = target.kind === 'op' ? target.prior : undefined
const opLang = useMemo(() => (target.kind === 'op' ? langOfPath(target.path) : undefined), [target])
const opRows = useMemo(() => (op === null ? [] : diffOf(op, prior)), [op, prior])
const opSegments = useMemo(() => buildDiffSegments(opRows), [opRows])
const opStats = useMemo(() => diffStats(opSegments), [opSegments])
const opReadLines = useMemo(
() => (op?.kind === 'read' && op.read !== undefined ? parseReadLines(op.read) : []),
[op],
)
// Header stats for git targets come off the parsed patch text.
const gitStats = useMemo(() => {
if (target.kind !== 'git' || diffText === null || diffText === '') return null
let added = 0
let deleted = 0
for (const file of parseUnifiedDiff(diffText).files) {
const stats = diffStats(unifiedSegments(file))
added += stats.added
deleted += stats.deleted
}
return { added, deleted }
}, [target, diffText])
// ── Resize: drag the top handle; commit on release (persisted by the
// shell). Arrow keys resize by a step for keyboard users. ────────────
const [dragHeight, setDragHeight] = useState<number | null>(null)
const paneHeight = dragHeight ?? height
const clamp = (value: number): number => Math.min(Math.max(value, HEIGHT_MIN), Math.round(window.innerHeight * 0.7))
const dragOrigin = useRef<{ y: number; h: number } | null>(null)
const onHandleDown = (event: ReactPointerEvent<HTMLDivElement>): void => {
event.preventDefault()
dragOrigin.current = { y: event.clientY, h: paneHeight }
const onMove = (ev: PointerEvent): void => {
if (dragOrigin.current === null) return
setDragHeight(clamp(dragOrigin.current.h + (dragOrigin.current.y - ev.clientY)))
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
dragOrigin.current = null
setDragHeight(current => {
if (current !== null) onHeightCommit(current)
return null
})
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
const title = target.kind === 'op'
? target.path
: target.ref.kind === 'worktree' ? target.ref.path : `${target.ref.hash} ${target.ref.subject}`
const stats = gitStats ?? (target.kind === 'op' && op !== null && op.kind !== 'read' && !op.isError ? opStats : null)
return (
<div className={css.diffPane} style={{ height: paneHeight }}>
<div
className={css.dragHandle}
role="separator"
aria-orientation="horizontal"
aria-label={t('changesResizePreview')}
tabIndex={0}
onPointerDown={onHandleDown}
onKeyDown={(event) => {
if (event.key === 'ArrowUp') { event.preventDefault(); onHeightCommit(clamp(paneHeight + HEIGHT_STEP)) }
if (event.key === 'ArrowDown') { event.preventDefault(); onHeightCommit(clamp(paneHeight - HEIGHT_STEP)) }
}}
/>
<div className={css.diffHead}>
{target.kind === 'op' && (
<span className={css.diffKind} data-kind={target.op.kind}>
{t(target.op.kind === 'read' ? 'changesRead' : target.op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
)}
{target.kind === 'git' && target.ref.kind === 'worktree' && (
<span className={css.diffKind} data-kind="git">{target.ref.staged ? t('staged') : t('unstaged')}</span>
)}
{target.kind === 'git' && target.ref.kind === 'commit' && (
<span className={css.diffKind} data-kind="git">{target.ref.hash}</span>
)}
<span className={css.diffPath} title={title}>{title}</span>
{stats !== null && (stats.added > 0 || stats.deleted > 0) && (
<span className={css.diffStats}>
{stats.added > 0 && <span className={diffCss.statAdd}>+{String(stats.added)}</span>}
{stats.deleted > 0 && <span className={diffCss.statDel}>−{String(stats.deleted)}</span>}
</span>
)}
{target.kind === 'git' && (
<>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
disabled={loading}
onClick={() => { setTick(value => value + 1) }}
>
<IconRefreshOutline16 size={14} />
</button>
<button
type="button"
className={css.iconButton}
aria-label={t('changesOpenDiffTab')}
title={t('changesOpenDiffTab')}
onClick={onExpand}
>
<IconRightUpOutline16 size={14} />
</button>
</>
)}
<button
type="button"
className={css.iconButton}
aria-label={t('changesClosePreview')}
title={t('changesClosePreview')}
onClick={onClose}
>
<IconCloseOutline16 size={14} />
</button>
</div>
{target.kind === 'op' && op !== null && op.isError
? (
<div className={css.paneBody}>
<div className={css.readError} role="alert">
{op.errorText ?? t('changesError')}
</div>
</div>
)
: target.kind === 'op' && op !== null && op.kind === 'read'
? (
<div className={css.paneBody}>
<ReadRows lines={opReadLines} lang={opLang} />
</div>
)
: target.kind === 'op'
? (
<div className={css.paneBody}>
{op !== null && op.kind === 'write'
&& prior === undefined
&& <div className={css.priorUnknown}>{t('changesPriorUnknown')}</div>}
<DiffRows key={target.op.callId} segments={opSegments} lang={opLang} />
</div>
)
: loading
? <div className={css.paneBody}><div className={css.gitPlaceholder}>{t('loading')}</div></div>
: error !== null
? <div className={css.paneBody}><div className={css.gitError}>{t('diffLoadError')}: {error}</div></div>
: (
<div className={css.paneBody}>
{diffText !== null && diffText !== '' && (
<DiffFiles
diff={diffText}
untrackedPath={untracked !== undefined && target.ref.kind === 'worktree' ? target.ref.path : undefined}
untrackedContent={untracked}
/>
)}
{diffText === '' && untracked === undefined && (
<div className={css.gitEmpty}>{t('diffEmpty')}</div>
)}
</div>
)}
</div>
)
}
@@ -1,32 +1,33 @@
/**
* 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. While visible it polls lightweight porcelain state so model-authored
* file changes appear without a manual refresh.
* 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 { isWithinWorkspace, relativeTo } from './paths.ts'
import { resolveSidebarPath } from './produced-files.ts'
import { relativeTime, t } from './locales.ts'
import type { SidebarStore, SidebarTab } from './state.ts'
import css from './sidebar.module.css'
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]
const worktree = entry.xy[1]
if (index !== undefined && index !== ' ' && index !== '?') return index
const worktree = entry.xy[1]
if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree
return '?'
}
@@ -51,12 +52,6 @@ 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(
@@ -81,17 +76,21 @@ interface ConfirmState {
* floods the panel at once (the end of the log is reached by paging). */
const LOG_BATCH = 20
export function GitView(props: {
export interface GitLensProps {
scope: SessionScope
/** The sidebar store: reads the `workspaceFence` pref (see the open guard below). */
store: SidebarStore
onOpenFile: (path: string) => void
/** Open a diff tab (the shell places it below the git pane on first use). */
onOpenDiff: (tab: SidebarTab) => 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
}) {
const { scope, store, onOpenFile, onOpenDiff, visible } = props
}
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>()
@@ -122,8 +121,8 @@ export function GitView(props: {
* 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 selectedRef = useRef<string | undefined>(undefined)
useEffect(() => { selectedRef.current = selectedWorktree }, [selectedWorktree])
const chosenPathRef = useRef<string | undefined>(undefined)
useEffect(() => { chosenPathRef.current = selectedWorktree }, [selectedWorktree])
const gitScope: SessionScope = repoRoot === undefined ? scope : { ...scope, repoRoot }
@@ -164,8 +163,8 @@ export function GitView(props: {
const listed = await api.gitWorktrees(scope)
if (generation !== refreshGeneration.current) return
setWorktrees(listed)
const selectedStillExists = listed.some(entry => entry.path === selectedRef.current)
let target = selectedStillExists ? selectedRef.current : listed.find(entry => entry.current)?.path
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.
@@ -176,12 +175,12 @@ export function GitView(props: {
? dirtyLinked[0]!.path
: current?.path
}
const targetChanged = target !== selectedRef.current
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
selectedRef.current = target
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
@@ -214,7 +213,7 @@ export function GitView(props: {
refreshGeneration.current += 1
refreshInFlight.current = false
worktreeChosenByUser.current = false
selectedRef.current = undefined
chosenPathRef.current = undefined
setSelectedWorktree(undefined)
}, [scope.sessionId, scope.cwd])
useEffect(() => { void refresh() }, [refresh])
@@ -223,7 +222,7 @@ export function GitView(props: {
* checkout-derived surface before destructive history actions can run. */
const chooseWorktree = (target: string): void => {
worktreeChosenByUser.current = true
selectedRef.current = target
chosenPathRef.current = target
setSelectedWorktree(target)
setStatus(null)
setBranchNames([])
@@ -248,7 +247,7 @@ export function GitView(props: {
// 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(selectedRef.current ?? '', { loading: true, generation })
void refreshTarget(chosenPathRef.current ?? '', { loading: true, generation })
}
useEffect(() => {
if (!visible) return
@@ -260,42 +259,49 @@ export function GitView(props: {
const loadMoreLog = async (): Promise<void> => {
if (logLoadingMore || logEnded) return
const generation = refreshGeneration.current
const target = selectedRef.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 !== selectedRef.current) return
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 === selectedRef.current) {
if (generation === refreshGeneration.current && target === chosenPathRef.current) {
setCommitError(`${t('historyLoadError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
}
} finally {
if (generation === refreshGeneration.current && target === selectedRef.current) setLogLoadingMore(false)
if (generation === refreshGeneration.current && target === chosenPathRef.current) 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:${encodeURIComponent(selectedWorktree ?? '')}:${staged ? 's' : 'u'}:${entry.path}`,
type: 'diff',
title: baseName(entry.path),
diff: { kind: 'worktree', path: entry.path, staged, untracked: isUntracked(entry), worktree: selectedWorktree, repoRoot },
})
}
/** 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 diff tab for one commit (one tab per commit). */
const openCommitDiff = (entry: GitLogEntry): void => {
onOpenDiff({
id: `diff:c:${encodeURIComponent(selectedWorktree ?? '')}:${entry.hashFull}`,
type: 'diff',
title: `${entry.hash} ${entry.subject}`,
diff: { kind: 'commit', hash: entry.hash, hashFull: entry.hashFull, subject: entry.subject, 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> => {
@@ -387,16 +393,21 @@ export function GitView(props: {
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}>
<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={() => { openWorktreeDiff(entry, staged) }}
onClick={() => { onPreview(worktreeRefOf(entry, staged)) }}
onContextMenu={(event) => { openFileMenu(event, entry, staged) }}
>
<span className={css.gitBadge}>{badgeOf(entry)}</span>
<span className={css.gitBadge} data-letter={badgeOf(entry)}>{badgeOf(entry)}</span>
<span className={css.gitName}>{entry.path}</span>
</button>
<button
@@ -531,12 +542,13 @@ export function GitView(props: {
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={() => { openCommitDiff(entry) }}
onClick={() => { onPreview(commitRefOf(entry)) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openCommitDiff(entry)
onPreview(commitRefOf(entry))
}
}}
onContextMenu={(event) => { openHistoryMenu(event, entry) }}
@@ -650,7 +662,7 @@ export function GitView(props: {
if (target === null) return
setHistoryMenu(null)
if (id === 'view') {
openCommitDiff(target.entry)
onPreview(commitRefOf(target.entry))
return
}
if (id === 'copyShort') {
+127
View File
@@ -0,0 +1,127 @@
/**
* The session lens of the changes tab: agent truth — every file the model
* read, wrote, or edited in this session (folded from the session event
* log), grouped by file, newest first, with kind filters. Clicking an op
* previews it in the tab's shared bottom pane (see {@link DiffPane}): writes
* and edits as line diffs, reads as a line-numbered content view, failures
* as their real error text. Live `tool/call` / `tool/result` events append
* while the tab is visible (bounded; paused and caught up when hidden).
*/
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import type { SidebarSessionEvent } from '../../context-types.ts'
import type { Context } from '../../context-types.ts'
import type { SessionScope } from '../api.ts'
import { relativeTime, t } from '../locales.ts'
import { extractFileOps, groupByFile, knownContentBefore, type FileOp, type FileOpKind } from './ops.ts'
import { formatBytes } from '../diff/rows.ts'
import css from './changes.module.css'
/** Cap on accumulated events: the lens shows the recent window, not eternity. */
const EVENTS_CAP = 4000
/** The op-kind filter chips: 'all' or one concrete kind. */
type OpFilter = 'all' | FileOpKind
export interface SessionLensProps {
ctx: Context
scope: SessionScope
/** Preview one op in the shared bottom pane (with its best-effort prior content). */
onPreview: (path: string, op: FileOp, prior: string | undefined) => void
/** The op currently previewed (row highlight); null when the pane is closed. */
selectedCallId: string | null
/** Fold events only while the tab is actually visible. */
visible: boolean
}
export function SessionLens({ ctx, scope, onPreview, selectedCallId, visible }: SessionLensProps) {
// The event log snapshot seeds the lens once; live tool/call and
// tool/result events append (bounded) and re-extract while visible.
const eventsRef = useRef<readonly SidebarSessionEvent[] | null>(null)
if (eventsRef.current === null) {
eventsRef.current = ctx.sessions.get(scope.sessionId)?.events ?? []
}
const [tick, setTick] = useState(0)
useEffect(() => {
if (typeof ctx.on !== 'function') return
const off = ctx.on('session/event', (session: unknown, event: SidebarSessionEvent) => {
const sessionId = (session as { id?: unknown } | null)?.id
if (sessionId !== scope.sessionId) return
if (event.type !== 'tool/call' && event.type !== 'tool/result') return
const list = [...eventsRef.current ?? [], event]
eventsRef.current = list.length > EVENTS_CAP ? list.slice(list.length - EVENTS_CAP) : list
if (visible) setTick(value => value + 1)
})
return () => { off() }
}, [ctx, scope.sessionId, visible])
// Becoming visible again catches up on events appended while paused.
useEffect(() => { if (visible) setTick(value => value + 1) }, [visible])
const ops = useMemo(() => extractFileOps(eventsRef.current ?? []), [tick])
const [filter, setFilter] = useState<OpFilter>('all')
const filteredOps = useMemo(
() => (filter === 'all' ? ops : ops.filter(op => op.kind === filter)),
[ops, filter],
)
const groups = useMemo(() => groupByFile(filteredOps), [filteredOps])
const counts = useMemo(() => {
const map = new Map<FileOpKind, number>([['read', 0], ['write', 0], ['edit', 0]])
for (const op of ops) map.set(op.kind, (map.get(op.kind) ?? 0) + 1)
return map
}, [ops])
const chip = (value: OpFilter, label: string, count: number): ReactNode => (
<button
type="button"
key={value}
className={css.filterChip}
data-active={filter === value ? 'true' : undefined}
onClick={() => { setFilter(value) }}
aria-pressed={filter === value}
>
{label}{value !== 'all' ? ` ${String(count)}` : ''}
</button>
)
return (
<div className={css.session}>
<div className={css.filterRow} role="group" aria-label={t('changesSessionLens')}>
{chip('all', t('changesFilterAll'), ops.length)}
{chip('write', t('changesWrite'), counts.get('write') ?? 0)}
{chip('edit', t('changesEdit'), counts.get('edit') ?? 0)}
{chip('read', t('changesRead'), counts.get('read') ?? 0)}
</div>
<div className={css.sessionList}>
{ops.length === 0 && <div className={css.empty}>{t('changesSessionEmpty')}</div>}
{ops.length > 0 && filteredOps.length === 0 && <div className={css.empty}>{t('changesFilterEmpty')}</div>}
{[...groups.entries()].map(([path, fileOps]) => (
<div key={path} className={css.fileGroup}>
<div className={css.filePath} title={path}>{path}</div>
{fileOps.map(op => (
<button
type="button"
key={op.callId}
className={css.opRow}
data-op-kind={op.kind}
data-op-error={op.isError ? 'true' : undefined}
data-selected={selectedCallId === op.callId ? 'true' : undefined}
onClick={() => { onPreview(path, op, knownContentBefore(ops, path, op)) }}
>
<span className={css.opKind} data-kind={op.kind}>
{t(op.kind === 'read' ? 'changesRead' : op.kind === 'write' ? 'changesWrite' : 'changesEdit')}
</span>
<span className={css.opTime}>{relativeTime(new Date(op.time).toISOString())}</span>
{op.running && <span className={css.opFlag}>{t('changesRunning')}</span>}
{op.isError && <span className={css.opFlagError}>{t('changesError')}</span>}
{op.kind !== 'read' && !op.isError && (
<span className={css.opSize}>
{formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
</span>
)}
</button>
))}
</div>
))}
</div>
</div>
)
}
+386
View File
@@ -0,0 +1,386 @@
/* The unified changes tab: lens switcher + Git lens + session lens + the
shared preview pane. Semantic --dsw-alias tokens only (skin contract).
The git lens styles moved here from sidebar.module.css when the git panel
became a lens of this tab; the generic placeholder/empty/error trio stays
duplicated in both modules (the diff tab still owns its copy). */
.root { display: flex; flex-direction: column; min-height: 0; height: 100%; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-primary); font-size: 12px; }
/* ── Lens switcher ─────────────────────────────────────────────────────── */
.lensBar { flex: none; display: flex; align-items: center; padding: 8px 8px 4px 12px; }
.lensSwitch { display: inline-flex; gap: 2px; padding: 2px; border: 1px solid var(--dsw-alias-border-l2); border-radius: 8px; }
.lensButton { border: 0; border-radius: 6px; padding: 2px 10px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-strong-11); cursor: pointer; }
.lensButton:hover { color: var(--dsw-alias-label-primary); }
.lensButton[data-active='true'] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); }
/* ── Git lens (the former git panel) ───────────────────────────────────── */
.git {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
overflow-y: auto;
/* Tab pages never scroll horizontally: content wraps or truncates to the
panel width instead of panning sideways. */
overflow-x: hidden;
}
.gitHeader {
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 36px;
padding: 0 8px 0 12px;
}
.gitWorktreeRow {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px 0 12px;
}
.gitWorktreeLabel {
flex: none;
color: var(--dsw-alias-label-tertiary);
font: var(--dsw-font-xxxs-11);
}
.gitBranchSelect {
flex: 1;
min-width: 0;
height: 26px;
padding: 0 6px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-alias-bg-base);
color: var(--dsw-alias-label-primary);
font: var(--dsw-font-xxs-12);
}
.gitSection {
border-top: 1px solid var(--dsw-alias-border-l1);
}
.gitSectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px 4px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-label-tertiary);
text-transform: uppercase;
}
.gitLink {
border: none;
background: transparent;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-brand-primary);
cursor: pointer;
padding: 0;
}
.gitLink:hover:not(:disabled) {
text-decoration: underline;
}
.gitLink:disabled {
opacity: 0.4;
cursor: default;
}
.gitRow {
display: flex;
align-items: center;
gap: 6px;
min-height: 34px;
margin: 0 6px;
padding: 0 8px;
border-radius: 8px;
animation: dsh-row-in 150ms var(--ds-ease-in-out);
}
.gitRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
}
.gitRowMain {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
border: none;
background: transparent;
padding: 3px 0;
cursor: pointer;
text-align: left;
}
.gitBadge {
flex: none;
width: 20px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 4px;
font: var(--dsw-font-xxxs-strong-11);
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
/* Status-letter tint: A added / D deleted / M·R modified / C·U conflicts. */
.gitBadge[data-letter='A'] { color: var(--dsw-alias-state-success-primary); }
.gitBadge[data-letter='D'] { color: var(--dsw-alias-state-error-primary); }
.gitBadge[data-letter='M'], .gitBadge[data-letter='R'] { color: var(--dsw-alias-state-business-primary); }
.gitBadge[data-letter='C'], .gitBadge[data-letter='U'] { color: var(--dsw-alias-state-warn-primary); }
.gitName {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitEmpty {
padding: 4px 12px 8px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
}
.gitPlaceholder {
padding: 16px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-tertiary);
text-align: center;
}
.gitError {
padding: 8px 12px;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-state-error-primary);
white-space: pre-wrap;
}
.gitConfirmDesc {
margin: 0;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
white-space: pre-wrap;
}
.gitCommit {
display: flex;
gap: 6px;
padding: 8px 12px;
border-top: 1px solid var(--dsw-alias-border-l1);
align-items: center;
}
.gitCommitInput {
flex: 1;
min-width: 0;
}
.gitCommitButton {
flex: none;
height: 26px;
padding: 0 12px;
border: none;
border-radius: 6px;
background: var(--dsw-alias-button-primary-fill);
color: var(--dsw-alias-label-primary-inverted);
font: var(--dsw-font-xxs-strong-12);
cursor: pointer;
}
.gitCommitButton:hover:not(:disabled) {
background: var(--dsw-alias-button-primary-hover);
}
.gitCommitButton:disabled {
opacity: 0.45;
cursor: default;
}
/* A history row: line 1 = hash + subject, line 2 = ref badges + author/date
(badges live on the second line so they never crowd the subject). */
.gitLogRow {
display: flex;
flex-direction: column;
gap: 2px;
padding: 5px 12px;
cursor: pointer;
border-radius: 8px;
}
.gitLogRow:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitLogRow[data-selected='true'] {
background: var(--dsw-alias-interactive-bg-active);
}
.gitLogLine1 {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.gitLogHash {
flex: none;
font: var(--dsw-font-markdown-code-block-small);
color: var(--dsw-alias-label-tertiary);
}
.gitLogLine2 {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: wrap;
}
.gitLogRef {
flex: none;
padding: 0 5px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 999px;
font: var(--dsw-font-xxxs-strong-11);
color: var(--dsw-alias-brand-primary);
white-space: nowrap;
}
.gitLogSubject {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--dsw-font-s-14);
color: var(--dsw-alias-label-primary);
}
.gitLogMeta {
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.gitLogMore {
display: block;
width: calc(100% - 24px);
margin: 4px 12px 8px;
padding: 6px 0;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: transparent;
font: var(--dsw-font-xxs-12);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.gitLogMore:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.gitLogMore:disabled {
opacity: 0.5;
cursor: default;
}
/* ── Session lens (this round's files) ─────────────────────────────────── */
.session { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.filterRow { flex: none; display: flex; gap: 4px; padding: 2px 8px 6px 12px; flex-wrap: wrap; }
.filterChip { border: 1px solid var(--dsw-alias-border-l2); border-radius: 999px; padding: 1px 8px; background: transparent; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxxs-11); cursor: pointer; }
.filterChip:hover { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); }
.filterChip[data-active='true'] { background: var(--dsw-alias-interactive-bg-active); color: var(--dsw-alias-label-primary); border-color: transparent; }
.sessionList { flex: 1; min-height: 0; overflow-y: auto; padding: 0 6px 10px; }
.empty { color: var(--dsw-alias-label-tertiary); padding: 24px 8px; text-align: center; }
.fileGroup { margin-bottom: 8px; }
.filePath { display: block; color: var(--dsw-alias-label-secondary); font-family: var(--ds-font-family-code, monospace); font-size: 11px; padding: 2px 4px; word-break: break-all; }
.opRow { display: flex; align-items: center; gap: 6px; width: 100%; border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 11px; padding: 3px 5px; cursor: pointer; text-align: left; }
.opRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.opRow[data-selected='true'] { background: var(--dsw-alias-interactive-bg-active); }
.opKind { border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); flex: none; }
.opKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.opKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
.opTime { color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; }
.opFlag { color: var(--dsw-alias-state-business-primary); font-size: 10px; flex: none; }
.opFlagError { color: var(--dsw-alias-state-error-primary); font-size: 10px; flex: none; }
.opSize { margin-left: auto; color: var(--dsw-alias-label-tertiary); font-size: 10px; flex: none; }
/* ── Shared preview pane ───────────────────────────────────────────────── */
.diffPane { border-top: 1px solid var(--dsw-alias-border-l1); display: flex; flex-direction: column; flex: none; min-height: 0; }
.dragHandle { flex: none; height: 8px; cursor: ns-resize; position: relative; background: var(--dsw-alias-bg-base); }
.dragHandle::after { content: ''; position: absolute; left: calc(50% - 18px); top: 3px; width: 36px; height: 2px; border-radius: 1px; background: var(--dsw-alias-border-l2); }
.dragHandle:hover::after, .dragHandle:focus-visible::after { background: var(--dsw-alias-state-business-primary); }
.diffHead { display: flex; align-items: center; gap: 6px; padding: 3px 8px 3px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
.diffKind { flex: none; border-radius: 4px; font-size: 10px; line-height: 16px; padding: 0 5px; background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-secondary); }
.diffKind[data-kind='git'] { color: var(--dsw-alias-brand-primary); }
.diffKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.diffKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
.diffPath { flex: 1; font-family: var(--ds-font-family-code, monospace); font-size: 11px; word-break: break-all; color: var(--dsw-alias-label-primary); min-width: 0; }
.diffStats { flex: none; display: inline-flex; gap: 5px; font-size: 10px; font-family: var(--ds-font-family-code, monospace); }
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
flex: none;
}
.iconButton:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.iconButton:disabled { opacity: 0.4; cursor: default; }
.paneBody { flex: 1; min-height: 0; overflow-y: auto; }
.readError { color: var(--dsw-alias-state-error-primary); border-left: 3px solid var(--dsw-alias-state-error-primary); border-radius: 0 6px 6px 0; margin: 6px 8px; padding: 6px 10px; white-space: pre-wrap; word-break: break-word; }
.priorUnknown { color: var(--dsw-alias-label-tertiary); font-size: 10px; padding: 3px 10px; }
/* ── Shared bits ───────────────────────────────────────────────────────── */
/* Mount fade for rows (the app's session-row reveal). */
@keyframes dsh-row-in {
from { opacity: 0; }
}
/* Keyboard focus rings match the shared sidebar convention. */
.gitLink:focus-visible, .gitRowMain:focus-visible, .gitLogRow:focus-visible,
.gitCommitButton:focus-visible, .gitLogMore:focus-visible, .gitBranchSelect:focus-visible,
.lensButton:focus-visible, .filterChip:focus-visible, .iconButton:focus-visible,
.dragHandle:focus-visible {
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
@media (prefers-reduced-motion: reduce) {
.gitRow { animation: none; }
}
@@ -1,10 +1,10 @@
/**
* File-operation extraction for the file-trace tab: 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.
* 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'
+122
View File
@@ -0,0 +1,122 @@
/**
* A full unified-diff document (one changed file, or every file of a commit
* patch): collapsible per-file headers — source files open by default, tests
* / docs / generated files stay folded — each expanded file rendering through
* the shared {@link DiffRows} (so git diffs get the same rewrite tinting,
* intra-line highlights, syntax colors and hunk folds as session-op diffs).
* Untracked files produce no `git diff` output; the caller passes their
* content to render as a full-file addition instead.
*/
import { useEffect, useMemo, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import { t } from '../locales.ts'
import { diffStats, displayPath, parseUnifiedDiff, unifiedSegments, untrackedFile, type DiffFile } from './rows.ts'
import { langOfPath } from './highlight.ts'
import { DiffRows } from './DiffRows.tsx'
import css from './diff.module.css'
const TEST_PATH = /(^|\/)(?:__tests__|tests?|specs?|fixtures?|mocks?|snapshots?)(?:\/|$)|\.(?:test|spec)\.[^/]+$/i
const DOC_PATH = /(^|\/)(?:docs?|documentation)(?:\/|$)|(^|\/)(?:readme|changelog|contributing|license|authors|notice)(\.[^/]*)?$/i
const GENERATED_PATH = /(^|\/)(?:dist|build|coverage|generated|vendor|node_modules)(?:\/|$)|(^|\/)(?:package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|composer\.lock|cargo\.lock|poetry\.lock)$/i
const SOURCE_PATH = /\.(?:js|jsx|mjs|cjs|ts|tsx|mts|cts|py|pyw|rb|php|java|kt|kts|scala|go|rs|swift|c|h|cc|cpp|cxx|hpp|hh|hxx|cs|fs|fsx|vb|dart|lua|r|ex|exs|erl|hrl|clj|cljs|cljc|groovy|sh|bash|zsh|fish|ps1|sql|vue|svelte|astro|html|htm|css|scss|sass|less)$/i
/** Source files open by default; tests, docs, generated files and unknown types stay folded. */
function defaultExpandedFiles(files: readonly DiffFile[]): Set<number> {
const expanded = new Set<number>()
files.forEach((file, index) => {
const path = displayPath(file.newPath === '/dev/null' ? file.oldPath : file.newPath)
if (!file.binary && file.hunks.length > 0
&& !TEST_PATH.test(path) && !DOC_PATH.test(path) && !GENERATED_PATH.test(path)
&& SOURCE_PATH.test(path)) {
expanded.add(index)
}
})
return expanded
}
/** 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
}
export interface DiffFilesProps {
/** 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 DiffFiles({ diff, untrackedPath, untrackedContent }: DiffFilesProps) {
const parsed = useMemo(() => {
if (untrackedPath !== undefined) {
return { files: [untrackedFile(untrackedPath, untrackedContent ?? '')] }
}
return parseUnifiedDiff(diff)
}, [diff, untrackedPath, untrackedContent])
const [expandedFiles, setExpandedFiles] = useState<Set<number>>(() => defaultExpandedFiles(parsed.files))
useEffect(() => { setExpandedFiles(defaultExpandedFiles(parsed.files)) }, [parsed])
// Segments and header stats computed once per file.
const files = useMemo(
() => parsed.files.map((file) => {
const segments = unifiedSegments(file)
return { file, segments, stats: diffStats(segments) }
}),
[parsed],
)
const renderFile = (entry: (typeof files)[number], fileIndex: number): ReactNode => {
const { file, segments, stats } = entry
const tag = fileTag(file)
const from = displayPath(file.oldPath)
const to = displayPath(file.newPath)
const expandable = !file.binary && file.hunks.length > 0
const fileExpanded = expandedFiles.has(fileIndex)
return (
<div key={`file-${String(fileIndex)}`} className={css.fileBlock}>
<button
type="button"
className={css.file}
disabled={!expandable}
aria-expanded={expandable ? fileExpanded : undefined}
onClick={() => {
setExpandedFiles(current => {
const next = new Set(current)
if (next.has(fileIndex)) next.delete(fileIndex)
else next.add(fileIndex)
return next
})
}}
>
{expandable && <span aria-hidden="true" className={clsx(css.fileChevron, fileExpanded && css.fileChevronExpanded)}>›</span>}
<span className={css.filePath}>{to}</span>
{from !== to && <span className={css.fileOld}>← {from}</span>}
{tag !== null && <span className={css.fileTag}>{tag}</span>}
{expandable && (stats.added > 0 || stats.deleted > 0) && (
<span className={css.fileStats}>
{stats.added > 0 && <span className={css.statAdd}>+{String(stats.added)}</span>}
{stats.deleted > 0 && <span className={css.statDel}>−{String(stats.deleted)}</span>}
</span>
)}
</button>
{expandable && fileExpanded && (
<DiffRows segments={segments} lang={langOfPath(to)} />
)}
</div>
)
}
if (parsed.files.length === 0) return null
return (
<div className={css.files}>
{files.map(renderFile)}
</div>
)
}
+270
View File
@@ -0,0 +1,270 @@
/**
* The one diff renderer every file-change surface shares (the changes tab's
* inline preview pane and the diff tab): fold-capable hunk rows with old/new
* line gutters, rewrite (mod) tinting with intra-line character highlights,
* lightweight syntax coloring, and long-line folding. Presentational — the
* segments arrive precomputed (session ops via buildDiffSegments, git diffs
* via unifiedSegments) so both producers render identically.
*/
import { useEffect, useMemo, useState, type ReactElement, type ReactNode } from 'react'
import { t } from '../locales.ts'
import { coalesceInline, diffInline, MIN_FOLD, type DiffRow, type DiffSegment, type InlineDiff } from './rows.ts'
import { hasBlockComment, isColored, scanLine, type CodeToken, type TokenType } from './highlight.ts'
import css from './diff.module.css'
/** Long diff lines fold to one ellipsized row; the threshold is the char count. */
const FOLD_THRESHOLD = 120
/** Rendered hunk rows per file capped at this count; expand reveals the rest. */
const MAX_ROWS = 600
/** Token class -> CSS color class ('' inherits the row's diff color). */
const TOKEN_CLASS: Readonly<Record<TokenType, string>> = {
plain: '',
comment: css.tokComment ?? '',
string: css.tokString ?? '',
keyword: css.tokKeyword ?? '',
number: css.tokNumber ?? '',
type: css.tokType ?? '',
function: css.tokFunction ?? '',
macro: css.tokMacro ?? '',
}
/** One token span's class list: its color class, plus the change tint. */
function tokenSpanClass(type: TokenType, changed: boolean): string {
const color = TOKEN_CLASS[type]
return changed ? `${color} ${css.inlineChange}` : color
}
/** Render scanned tokens as colored nodes; uncolored runs stay text. */
function tokensToNodes(tokens: readonly CodeToken[], changed = false): ReactNode[] {
const nodes: ReactNode[] = []
for (const token of tokens) {
if (!changed && !isColored(token)) nodes.push(token.text)
else nodes.push(<span key={String(nodes.length)} className={tokenSpanClass(token.type, changed)}>{token.text}</span>)
}
return nodes
}
/**
* Intra-line diffs for mod-row pairs, keyed by row identity: each mod-run's
* first half (old side) pairs with its second half (new side), each pair
* diffed by common prefix/suffix so both sides highlight the exact changed
* substring.
*/
function buildInlineMap(segments: readonly DiffSegment[]): Map<DiffRow, InlineDiff> {
const map = new Map<DiffRow, InlineDiff>()
for (const segment of segments) {
if (segment.kind !== 'hunk') continue
const rows = segment.rows
let i = 0
while (i < rows.length) {
if (rows[i]!.kind !== 'mod') { i += 1; continue }
let j = i
while (j < rows.length && rows[j]!.kind === 'mod') j += 1
const block = rows.slice(i, j)
const half = Math.floor(block.length / 2)
for (let p = 0; p < half; p += 1) {
const delRow = block[p]!
const addRow = block[p + half]!
const inline = diffInline(delRow.text, addRow.text)
map.set(delRow, inline)
map.set(addRow, inline)
}
i = j
}
}
return map
}
/**
* Per-row block-comment entry state for a diff: the old side threads along
* old-line order and the new side along new-line order (the row order
* preserves both), so multi-line comments color correctly on each side.
*/
function diffBlockEntries(segments: readonly DiffSegment[], lang: string | undefined): Map<DiffRow, boolean> {
const entries = new Map<DiffRow, boolean>()
if (!hasBlockComment(lang)) return entries
let oldIn = false
let newIn = false
for (const segment of segments) {
if (segment.kind !== 'hunk') continue
for (const row of segment.rows) {
const isOld = row.oldLine !== undefined
const isNew = row.newLine !== undefined
entries.set(row, isOld ? oldIn : newIn)
if (isOld) oldIn = scanLine(row.text, lang, oldIn).inBlock
if (isNew) newIn = scanLine(row.text, lang, newIn).inBlock
}
}
return entries
}
export interface DiffRowsProps {
/** Precomputed segments (hunks and folds) for one file's diff. */
segments: readonly DiffSegment[]
/** Syntax language id (langOfPath); undefined renders plain text. */
lang?: string
}
/** One file's diff rows: fold chips between hunks, highlighted code rows. */
export function DiffRows({ segments, lang }: DiffRowsProps) {
// Long diff lines fold to one ellipsized row; the set holds expanded row keys.
const [expandedLines, setExpandedLines] = useState<ReadonlySet<string>>(new Set())
// Hunk-fold segments expanded by index; default collapsed.
const [expandedFolds, setExpandedFolds] = useState<ReadonlySet<number>>(new Set())
// Row-count cap expanded: a huge file renders head rows plus this button.
const [expandedAll, setExpandedAll] = useState(false)
// Reset all folding when the segments identity changes (new target).
useEffect(() => {
setExpandedLines(new Set())
setExpandedFolds(new Set())
setExpandedAll(false)
}, [segments])
const inlineMap = useMemo(() => buildInlineMap(segments), [segments])
const blockEntries = useMemo(() => diffBlockEntries(segments, lang), [segments, lang])
/** One diff row: colored sign + syntax-colored text, long-line fold toggle. */
const renderDiffRow = (row: DiffRow, rowKey: string): ReactElement => {
if (row.kind === 'meta') {
return (
<div key={rowKey} className={css.row} data-kind="meta">
<span className={css.metaText}>{row.text}</span>
</div>
)
}
const isLong = row.text.length > FOLD_THRESHOLD
const isFolded = isLong && !expandedLines.has(rowKey)
const blockEntry = blockEntries.get(row) ?? false
return (
<div
key={rowKey}
className={css.row}
data-kind={row.kind}
data-folded={isFolded ? 'true' : undefined}
onClick={isLong ? () => {
setExpandedLines(prev => {
const next = new Set(prev)
if (next.has(rowKey)) next.delete(rowKey)
else next.add(rowKey)
return next
})
} : undefined}
title={isFolded ? row.text : undefined}
>
<span className={css.lineNo}>{row.oldLine !== undefined ? String(row.oldLine) : ''}</span>
<span className={css.lineNo}>{row.newLine !== undefined ? String(row.newLine) : ''}</span>
<span className={css.sign}>{row.kind === 'del' ? '-' : row.kind === 'add' ? '+' : row.kind === 'mod' ? '~' : ' '}</span>
<span className={css.text} data-folded={isFolded ? 'true' : undefined}>
{row.kind === 'mod' && (() => {
const inline = inlineMap.get(row)
if (inline === undefined) return tokensToNodes(scanLine(row.text, lang, blockEntry).tokens)
// Coalesced change runs, each split into syntax tokens with
// block-comment state threaded across the runs of this line.
const side = coalesceInline(row.oldLine !== undefined ? inline.old : inline.next)
const nodes: ReactNode[] = []
let state = blockEntry
for (const seg of side) {
const scan = scanLine(seg.text, lang, state)
state = scan.inBlock
nodes.push(...tokensToNodes(scan.tokens, seg.changed))
}
return nodes
})()}
{row.kind !== 'mod' && tokensToNodes(scanLine(row.text, lang, blockEntry).tokens)}
</span>
</div>
)
}
let renderedRows = 0
const renderSegment = (segment: DiffSegment, segIndex: number): ReactNode => {
if (segment.kind === 'hunk') {
const capped = !expandedAll && renderedRows + segment.rows.length > MAX_ROWS
const rows = capped ? segment.rows.slice(0, Math.max(MAX_ROWS - renderedRows, 0)) : segment.rows
renderedRows += rows.length
return (
<div key={`hunk-${String(segIndex)}`}>
{rows.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))}
</div>
)
}
const expandable = segment.rows !== undefined && segment.count >= MIN_FOLD
if (!expandable) {
// A tiny fold (or a git gap with no rows to reveal): a quiet marker.
return (
<div key={`fold-${String(segIndex)}`} className={css.foldRow}>
<span className={css.foldMarker} title={t('changesContext')}>
{t('changesFold', { count: segment.count })}
</span>
</div>
)
}
const isExpanded = expandedFolds.has(segIndex)
return (
<div
key={`fold-${String(segIndex)}`}
className={css.foldRow}
data-expanded={isExpanded ? 'true' : undefined}
onClick={() => {
setExpandedFolds(prev => {
const next = new Set(prev)
if (next.has(segIndex)) next.delete(segIndex)
else next.add(segIndex)
return next
})
}}
>
{isExpanded
? segment.rows!.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`))
: (
<span className={css.foldMarker} title={t('changesContext')}>
{t('changesFold', { count: segment.count })}
</span>
)}
</div>
)
}
const parts = segments.map(renderSegment)
return (
<div className={css.rows}>
{parts}
{renderedRows >= MAX_ROWS && !expandedAll && (
<button type="button" className={css.expand} onClick={() => { setExpandedAll(true) }}>
{t('diffExpand', { count: segments.reduce((sum, s) => sum + (s.kind === 'hunk' ? s.rows.length : 0), 0) - renderedRows })}
</button>
)}
</div>
)
}
export interface ReadRowsProps {
/** The file lines with their real line numbers (parseReadLines output). */
lines: ReadonlyArray<{ line: number; text: string }>
/** Syntax language id (langOfPath); undefined renders plain text. */
lang?: string
}
/** The read view: a line-numbered, syntax-colored slice of a read file. */
export function ReadRows({ lines, lang }: ReadRowsProps) {
const rows = useMemo(() => {
let state = false
return lines.map((line) => {
const scan = scanLine(line.text, lang, state)
state = scan.inBlock
return { line: line.line, nodes: tokensToNodes(scan.tokens) }
})
}, [lines, lang])
return (
<div className={css.rows}>
{rows.map((row) => (
<div key={String(row.line)} className={css.row} data-kind="read">
<span className={css.lineNo}>{String(row.line)}</span>
<span className={css.text}>{row.nodes}</span>
</div>
))}
</div>
)
}
+81
View File
@@ -0,0 +1,81 @@
/* The shared diff renderer (DiffRows / DiffFiles): one visual language for
every file-change surface — the changes tab's preview pane, the diff tab,
session-op diffs and git diffs alike. Semantic --dsw-alias tokens only
(skin contract); the syntax-token hues derive from the alias state tokens
via color-mix so they sit BETWEEN the diff colors (violet keywords, teal
strings, brand-tinted types, warn-family numbers/functions, magenta
macros) instead of colliding with red del / green add / blue mod. */
.rows { padding: 4px 0 8px; font-family: var(--ds-font-family-code, monospace); font-size: 11px; }
.row { display: flex; align-items: flex-start; gap: 5px; padding: 0 10px; line-height: 18px; cursor: default; }
.row[data-folded='true'] { cursor: pointer; }
.lineNo { flex: none; width: 2.5em; color: var(--dsw-alias-label-tertiary); text-align: right; user-select: none; font-size: 10px; }
.sign { flex: none; width: 1em; text-align: center; user-select: none; font-weight: 700; }
.row[data-kind='del'] .sign, .row[data-kind='del'] .text { color: var(--dsw-alias-state-error-primary); }
.row[data-kind='add'] .sign, .row[data-kind='add'] .text { color: var(--dsw-alias-state-success-primary); }
.row[data-kind='mod'] .sign, .row[data-kind='mod'] .text { color: var(--dsw-alias-state-business-primary); }
.row[data-kind='context'] .sign, .row[data-kind='context'] .text { color: var(--dsw-alias-label-secondary); }
.row[data-kind='read'] .text { color: var(--dsw-alias-label-primary); }
/* Faint row tints under changed lines (GitHub-style, lighter than the text
hue so the syntax colors stay readable on top). */
.row[data-kind='del'] { background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); }
.row[data-kind='add'] { background: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); }
.row[data-kind='mod'] { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 8%, transparent); }
.row .text { flex: 1; min-width: 0; white-space: pre-wrap; word-break: break-word; }
.row[data-folded='true'] .text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* The `\ No newline at end of file` marker row attached to a hunk's tail. */
.row[data-kind='meta'] .metaText { color: var(--dsw-alias-label-tertiary); font-style: italic; }
/* Intra-line changed substring inside a rewritten (mod) line. */
.inlineChange { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 26%, transparent); border-radius: 3px; padding: 0 1px; }
/* Syntax-token colors: alias-token mixes between the diff hues. */
.tokComment { color: var(--dsw-alias-label-tertiary); font-style: italic; }
.tokKeyword { color: color-mix(in oklab, var(--dsw-alias-state-business-primary) 55%, var(--dsw-alias-state-error-primary)); }
.tokString { color: color-mix(in oklab, var(--dsw-alias-state-success-primary) 45%, var(--dsw-alias-state-business-primary)); }
.tokType { color: color-mix(in oklab, var(--dsw-alias-brand-primary) 60%, var(--dsw-alias-state-success-primary)); }
.tokNumber { color: var(--dsw-alias-state-warn-primary); }
.tokFunction { color: color-mix(in oklab, var(--dsw-alias-state-warn-primary) 55%, var(--dsw-alias-state-error-primary)); }
.tokMacro { color: color-mix(in oklab, var(--dsw-alias-state-error-primary) 45%, var(--dsw-alias-state-business-primary)); }
/* Hunk-fold row: collapsed marker chip; expanded padding drops to 0 so the
revealed rows keep the x-alignment of hunk rows (they carry their own). */
.foldRow { padding: 0 10px; cursor: pointer; }
.foldRow[data-expanded='true'] { padding: 0; }
.foldRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.foldMarker { display: inline-block; padding: 2px 6px; border-radius: 4px; margin: 2px 0; color: var(--dsw-alias-label-tertiary); background: var(--dsw-alias-interactive-bg-hover); font-style: italic; }
.foldRow[data-expanded='true'] { cursor: default; }
.foldRow[data-expanded='true'] .foldMarker { display: none; }
/* The huge-file overflow reveal under a capped hunk. */
.expand { display: block; width: calc(100% - 20px); margin: 4px 10px; padding: 4px; border: 1px solid var(--dsw-alias-border-l1); border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; font-size: 11px; cursor: pointer; }
.expand:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
/* Multi-file documents (commit patches): collapsible file headers. */
.files { display: flex; flex-direction: column; }
.fileBlock { display: flex; flex-direction: column; }
.file { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 10px; border: 0; background: transparent; font: inherit; font-size: 11px; text-align: left; cursor: pointer; }
.file:disabled { cursor: default; }
.file:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover); }
.fileChevron { flex: none; width: 12px; color: var(--dsw-alias-label-tertiary); transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); }
.fileChevronExpanded { transform: rotate(90deg); }
.filePath { font-family: var(--ds-font-family-code, monospace); color: var(--dsw-alias-label-primary); word-break: break-all; min-width: 0; }
.fileOld { flex: none; max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code, monospace); }
.fileTag { 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); }
.fileStats { margin-left: auto; flex: none; display: inline-flex; gap: 5px; font-size: 10px; font-family: var(--ds-font-family-code, monospace); }
.statAdd { color: var(--dsw-alias-state-success-primary); }
.statDel { color: var(--dsw-alias-state-error-primary); }
/* Keyboard focus rings match the shared sidebar convention. */
.row:focus-visible, .foldRow:focus-visible, .file:focus-visible, .expand:focus-visible {
outline: 2px solid var(--dsw-alias-interactive-bg-hover-accent);
outline-offset: -1px;
}
@media (prefers-reduced-motion: reduce) {
.fileChevron { transition: none; }
}
@@ -1,11 +1,12 @@
/**
* Lightweight syntax highlighting for the file-trace panel: extension-based
* language detection plus a single-pass line tokenizer that colors comments,
* strings, numbers, keywords, types, functions, and preprocessor directives
* for common languages (C/C++, Java, C#, JS/TS, Python, Go, Rust, shell, batch,
* PowerShell, and config formats). Pure functions only — no React, no DOM.
* Multi-line block-comment state threads across lines via scanLine's
* inBlock parameter, so interior lines of a block comment color correctly.
* Lightweight syntax highlighting for the shared diff renderer: extension-
* based language detection plus a single-pass line tokenizer that colors
* comments, strings, numbers, keywords, types, functions, and preprocessor
* directives for common languages (C/C++, Java, C#, JS/TS, Python, Go, Rust,
* shell, batch, PowerShell, and config formats). Pure functions only — no
* React, no DOM. Multi-line block-comment state threads across lines via
* scanLine's inBlock parameter, so interior lines of a block comment color
* correctly.
*/
/** Token classes mapped 1:1 to panel CSS color classes. */
+473
View File
@@ -0,0 +1,473 @@
/**
* The one diff engine every file-change surface shares (the changes tab's
* git and session lenses, the diff tab): a unified DiffRow model fed by two
* producers — a line LCS over known contents (session file ops) and git's
* own unified-diff text (worktree/commit diffs, via `parseUnifiedDiff` +
* `unifiedSegments`) — collapsed into hunk/fold segments for the shared
* renderer. Pure functions only — no React, no DOM.
*/
/** One rendered diff row. */
export interface DiffRow {
/** Change class: 'context' shared, 'del' removed, 'add' added, 'mod'
* rewritten (paired del+add), 'meta' an attached marker row. */
readonly kind: 'context' | 'del' | 'add' | 'mod' | 'meta'
/** Old-side 1-based line number; absent on pure additions. */
readonly oldLine?: number
/** New-side 1-based line number; absent on pure deletions. */
readonly newLine?: number
/** The line's text without its terminator. */
readonly text: string
}
/**
* Longest-common-subsequence table over line equality.
* @param oldLines - old side lines.
* @param newLines - new side lines.
* @returns the LCS length matrix (rows index oldLines, columns newLines).
*/
function lcsTable(oldLines: readonly string[], newLines: readonly string[]): number[][] {
const table: number[][] = Array.from({ length: oldLines.length + 1 }, () => new Array<number>(newLines.length + 1).fill(0))
for (let i = oldLines.length - 1; i >= 0; i -= 1) {
for (let j = newLines.length - 1; j >= 0; j -= 1) {
table[i]![j] = oldLines[i] === newLines[j]
? table[i + 1]![j + 1]! + 1
: Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
}
}
return table
}
/**
* Pair each del-run with the add-run that follows it: the overlapping
* `min(len)` rows on both sides become 'mod' so rewrites tint distinctly and
* receive intra-line highlighting. Pure — returns a new array when anything
* changed. Shared by the LCS walk and the unified-diff converter so git
* hunks rewrite-pair exactly like session ops do.
*/
export function pairMods(rows: readonly DiffRow[]): DiffRow[] {
const out = rows.slice()
let k = 0
while (k < out.length) {
if (out[k]!.kind !== 'del') { k += 1; continue }
const delStart = k
while (k < out.length && out[k]!.kind === 'del') k += 1
const addStart = k
while (k < out.length && out[k]!.kind === 'add') k += 1
const pairs = Math.min(addStart - delStart, k - addStart)
for (let p = 0; p < pairs; p += 1) {
out[delStart + p] = { ...out[delStart + p]!, kind: 'mod' }
out[addStart + p] = { ...out[addStart + p]!, kind: 'mod' }
}
}
return out
}
/**
* Line diff by LCS walk with rewrite pairing: the raw walk emits context/del/
* add rows; `pairMods` marks rewritten pairs as 'mod'.
* @param oldText - the previous content; empty string diffs against nothing.
* @param newText - the next content.
* @returns ordered diff rows, old-side deletions before new-side additions.
*/
export function diffLines(oldText: string, newText: string): DiffRow[] {
const oldLines = oldText.length === 0 ? [] : oldText.split('\n')
const newLines = newText.length === 0 ? [] : newText.split('\n')
const table = lcsTable(oldLines, newLines)
const raw: DiffRow[] = []
let i = 0
let j = 0
while (i < oldLines.length && j < newLines.length) {
if (oldLines[i] === newLines[j]) {
raw.push({ kind: 'context', oldLine: i + 1, newLine: j + 1, text: oldLines[i]! })
i += 1
j += 1
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
raw.push({ kind: 'del', oldLine: i + 1, text: oldLines[i]! })
i += 1
} else {
raw.push({ kind: 'add', newLine: j + 1, text: newLines[j]! })
j += 1
}
}
while (i < oldLines.length) {
raw.push({ kind: 'del', oldLine: i + 1, text: oldLines[i]! })
i += 1
}
while (j < newLines.length) {
raw.push({ kind: 'add', newLine: j + 1, text: newLines[j]! })
j += 1
}
return pairMods(raw)
}
/** One rendered hunk: change rows plus the surrounding context window. */
export interface HunkSegment { readonly kind: 'hunk'; readonly rows: readonly DiffRow[] }
/** One collapsed run of unchanged rows (the "… n 行" fold). */
export interface FoldSegment {
readonly kind: 'fold'
/** The rows this fold hides, when the producer knows them (session diffs
* re-derive them from full contents; git gaps never emitted the text, so
* those folds carry only their line ranges and cannot expand). */
readonly rows?: readonly DiffRow[]
/** How many rows the fold hides. */
readonly count: number
readonly oldStart: number
readonly oldEnd: number
readonly newStart: number
readonly newEnd: number
}
export type DiffSegment = HunkSegment | FoldSegment
/** Context window (rows around a change) kept visible in a hunk. */
export const HUNK_CONTEXT = 3
/** Folded context runs shorter than this are shown directly, not collapsed. */
export const MIN_FOLD = 3
/**
* Group a line diff into hunks and folded context runs. Consecutive changes
* whose gap fits within the context window merge into one hunk; unchanged
* regions between hunks (and any surrounding the whole diff) become fold
* segments that default collapsed. This yields the file-hunk presentation
* familiar from terminal diffs (Claude Code / git hunk headers).
* @param rows - the flat diff rows.
* @param context - how many unchanged rows around a change stay visible.
* @returns ordered segments (hunks and folds).
*/
export function buildDiffSegments(rows: readonly DiffRow[], context = HUNK_CONTEXT): DiffSegment[] {
if (rows.length === 0) return []
const changeIndexes = rows.flatMap((row, index) => (row.kind === 'context' || row.kind === 'meta' ? [] : [index]))
if (changeIndexes.length === 0) {
// Whole diff is unchanged (or a pure no-op): hide it all behind one fold.
return [{
kind: 'fold', rows: [...rows], count: rows.length,
oldStart: 1, oldEnd: rows.length, newStart: 1, newEnd: rows.length,
}]
}
// Cluster changes into runs whose gaps fit within 2*context+1 rows.
const hunks: { start: number; end: number }[] = []
for (const ci of changeIndexes) {
const start = Math.max(0, ci - context)
const end = Math.min(rows.length - 1, ci + context)
const last = hunks[hunks.length - 1]
if (last !== undefined && start <= last.end + 1) {
last.end = Math.max(last.end, end)
} else {
hunks.push({ start, end })
}
}
const segments: DiffSegment[] = []
let cursor = 0
for (const hunk of hunks) {
if (hunk.start > cursor) segments.push(foldOf(rows.slice(cursor, hunk.start)))
segments.push({ kind: 'hunk', rows: rows.slice(hunk.start, hunk.end + 1) })
cursor = hunk.end + 1
}
if (cursor < rows.length) segments.push(foldOf(rows.slice(cursor)))
return segments
}
function foldOf(rows: readonly DiffRow[]): FoldSegment {
return {
kind: 'fold',
rows,
count: rows.length,
oldStart: firstOldLine(rows) ?? 1,
oldEnd: lastOldLine(rows) ?? 0,
newStart: firstNewLine(rows) ?? 1,
newEnd: lastNewLine(rows) ?? 0,
}
}
function firstOldLine(rows: readonly DiffRow[]): number | undefined {
for (const row of rows) if (row.oldLine !== undefined) return row.oldLine
return undefined
}
function lastOldLine(rows: readonly DiffRow[]): number | undefined {
for (let i = rows.length - 1; i >= 0; i -= 1) if (rows[i]!.oldLine !== undefined) return rows[i]!.oldLine
return undefined
}
function firstNewLine(rows: readonly DiffRow[]): number | undefined {
for (const row of rows) if (row.newLine !== undefined) return row.newLine
return undefined
}
function lastNewLine(rows: readonly DiffRow[]): number | undefined {
for (let i = rows.length - 1; i >= 0; i -= 1) if (rows[i]!.newLine !== undefined) return rows[i]!.newLine
return undefined
}
/** One intra-line segment: a run of chars with a changed flag. */
export interface InlineSegment { readonly text: string; readonly changed: boolean }
/** Each side's intra-line segments. */
export interface InlineDiff { readonly old: readonly InlineSegment[]; readonly next: readonly InlineSegment[] }
/**
* Intra-line diff by common prefix/suffix: the shared leading and trailing
* characters stay unchanged, and only the differing middle is marked changed
* on both sides. This never highlights identical characters and keeps a small
* edit inside a long line immediately visible. Pure, no React/DOM.
* @param oldText - the old line.
* @param newText - the new line.
* @returns per-side segments marking changed runs.
*/
export function diffInline(oldText: string, newText: string): InlineDiff {
const minLen = Math.min(oldText.length, newText.length)
let prefix = 0
while (prefix < minLen && oldText[prefix] === newText[prefix]) prefix += 1
let suffix = 0
while (suffix < minLen - prefix && oldText[oldText.length - 1 - suffix] === newText[newText.length - 1 - suffix]) suffix += 1
const oldMidStart = prefix
const oldMidEnd = oldText.length - suffix
const newMidStart = prefix
const newMidEnd = newText.length - suffix
const segments = (text: string, midStart: number, midEnd: number): InlineSegment[] => {
const out: InlineSegment[] = []
if (midStart > 0) out.push({ text: text.slice(0, midStart), changed: false })
const mid = text.slice(midStart, midEnd)
if (mid.length > 0) out.push({ text: mid, changed: true })
if (midEnd < text.length) out.push({ text: text.slice(midEnd), changed: false })
return out
}
return { old: segments(oldText, oldMidStart, oldMidEnd), next: segments(newText, newMidStart, newMidEnd) }
}
/**
* Merge adjacent inline segments sharing the same changed flag, so rendering
* wraps each run — not each character — in one span.
* @param segments - the raw per-character-heavy inline segments.
* @returns coalesced segments; identical text joined into runs.
*/
export function coalesceInline(segments: readonly InlineSegment[]): InlineSegment[] {
const out: InlineSegment[] = []
for (const seg of segments) {
const last = out[out.length - 1]
if (last !== undefined && last.changed === seg.changed) out[out.length - 1] = { text: last.text + seg.text, changed: seg.changed }
else out.push(seg)
}
return out
}
/** Human byte count for the panel meta row. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${String(bytes)} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
// ─────────────────────────────────────────────────────────────────────────────
// Unified-diff text (git) → DiffRow segments
// ─────────────────────────────────────────────────────────────────────────────
/** One parsed diff line of a unified diff (git's own hunk structure). */
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 }
}
/** One DiffLine row mapped onto the shared DiffRow model. */
function rowOfLine(line: DiffLine): DiffRow {
if (line.kind === 'meta') return { kind: 'meta', text: line.text }
const kind: DiffRow['kind'] = line.kind === 'ctx' ? 'context' : line.kind
if (line.kind === 'del') return { kind, oldLine: line.oldNum ?? undefined, text: line.text }
if (line.kind === 'add') return { kind, newLine: line.newNum ?? undefined, text: line.text }
return { kind, oldLine: line.oldNum ?? undefined, newLine: line.newNum ?? undefined, text: line.text }
}
/**
* Convert one parsed unified-diff file into renderer segments: each hunk's
* lines become a paired-mod hunk segment, and the unemitted context gaps
* between hunks (git already trimmed them) become non-expandable folds that
* still carry their old/new line ranges. The rewrite pairing applies within
* each hunk, exactly like session-op diffs.
*/
export function unifiedSegments(file: DiffFile): DiffSegment[] {
const segments: DiffSegment[] = []
file.hunks.forEach((hunk, index) => {
const rows = pairMods(hunk.lines.map(rowOfLine))
const prev = file.hunks[index - 1]
if (prev !== undefined) {
// The gap git never emitted: rows are unknown (non-expandable), but
// the ranges are derivable from the surrounding hunk headers/counts.
const prevOldEnd = prev.oldStart + prev.lines.filter(line => line.oldNum !== null).length - 1
const prevNewEnd = prev.newStart + prev.lines.filter(line => line.newNum !== null).length - 1
const oldGap = hunk.oldStart - prevOldEnd - 1
const newGap = hunk.newStart - prevNewEnd - 1
if (oldGap > 0 || newGap > 0) {
segments.push({
kind: 'fold',
count: Math.max(oldGap, newGap, 0),
oldStart: prevOldEnd + 1,
oldEnd: Math.max(hunk.oldStart - 1, prevOldEnd),
newStart: prevNewEnd + 1,
newEnd: Math.max(hunk.newStart - 1, prevNewEnd),
})
}
} else if (hunk.oldStart > 1 || hunk.newStart > 1) {
// Leading context git trimmed (e.g. -U0 diffs).
segments.push({
kind: 'fold',
count: Math.max(hunk.oldStart - 1, hunk.newStart - 1, 0),
oldStart: 1,
oldEnd: hunk.oldStart - 1,
newStart: 1,
newEnd: hunk.newStart - 1,
})
}
segments.push({ kind: 'hunk', rows })
})
return segments
}
/** Build the untracked-file shape: one file, one hunk of pure additions. */
export 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). */
export 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 diff's add/del/mod row counts (the "+n −m" header chips). */
export function diffStats(segments: readonly DiffSegment[]): { added: number; deleted: number } {
let added = 0
let deleted = 0
for (const segment of segments) {
if (segment.kind !== 'hunk') continue
for (const row of segment.rows) {
if (row.kind === 'add' || row.kind === 'mod') added += 1
if (row.kind === 'del' || row.kind === 'mod') deleted += 1
}
}
return { added, deleted }
}
-336
View File
@@ -1,336 +0,0 @@
/**
* The file-trace tab: records every file the model read, wrote, or edited
* in this session (folded from the session event log) and reviews any op as
* a line diff (del red / add green / mod blue) or a line-numbered read view,
* with lightweight syntax coloring whose hues sit between the diff colors.
* Ported from the standalone dsh-file-trace plugin's panel; the tab can be
* disabled per user in the Side card settings when the standalone plugin is
* preferred.
*/
import { useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent, type ReactElement, type ReactNode } from 'react'
import type { SidebarSessionEvent } from '../../context-types.ts'
import type { TabComponentProps } from '../service.ts'
import { t } from '../locales.ts'
import { extractFileOps, groupByFile, knownContentBefore, parseReadLines, type FileOp } from './ops.ts'
import { diffLines, formatBytes, buildDiffSegments, diffInline, coalesceInline, MIN_FOLD, type DiffRow, type InlineDiff } from './diff.ts'
import { scanLine, isColored, langOfPath, hasBlockComment, type TokenType, type CodeToken } from './highlight.ts'
import css from './filetrace.module.css'
/** Long diff lines fold to one ellipsized row; the threshold is the char count. */
const FOLD_THRESHOLD = 120
/** Cap on accumulated events: the trace shows the recent window, not eternity. */
const EVENTS_CAP = 4000
/** Token class -> CSS color class ('' inherits the row's diff color). */
const TOKEN_CLASS: Readonly<Record<TokenType, string>> = {
plain: '',
comment: css.tokComment ?? '',
string: css.tokString ?? '',
keyword: css.tokKeyword ?? '',
number: css.tokNumber ?? '',
type: css.tokType ?? '',
function: css.tokFunction ?? '',
macro: css.tokMacro ?? '',
}
/** One token span's class list: its color class, plus the change tint. */
function tokenSpanClass(type: TokenType, changed: boolean): string {
const color = TOKEN_CLASS[type]
return changed ? `${color} ${css.inlineChange}` : color
}
/** Render scanned tokens as colored nodes; uncolored runs stay text. */
function tokensToNodes(tokens: readonly CodeToken[], changed = false): ReactNode[] {
const nodes: ReactNode[] = []
for (const token of tokens) {
if (!changed && !isColored(token)) nodes.push(token.text)
else nodes.push(<span key={String(nodes.length)} className={tokenSpanClass(token.type, changed)}>{token.text}</span>)
}
return nodes
}
/** Per-row block-comment entry state for a diff: the old side threads along
* old-line order and the new side along new-line order (the LCS row order
* preserves both), so multi-line comments color correctly on each side. */
function diffBlockEntries(rows: readonly DiffRow[], lang: string | undefined): Map<DiffRow, boolean> {
const entries = new Map<DiffRow, boolean>()
if (!hasBlockComment(lang)) return entries
let oldIn = false
let newIn = false
for (const row of rows) {
const isOld = row.oldLine !== undefined
const isNew = row.newLine !== undefined
entries.set(row, isOld ? oldIn : newIn)
if (isOld) oldIn = scanLine(row.text, lang, oldIn).inBlock
if (isNew) newIn = scanLine(row.text, lang, newIn).inBlock
}
return entries
}
/** Diff material for one operation: 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 file-trace tab body: op list above, selected diff/read pane below. */
export function FileTraceTab({ ctx, scope, visible }: TabComponentProps) {
// The event log snapshot seeds the trace 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 groups = useMemo(() => groupByFile(ops), [ops])
const [selected, setSelected] = useState<{ path: string; op: FileOp } | null>(null)
// Long diff lines fold to one ellipsized row; the set holds expanded row keys.
const [expandedLines, setExpandedLines] = useState<ReadonlySet<string>>(new Set())
// Hunk-fold segments expanded by index; default collapsed.
const [expandedFolds, setExpandedFolds] = useState<ReadonlySet<number>>(new Set())
// Bottom diff pane height in px; drag the handle to resize (min/max clamp).
const [diffHeight, setDiffHeight] = useState(300)
const selectedOp = selected?.op
const selectedLang = useMemo(
() => selected === null ? undefined : langOfPath(selected.path),
[selected],
)
const diffRows = useMemo(
() => selectedOp === undefined ? [] : diffOf(selectedOp, knownContentBefore(ops, selected?.path ?? '', selectedOp)),
[selectedOp, selected?.path, ops],
)
const segments = useMemo(() => buildDiffSegments(diffRows), [diffRows])
// Char-level highlight for mod-row pairs, keyed by (oldLine|newLine).
const inlineMap = useMemo(() => {
const map = new Map<string, InlineDiff>()
let i = 0
while (i < diffRows.length) {
if (diffRows[i]!.kind !== 'mod') { i += 1; continue }
let j = i
while (j < diffRows.length && diffRows[j]!.kind === 'mod') j += 1
const block = diffRows.slice(i, j)
const k = Math.floor(block.length / 2)
for (let p = 0; p < k; p += 1) {
const delRow = block[p]!
const addRow = block[p + k]!
const r = diffInline(delRow.text, addRow.text)
map.set(`${String(delRow.oldLine ?? '')}|${String(delRow.newLine ?? '')}`, r)
map.set(`${String(addRow.oldLine ?? '')}|${String(addRow.newLine ?? '')}`, r)
}
i = j
}
return map
}, [diffRows])
// Block-comment entry state per row, threaded per side.
const blockEntries = useMemo(() => diffBlockEntries(diffRows, selectedLang), [diffRows, selectedLang])
// Read view rows with block-comment state threaded down the file's lines.
const readRows = useMemo(() => {
if (selectedOp?.kind !== 'read' || selectedOp.read === undefined) return []
let state = false
return parseReadLines(selectedOp.read).map((line) => {
const scan = scanLine(line.text, selectedLang, state)
state = scan.inBlock
return { line: line.line, nodes: tokensToNodes(scan.tokens) }
})
}, [selectedOp, selectedLang])
// Reset folding when selecting a different operation.
useEffect(() => { setExpandedLines(new Set()); setExpandedFolds(new Set()) }, [selectedOp])
/** One diff row: colored sign + syntax-colored text, long-line fold toggle. */
const renderDiffRow = (row: DiffRow, rowKey: string, lang: string | undefined): ReactElement => {
const isLong = row.text.length > FOLD_THRESHOLD
const isFolded = isLong && !expandedLines.has(rowKey)
const blockEntry = blockEntries.get(row) ?? false
return (
<div
key={rowKey}
className={css.diffRow}
data-kind={row.kind}
data-folded={isFolded ? 'true' : undefined}
onClick={isLong ? () => {
setExpandedLines(prev => {
const next = new Set(prev)
if (next.has(rowKey)) next.delete(rowKey)
else next.add(rowKey)
return next
})
} : undefined}
title={isFolded ? row.text : undefined}
>
<span className={css.lineNo}>{row.oldLine !== undefined ? String(row.oldLine) : ''}</span>
<span className={css.lineNo}>{row.newLine !== undefined ? String(row.newLine) : ''}</span>
<span className={css.sign}>{row.kind === 'del' ? '-' : row.kind === 'add' ? '+' : row.kind === 'mod' ? '~' : ' '}</span>
<span className={css.text} data-folded={isFolded ? 'true' : undefined}>
{row.kind === 'mod' && (() => {
const inline = inlineMap.get(`${String(row.oldLine ?? '')}|${String(row.newLine ?? '')}`)
if (inline === undefined) return tokensToNodes(scanLine(row.text, lang, blockEntry).tokens)
// Coalesced change runs, each split into syntax tokens with
// block-comment state threaded across the runs of this line.
const side = coalesceInline(row.oldLine !== undefined ? inline.old : inline.next)
const nodes: ReactNode[] = []
let state = blockEntry
for (const seg of side) {
const scan = scanLine(seg.text, lang, state)
state = scan.inBlock
nodes.push(...tokensToNodes(scan.tokens, seg.changed))
}
return nodes
})()}
{row.kind !== 'mod' && tokensToNodes(scanLine(row.text, lang, blockEntry).tokens)}
</span>
</div>
)
}
const onHandleDown = (e: ReactPointerEvent<HTMLDivElement>): void => {
e.preventDefault()
const startY = e.clientY
const startH = diffHeight
const onMove = (ev: PointerEvent): void => {
setDiffHeight(Math.min(Math.max(startH + (startY - ev.clientY), 140), Math.round(window.innerHeight * 0.7)))
}
const onUp = (): void => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
}
return (
<div className={css.root}>
<div className={css.list}>
{ops.length === 0 && <div className={css.empty}>{t('fileTraceEmpty')}</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}
onClick={() => { setSelected({ path, op }) }}
>
<span className={css.opKind} data-kind={op.kind}>{t(op.kind === 'read' ? 'fileTraceRead' : op.kind === 'write' ? 'fileTraceWrite' : 'fileTraceEdit')}</span>
<span className={css.opTime}>{new Date(op.time).toLocaleTimeString()}</span>
{op.running && <span className={css.opFlag}>{t('fileTraceRunning')}</span>}
{op.isError && <span className={css.opFlagError}>{t('fileTraceError')}</span>}
{op.kind !== 'read' && !op.isError && (
<span className={css.opSize}>
{formatBytes(new Blob([op.edit?.newString ?? op.content ?? '']).size)}
</span>
)}
</button>
))}
</div>
))}
</div>
{selected !== null && (
<div className={css.diffPane} style={{ height: diffHeight }}>
<div className={css.dragHandle} onPointerDown={onHandleDown} role="separator" aria-orientation="horizontal" />
<div className={css.diffHead}>
<span className={css.diffPath}>{selected.path}</span>
<span className={css.diffKind} data-kind={selected.op.kind}>
{t(selected.op.kind === 'read' ? 'fileTraceRead' : selected.op.kind === 'write' ? 'fileTraceWrite' : 'fileTraceEdit')}
</span>
<button type="button" className={css.close} onClick={() => { setSelected(null) }}>×</button>
</div>
{selected.op.isError
? (
<div className={css.readContent} data-error="true">
<div className={css.readError} role="alert">
{selected.op.errorText ?? t('fileTraceError')}
</div>
</div>
)
: selected.op.kind === 'read'
? (
<div className={css.readContent}>
{readRows.map((row) => (
<div key={String(row.line)} className={css.readRow}>
<span className={css.lineNo}>{String(row.line)}</span>
<span className={css.text}>{row.nodes}</span>
</div>
))}
</div>
)
: (
<div className={css.diffRows}>
{selected.op.kind === 'write'
&& knownContentBefore(ops, selected.path, selected.op) === undefined
&& <div className={css.priorUnknown}>{t('fileTracePriorUnknown')}</div>}
{segments.map((segment, segIndex) => {
if (segment.kind === 'fold') {
const shouldFold = segment.rows.length >= MIN_FOLD
const isExpanded = expandedFolds.has(segIndex)
if (!shouldFold) {
return segment.rows.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`, selectedLang))
}
return (
<div
key={`fold-${String(segIndex)}`}
className={css.foldRow}
data-expanded={isExpanded ? 'true' : undefined}
onClick={() => {
setExpandedFolds(prev => {
const next = new Set(prev)
if (next.has(segIndex)) next.delete(segIndex)
else next.add(segIndex)
return next
})
}}
>
{isExpanded
? segment.rows.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`, selectedLang))
: (
<span className={css.foldMarker} title={t('fileTraceContext')}>
{t('fileTraceFold', { count: segment.rows.length })}
</span>
)}
</div>
)
}
return segment.rows.map((row, index) => renderDiffRow(row, `${segIndex}-${String(index)}`, selectedLang))
})}
</div>
)}
</div>
)}
</div>
)
}
-257
View File
@@ -1,257 +0,0 @@
/**
* Line-based diff engine for the file-trace panel: an LCS over lines with
* per-row change classes. Pure functions only — no React, no DOM.
*/
/** One rendered diff row. */
export interface DiffRow {
/** Change class: 'context' shared, 'del' removed, 'add' added, 'mod' rewritten. */
readonly kind: 'context' | 'del' | 'add' | 'mod'
/** Old-side 1-based line number; absent on pure additions. */
readonly oldLine?: number
/** New-side 1-based line number; absent on pure deletions. */
readonly newLine?: number
/** The line's text without its terminator. */
readonly text: string
}
/**
* Longest-common-subsequence table over line equality.
* @param oldLines - old side lines.
* @param newLines - new side lines.
* @returns the LCS length matrix (rows index oldLines, columns newLines).
*/
function lcsTable(oldLines: readonly string[], newLines: readonly string[]): number[][] {
const table: number[][] = Array.from({ length: oldLines.length + 1 }, () => new Array<number>(newLines.length + 1).fill(0))
for (let i = oldLines.length - 1; i >= 0; i -= 1) {
for (let j = newLines.length - 1; j >= 0; j -= 1) {
table[i]![j] = oldLines[i] === newLines[j]
? table[i + 1]![j + 1]! + 1
: Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
}
}
return table
}
/**
* Line diff by LCS walk with rewrite pairing: the raw walk emits context/del/
* add rows; a del-run immediately followed by an add-run marks the overlapping
* min(len) rows on both sides as 'mod' so rewrites tint distinctly.
* @param oldText - the previous content; empty string diffs against nothing.
* @param newText - the next content.
* @returns ordered diff rows, old-side deletions before new-side additions.
*/
export function diffLines(oldText: string, newText: string): DiffRow[] {
const oldLines = oldText.length === 0 ? [] : oldText.split('\n')
const newLines = newText.length === 0 ? [] : newText.split('\n')
const table = lcsTable(oldLines, newLines)
const raw: DiffRow[] = []
let i = 0
let j = 0
while (i < oldLines.length && j < newLines.length) {
if (oldLines[i] === newLines[j]) {
raw.push({ kind: 'context', oldLine: i + 1, newLine: j + 1, text: oldLines[i]! })
i += 1
j += 1
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) {
raw.push({ kind: 'del', oldLine: i + 1, text: oldLines[i]! })
i += 1
} else {
raw.push({ kind: 'add', newLine: j + 1, text: newLines[j]! })
j += 1
}
}
while (i < oldLines.length) {
raw.push({ kind: 'del', oldLine: i + 1, text: oldLines[i]! })
i += 1
}
while (j < newLines.length) {
raw.push({ kind: 'add', newLine: j + 1, text: newLines[j]! })
j += 1
}
// Post-pass: pair each del-run with the add-run that follows it.
const rows = raw.slice()
let k = 0
while (k < rows.length) {
if (rows[k]!.kind !== 'del') { k += 1; continue }
const delStart = k
while (k < rows.length && rows[k]!.kind === 'del') k += 1
const addStart = k
while (k < rows.length && rows[k]!.kind === 'add') k += 1
const pairs = Math.min(addStart - delStart, k - addStart)
for (let p = 0; p < pairs; p += 1) {
rows[delStart + p] = { ...rows[delStart + p]!, kind: 'mod' }
rows[addStart + p] = { ...rows[addStart + p]!, kind: 'mod' }
}
}
return rows
}
/** One rendered hunk: change rows plus the surrounding context window. */
export interface DiffHunk { readonly kind: 'hunk'; readonly rows: readonly DiffRow[] }
/** One collapsed run of unchanged context rows (the "… n 行" fold). */
export interface DiffFold {
readonly kind: 'fold'
/** The context rows this fold hides; re-render them when expanded. */
readonly rows: readonly DiffRow[]
readonly oldStart: number
readonly oldEnd: number
readonly newStart: number
readonly newEnd: number
}
export type DiffSegment = DiffHunk | DiffFold
/** Context window (rows around a change) kept visible in a hunk. */
export const HUNK_CONTEXT = 3
/** Folded context runs shorter than this are shown directly, not collapsed. */
export const MIN_FOLD = 3
/**
* Group a line diff into hunks and folded context runs. Consecutive changes
* whose gap fits within the context window merge into one hunk; unchanged
* regions between hunks (and any surrounding the whole diff) become fold
* segments that default collapsed. This yields the file-hunk presentation
* familiar from terminal diffs (Claude Code / git hunk headers).
* @param rows - the flat diff rows.
* @param context - how many unchanged rows around a change stay visible.
* @returns ordered segments (hunks and folds).
*/
export function buildDiffSegments(rows: readonly DiffRow[], context = HUNK_CONTEXT): DiffSegment[] {
if (rows.length === 0) return []
const changeIndexes = rows.flatMap((row, index) => (row.kind === 'context' ? [] : [index]))
if (changeIndexes.length === 0) {
// Whole diff is unchanged (or a pure no-op): hide it all behind one fold.
return [{ kind: 'fold', rows: [...rows], oldStart: 1, oldEnd: rows.length, newStart: 1, newEnd: rows.length }]
}
// Cluster changes into runs whose gaps fit within 2*context+1 rows.
const hunks: { start: number; end: number }[] = []
for (const ci of changeIndexes) {
const start = Math.max(0, ci - context)
const end = Math.min(rows.length - 1, ci + context)
const last = hunks[hunks.length - 1]
if (last !== undefined && start <= last.end + 1) {
last.end = Math.max(last.end, end)
} else {
hunks.push({ start, end })
}
}
const segments: DiffSegment[] = []
let cursor = 0
for (const hunk of hunks) {
if (hunk.start > cursor) {
const foldRows = rows.slice(cursor, hunk.start)
segments.push({
kind: 'fold',
rows: foldRows,
oldStart: firstOldLine(foldRows) ?? (foldRows.length === 0 ? cursor + 1 : cursor + 1),
oldEnd: lastOldLine(foldRows) ?? 0,
newStart: firstNewLine(foldRows) ?? (foldRows.length === 0 ? cursor + 1 : cursor + 1),
newEnd: lastNewLine(foldRows) ?? 0,
})
}
segments.push({ kind: 'hunk', rows: rows.slice(hunk.start, hunk.end + 1) })
cursor = hunk.end + 1
}
if (cursor < rows.length) {
const foldRows = rows.slice(cursor)
segments.push({
kind: 'fold',
rows: foldRows,
oldStart: firstOldLine(foldRows) ?? cursor + 1,
oldEnd: lastOldLine(foldRows) ?? 0,
newStart: firstNewLine(foldRows) ?? cursor + 1,
newEnd: lastNewLine(foldRows) ?? 0,
})
}
return segments
}
function firstOldLine(rows: readonly DiffRow[]): number | undefined {
for (const row of rows) if (row.oldLine !== undefined) return row.oldLine
return undefined
}
function lastOldLine(rows: readonly DiffRow[]): number | undefined {
for (let i = rows.length - 1; i >= 0; i -= 1) if (rows[i]!.oldLine !== undefined) return rows[i]!.oldLine
return undefined
}
function firstNewLine(rows: readonly DiffRow[]): number | undefined {
for (const row of rows) if (row.newLine !== undefined) return row.newLine
return undefined
}
function lastNewLine(rows: readonly DiffRow[]): number | undefined {
for (let i = rows.length - 1; i >= 0; i -= 1) if (rows[i]!.newLine !== undefined) return rows[i]!.newLine
return undefined
}
/** One intra-line segment: a run of chars with a changed flag. */
export interface InlineSegment { readonly text: string; readonly changed: boolean }
/** Each side's intra-line segments. */
export interface InlineDiff { readonly old: readonly InlineSegment[]; readonly next: readonly InlineSegment[] }
/** Char-diff table guard: above this, inline highlighting is skipped. */
const INLINE_MAX = 2000
/**
* Character-level diff between two line texts, used to highlight the exact
* changed substring inside a "mod" (rewritten) line. Long lines degrade to a
* single all-changed segment. Pure, no React/DOM.
* @param oldText - the old line.
* @param newText - the new line.
* @returns per-side segments marking changed runs.
*/
/**
* Intra-line diff by common prefix/suffix: the shared leading and trailing
* characters stay unchanged, and only the differing middle is marked changed
* on both sides. This never highlights identical characters and keeps a small
* edit inside a long line immediately visible. Pure, no React/DOM.
* @param oldText - the old line.
* @param newText - the new line.
* @returns per-side segments marking changed runs.
*/
export function diffInline(oldText: string, newText: string): InlineDiff {
const minLen = Math.min(oldText.length, newText.length)
let prefix = 0
while (prefix < minLen && oldText[prefix] === newText[prefix]) prefix += 1
let suffix = 0
while (suffix < minLen - prefix && oldText[oldText.length - 1 - suffix] === newText[newText.length - 1 - suffix]) suffix += 1
const oldMidStart = prefix
const oldMidEnd = oldText.length - suffix
const newMidStart = prefix
const newMidEnd = newText.length - suffix
const segments = (text: string, midStart: number, midEnd: number): InlineSegment[] => {
const out: InlineSegment[] = []
if (midStart > 0) out.push({ text: text.slice(0, midStart), changed: false })
const mid = text.slice(midStart, midEnd)
if (mid.length > 0) out.push({ text: mid, changed: true })
if (midEnd < text.length) out.push({ text: text.slice(midEnd), changed: false })
return out
}
return { old: segments(oldText, oldMidStart, oldMidEnd), next: segments(newText, newMidStart, newMidEnd) }
}
/**
* Merge adjacent inline segments sharing the same changed flag, so rendering
* wraps each run — not each character — in one span.
* @param segments - the raw per-character-heavy inline segments.
* @returns coalesced segments; identical text joined into runs.
*/
export function coalesceInline(segments: readonly InlineSegment[]): InlineSegment[] {
const out: InlineSegment[] = []
for (const seg of segments) {
const last = out[out.length - 1]
if (last !== undefined && last.changed === seg.changed) out[out.length - 1] = { text: last.text + seg.text, changed: seg.changed }
else out.push(seg)
}
return out
}
/** Human byte count for the panel meta row. */
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${String(bytes)} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
-76
View File
@@ -1,76 +0,0 @@
/* File-trace tab: terminal-style op list + diff pane. Semantic --dsw-alias
tokens only (skin contract); the syntax-token hues derive from the alias
state tokens via color-mix so they sit BETWEEN the diff colors (violet
keywords, teal strings, brand-tinted types, warn-family numbers/functions,
magenta macros) instead of colliding with red del / green add / blue mod. */
.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; }
.list { flex: 1; min-height: 0; overflow-y: auto; padding: 6px 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); }
.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; }
.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 { background: var(--dsw-alias-state-business-primary); }
.diffHead { display: flex; align-items: center; gap: 6px; padding: 5px 10px; border-bottom: 1px solid var(--dsw-alias-border-l1); }
.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; }
.diffKind { flex: none; font-size: 10px; }
.diffKind[data-kind='write'] { color: var(--dsw-alias-state-success-primary); }
.diffKind[data-kind='edit'] { color: var(--dsw-alias-state-business-primary); }
.close { border: 0; border-radius: 6px; background: transparent; color: var(--dsw-alias-label-secondary); font: inherit; cursor: pointer; padding: 1px 6px; flex: none; }
.close:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-label-primary); }
.diffRows { flex: 1; min-height: 0; overflow-y: auto; padding: 4px 0 8px; font-family: var(--ds-font-family-code, monospace); font-size: 11px; }
.diffRow { display: flex; align-items: flex-start; gap: 5px; padding: 0 8px; line-height: 18px; cursor: default; }
.diffRow[data-folded='true'] { cursor: pointer; }
.lineNo { flex: none; width: 2.5em; color: var(--dsw-alias-label-tertiary); text-align: right; user-select: none; font-size: 10px; }
.sign { flex: none; width: 1em; text-align: center; user-select: none; font-weight: 700; }
.diffRow[data-kind='del'] .sign, .diffRow[data-kind='del'] .text { color: var(--dsw-alias-state-error-primary); }
.diffRow[data-kind='add'] .sign, .diffRow[data-kind='add'] .text { color: var(--dsw-alias-state-success-primary); }
.diffRow[data-kind='mod'] .sign, .diffRow[data-kind='mod'] .text { color: var(--dsw-alias-state-business-primary); }
.diffRow[data-kind='context'] .sign, .diffRow[data-kind='context'] .text { color: var(--dsw-alias-label-secondary); }
.diffRow .text { flex: 1; min-width: 0; white-space: pre-wrap; word-break: break-word; }
.diffRow[data-folded='true'] .text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* Intra-line changed substring inside a rewritten (mod) line. */
.inlineChange { background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 26%, transparent); border-radius: 3px; padding: 0 1px; }
/* Syntax-token colors: alias-token mixes between the diff hues. */
.tokComment { color: var(--dsw-alias-label-tertiary); font-style: italic; }
.tokKeyword { color: color-mix(in oklab, var(--dsw-alias-state-business-primary) 55%, var(--dsw-alias-state-error-primary)); }
.tokString { color: color-mix(in oklab, var(--dsw-alias-state-success-primary) 45%, var(--dsw-alias-state-business-primary)); }
.tokType { color: color-mix(in oklab, var(--dsw-alias-brand-primary) 60%, var(--dsw-alias-state-success-primary)); }
.tokNumber { color: var(--dsw-alias-state-warn-primary); }
.tokFunction { color: color-mix(in oklab, var(--dsw-alias-state-warn-primary) 55%, var(--dsw-alias-state-error-primary)); }
.tokMacro { color: color-mix(in oklab, var(--dsw-alias-state-error-primary) 45%, var(--dsw-alias-state-business-primary)); }
.readContent { flex: 1; min-height: 0; margin: 0; padding: 6px 0 8px; overflow: auto; font-family: var(--ds-font-family-code, monospace); font-size: 11px; line-height: 1.5; }
.readRow { display: flex; gap: 5px; padding: 0 8px; line-height: 18px; color: var(--dsw-alias-label-primary); }
.readRow .text { flex: 1; min-width: 0; white-space: pre-wrap; word-break: break-word; }
.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; }
/* Hunk-fold row: collapsed marker chip; expanded padding drops to 0 so the
revealed rows keep the x-alignment of hunk rows (they carry their own). */
.foldRow { padding: 0 8px; cursor: pointer; }
.foldRow[data-expanded='true'] { padding: 0; }
.foldRow:hover { background: var(--dsw-alias-interactive-bg-hover); }
.foldMarker { display: inline-block; padding: 2px 6px; border-radius: 4px; margin: 2px 0; color: var(--dsw-alias-label-tertiary); background: var(--dsw-alias-interactive-bg-hover); font-style: italic; }
.foldRow[data-expanded='true'] { cursor: default; }
.foldRow[data-expanded='true'] .foldMarker { display: none; }
+17 -12
View File
@@ -18,18 +18,16 @@
/** The ar dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ar: Record<string, string> = {
files: 'الملفات',
fileTrace: 'تتبع الملفات',
fileTraceEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
fileTraceRead: 'قراءة',
fileTraceWrite: 'كتابة',
fileTraceEdit: 'تحرير',
fileTraceRunning: 'قيد التنفيذ',
fileTraceError: 'خطأ',
fileTraceFold: '{count} أسطر… انقر للتوسيع',
fileTraceContext: 'سياق',
fileTracePriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل',
changesSessionEmpty: 'لا توجد عمليات ملفات في هذه الجلسة بعد',
changesRead: 'قراءة',
changesWrite: 'كتابة',
changesEdit: 'تحرير',
changesRunning: 'قيد التنفيذ',
changesError: 'خطأ',
changesFold: '{count} أسطر… انقر للتوسيع',
changesContext: 'سياق',
changesPriorUnknown: 'المحتوى السابق خارج النافذة؛ يُعرض كما لو أُضيف بالكامل',
explorer: 'المستكشف',
git: 'إدارة المصدر',
terminal: 'الطرفية',
editor: 'المحرّر',
editorExplorer: 'سلوك فتح الملفات',
@@ -139,7 +137,14 @@ export const ar: Record<string, string> = {
worktree: 'شجرة العمل',
checkoutError: 'فشل تبديل الفرع',
history: 'السجل',
changes: 'التغييرات',
changes: 'تغييرات الملفات',
changesGitLens: 'Git',
changesSessionLens: 'الجلسة',
changesFilterAll: 'الكل',
changesFilterEmpty: 'لا توجد عمليات من هذا النوع',
changesOpenDiffTab: 'فتح في تبويب diff منفصل',
changesClosePreview: 'إغلاق المعاينة',
changesResizePreview: 'تغيير حجم المعاينة',
staged: 'مُدرج',
unstaged: 'غير مُدرج',
cancel: 'إلغاء',
+17 -12
View File
@@ -3,18 +3,16 @@
*/
export const de: Record<string, string> = {
files: 'Dateien',
fileTrace: 'Dateiverfolgung',
fileTraceEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
fileTraceRead: 'Lesen',
fileTraceWrite: 'Schreiben',
fileTraceEdit: 'Bearbeiten',
fileTraceRunning: 'läuft',
fileTraceError: 'Fehler',
fileTraceFold: '{count} Zeilen… zum Aufklappen klicken',
fileTraceContext: 'Kontext',
fileTracePriorUnknown: 'Inhalt vor der Änderung liegt außerhalb des Fensters; als vollständig neu angezeigt',
changesSessionEmpty: 'Noch keine Dateioperationen in dieser Sitzung',
changesRead: 'Lesen',
changesWrite: 'Schreiben',
changesEdit: 'Bearbeiten',
changesRunning: 'läuft',
changesError: 'Fehler',
changesFold: '{count} Zeilen… zum Aufklappen klicken',
changesContext: 'Kontext',
changesPriorUnknown: 'Inhalt vor der Änderung liegt außerhalb des Fensters; als vollständig neu angezeigt',
explorer: 'Explorer',
git: 'Quellcodeverwaltung',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'Dateiöffnungsverhalten',
@@ -124,7 +122,14 @@ export const de: Record<string, string> = {
worktree: 'Worktree',
checkoutError: 'Branchenwechsel fehlgeschlagen',
history: 'Verlauf',
changes: 'Änderungen',
changes: 'Dateiänderungen',
changesGitLens: 'Git',
changesSessionLens: 'Sitzung',
changesFilterAll: 'Alle',
changesFilterEmpty: 'Keine Vorgänge dieser Art',
changesOpenDiffTab: 'In eigenem Tab öffnen',
changesClosePreview: 'Vorschau schließen',
changesResizePreview: 'Vorschauhöhe anpassen',
staged: 'Gestaged',
unstaged: 'Nicht gestaged',
cancel: 'Abbrechen',
+16 -11
View File
@@ -10,18 +10,16 @@
/** fr dictionary for the `betterSidebar` namespace. */
export const fr: Record<string, string> = {
files: 'Fichiers',
fileTrace: 'Suivi des fichiers',
fileTraceEmpty: 'Aucune opération de fichier dans cette session',
fileTraceRead: 'Lecture',
fileTraceWrite: 'Écriture',
fileTraceEdit: 'Modification',
fileTraceRunning: 'en cours',
fileTraceError: 'erreur',
fileTraceFold: '{count} lignes… cliquer pour déplier',
fileTraceContext: 'contexte',
fileTracePriorUnknown: 'Le contenu antérieur est hors de la fenêtre ; affiché comme entièrement ajouté',
changesSessionEmpty: 'Aucune opération de fichier dans cette session',
changesRead: 'Lecture',
changesWrite: 'Écriture',
changesEdit: 'Modification',
changesRunning: 'en cours',
changesError: 'erreur',
changesFold: '{count} lignes… cliquer pour déplier',
changesContext: 'contexte',
changesPriorUnknown: 'Le contenu antérieur est hors de la fenêtre ; affiché comme entièrement ajouté',
explorer: 'Explorateur',
git: 'Gestion de code source',
terminal: 'Terminal',
editor: 'Éditeur',
editorExplorer: 'Comportement d’ouverture des fichiers',
@@ -132,6 +130,13 @@ export const fr: Record<string, string> = {
checkoutError: 'Échec du changement de branche',
history: 'Historique',
changes: 'Modifications',
changesGitLens: 'Git',
changesSessionLens: 'Session',
changesFilterAll: 'Tout',
changesFilterEmpty: 'Aucune opération de ce type',
changesOpenDiffTab: 'Ouvrir dans un onglet diff',
changesClosePreview: 'Fermer l\'aperçu',
changesResizePreview: 'Redimensionner l\'aperçu',
staged: 'En scène',
unstaged: 'Hors scène',
cancel: 'Annuler',
+17 -12
View File
@@ -17,18 +17,16 @@
/** The hi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const hi: Record<string, string> = {
files: 'फ़ाइलें',
fileTrace: 'फ़ाइल ट्रेस',
fileTraceEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
fileTraceRead: 'पढ़ना',
fileTraceWrite: 'लिखना',
fileTraceEdit: 'संपादन',
fileTraceRunning: 'चल रहा है',
fileTraceError: 'त्रुटि',
fileTraceFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें',
fileTraceContext: 'संदर्भ',
fileTracePriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है',
changesSessionEmpty: 'इस सत्र में अभी कोई फ़ाइल ऑपरेशन नहीं',
changesRead: 'पढ़ना',
changesWrite: 'लिखना',
changesEdit: 'संपादन',
changesRunning: 'चल रहा है',
changesError: 'त्रुटि',
changesFold: '{count} पंक्तियाँ… विस्तार के लिए क्लिक करें',
changesContext: 'संदर्भ',
changesPriorUnknown: 'बदलाव से पहले की सामग्री विंडो में नहीं है; पूरी नई जोड़ी गई दिखाई जाती है',
explorer: 'एक्सप्लोरर',
git: 'सोर्स कंट्रोल',
terminal: 'टर्मिनल',
editor: 'एडिटर',
editorExplorer: 'फ़ाइल खोलने का तरीका',
@@ -138,7 +136,14 @@ export const hi: Record<string, string> = {
worktree: 'वर्कट्री',
checkoutError: 'ब्रांच स्विच विफल',
history: 'इतिहास',
changes: 'बदलाव',
changes: 'फ़ाइल परिवर्तन',
changesGitLens: 'Git',
changesSessionLens: 'सेशन',
changesFilterAll: 'सभी',
changesFilterEmpty: 'इस प्रकार का कोई ऑपरेशन नहीं',
changesOpenDiffTab: 'अलग diff टैब में खोलें',
changesClosePreview: 'पूर्वावलोकन बंद करें',
changesResizePreview: 'पूर्वावलोकन का आकार बदलें',
staged: 'स्टेज किया',
unstaged: 'अनस्टेज',
cancel: 'रद्द करें',
+17 -12
View File
@@ -15,18 +15,16 @@
export const id: Record<string, string> = {
files: 'Berkas',
fileTrace: 'Pelacakan file',
fileTraceEmpty: 'Belum ada operasi file di sesi ini',
fileTraceRead: 'Baca',
fileTraceWrite: 'Tulis',
fileTraceEdit: 'Edit',
fileTraceRunning: 'berjalan',
fileTraceError: 'error',
fileTraceFold: '{count} baris… klik untuk meluaskan',
fileTraceContext: 'konteks',
fileTracePriorUnknown: 'Konten sebelum perubahan berada di luar jendela; ditampilkan sebagai sepenuhnya baru',
changesSessionEmpty: 'Belum ada operasi file di sesi ini',
changesRead: 'Baca',
changesWrite: 'Tulis',
changesEdit: 'Edit',
changesRunning: 'berjalan',
changesError: 'error',
changesFold: '{count} baris… klik untuk meluaskan',
changesContext: 'konteks',
changesPriorUnknown: 'Konten sebelum perubahan berada di luar jendela; ditampilkan sebagai sepenuhnya baru',
explorer: 'Explorer',
git: 'Source Control',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'Perilaku buka berkas',
@@ -136,7 +134,14 @@ export const id: Record<string, string> = {
worktree: 'Worktree',
checkoutError: 'Beralih branch gagal',
history: 'Riwayat',
changes: 'Perubahan',
changes: 'Perubahan berkas',
changesGitLens: 'Git',
changesSessionLens: 'Sesi',
changesFilterAll: 'Semua',
changesFilterEmpty: 'Tidak ada operasi jenis ini',
changesOpenDiffTab: 'Buka di tab diff terpisah',
changesClosePreview: 'Tutup pratinjau',
changesResizePreview: 'Ubah ukuran pratinjau',
staged: 'Staged',
unstaged: 'Unstaged',
cancel: 'Batal',
+16 -11
View File
@@ -8,18 +8,16 @@
export const it: Record<string, string> = {
files: 'File',
fileTrace: 'Tracciatura file',
fileTraceEmpty: 'Ancora nessuna operazione sui file in questa sessione',
fileTraceRead: 'Lettura',
fileTraceWrite: 'Scrittura',
fileTraceEdit: 'Modifica',
fileTraceRunning: 'in esecuzione',
fileTraceError: 'errore',
fileTraceFold: '{count} righe… clicca per espandere',
fileTraceContext: 'contesto',
fileTracePriorUnknown: 'Il contenuto precedente è fuori dalla finestra; mostrato come interamente aggiunto',
changesSessionEmpty: 'Ancora nessuna operazione sui file in questa sessione',
changesRead: 'Lettura',
changesWrite: 'Scrittura',
changesEdit: 'Modifica',
changesRunning: 'in esecuzione',
changesError: 'errore',
changesFold: '{count} righe… clicca per espandere',
changesContext: 'contesto',
changesPriorUnknown: 'Il contenuto precedente è fuori dalla finestra; mostrato come interamente aggiunto',
explorer: 'Esplora risorse',
git: 'Controllo del codice sorgente',
terminal: 'Terminale',
editor: 'Editor',
editorExplorer: 'Modalità di apertura dei file',
@@ -130,6 +128,13 @@ export const it: Record<string, string> = {
checkoutError: 'Cambio di ramo non riuscito',
history: 'Cronologia',
changes: 'Modifiche',
changesGitLens: 'Git',
changesSessionLens: 'Sessione',
changesFilterAll: 'Tutti',
changesFilterEmpty: 'Nessuna operazione di questo tipo',
changesOpenDiffTab: 'Apri in una scheda diff',
changesClosePreview: 'Ridimensiona l\'anteprima',
changesResizePreview: 'Ridimensiona anteprima',
staged: 'In stage',
unstaged: 'Non in stage',
cancel: 'Annulla',
+17 -12
View File
@@ -17,18 +17,16 @@
/** The ja dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const ja: Record<string, string> = {
files: 'ファイル',
fileTrace: 'ファイル追跡',
fileTraceEmpty: 'このセッションにはファイル操作がまだありません',
fileTraceRead: '読み取り',
fileTraceWrite: '書き込み',
fileTraceEdit: '編集',
fileTraceRunning: '実行中',
fileTraceError: 'エラー',
fileTraceFold: '{count} 行…クリックで展開',
fileTraceContext: 'コンテキスト',
fileTracePriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます',
changesSessionEmpty: 'このセッションにはファイル操作がまだありません',
changesRead: '読み取り',
changesWrite: '書き込み',
changesEdit: '編集',
changesRunning: '実行中',
changesError: 'エラー',
changesFold: '{count} 行…クリックで展開',
changesContext: 'コンテキスト',
changesPriorUnknown: '変更前の内容がウィンドウ内になく、すべて新規追加として表示されます',
explorer: 'エクスプローラー',
git: 'ソース管理',
terminal: 'ターミナル',
editor: 'エディター',
editorExplorer: 'ファイルの開き方',
@@ -138,7 +136,14 @@ export const ja: Record<string, string> = {
worktree: 'ワークツリー',
checkoutError: 'ブランチ切り替えに失敗',
history: '履歴',
changes: '変更',
changes: 'ファイル変更',
changesGitLens: 'Git',
changesSessionLens: 'セッション',
changesFilterAll: 'すべて',
changesFilterEmpty: 'この種類の操作はありません',
changesOpenDiffTab: '独立タブで開く',
changesClosePreview: 'プレビューを閉じる',
changesResizePreview: 'プレビューの高さを変更',
staged: 'ステージ済み',
unstaged: '未ステージ',
cancel: 'キャンセル',
+17 -12
View File
@@ -9,18 +9,16 @@
export const ko: Record<string, string> = {
files: '파일',
fileTrace: '파일 추적',
fileTraceEmpty: '이 세션에 아직 파일 작업이 없습니다',
fileTraceRead: '읽기',
fileTraceWrite: '쓰기',
fileTraceEdit: '편집',
fileTraceRunning: '실행 중',
fileTraceError: '오류',
fileTraceFold: '{count}줄… 클릭하여 펼치기',
fileTraceContext: '컨텍스트',
fileTracePriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다',
changesSessionEmpty: '이 세션에 아직 파일 작업이 없습니다',
changesRead: '읽기',
changesWrite: '쓰기',
changesEdit: '편집',
changesRunning: '실행 중',
changesError: '오류',
changesFold: '{count}줄… 클릭하여 펼치기',
changesContext: '컨텍스트',
changesPriorUnknown: '변경 전 내용이 창에 없어 모두 새로 추가된 것으로 표시됩니다',
explorer: '탐색기',
git: '소스 제어',
terminal: '터미널',
editor: '편집기',
editorExplorer: '파일 열기 방식',
@@ -130,7 +128,14 @@ export const ko: Record<string, string> = {
worktree: '워크트리',
checkoutError: '브랜치 전환 실패',
history: '기록',
changes: '변경 사항',
changes: '파일 변경',
changesGitLens: 'Git',
changesSessionLens: '세션',
changesFilterAll: '모두',
changesFilterEmpty: '이 종류의 작업이 없습니다',
changesOpenDiffTab: '별도 탭에서 열기',
changesClosePreview: '미리보기 닫기',
changesResizePreview: '미리보기 높이 조정',
staged: '스테이징됨',
unstaged: '스테이징 안 됨',
cancel: '취소',
+17 -12
View File
@@ -15,18 +15,16 @@
*/
export const nl: Record<string, string> = {
files: 'Bestanden',
fileTrace: 'Bestandsspoor',
fileTraceEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
fileTraceRead: 'Lezen',
fileTraceWrite: 'Schrijven',
fileTraceEdit: 'Bewerken',
fileTraceRunning: 'actief',
fileTraceError: 'fout',
fileTraceFold: '{count} regels… klik om uit te klappen',
fileTraceContext: 'context',
fileTracePriorUnknown: 'Eerdere inhoud valt buiten het venster; getoond als volledig nieuw',
changesSessionEmpty: 'Nog geen bestandsbewerkingen in deze sessie',
changesRead: 'Lezen',
changesWrite: 'Schrijven',
changesEdit: 'Bewerken',
changesRunning: 'actief',
changesError: 'fout',
changesFold: '{count} regels… klik om uit te klappen',
changesContext: 'context',
changesPriorUnknown: 'Eerdere inhoud valt buiten het venster; getoond als volledig nieuw',
explorer: 'Verkenner',
git: 'Bronbeheer',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'Bestand openingsgedrag',
@@ -136,7 +134,14 @@ export const nl: Record<string, string> = {
worktree: 'Worktree',
checkoutError: 'Wisselen van branch mislukt',
history: 'Geschiedenis',
changes: 'Wijzigingen',
changes: 'Bestandswijzigingen',
changesGitLens: 'Git',
changesSessionLens: 'Sessie',
changesFilterAll: 'Alles',
changesFilterEmpty: 'Geen bewerkingen van dit soort',
changesOpenDiffTab: 'In eigen diff-tabblad openen',
changesClosePreview: 'Voorbeeld sluiten',
changesResizePreview: 'Voorbeeld vergroten/verkleinen',
staged: 'Gestaged',
unstaged: 'Niet gestaged',
cancel: 'Annuleren',
+17 -12
View File
@@ -19,18 +19,16 @@
/** The pl dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const pl: Record<string, string> = {
files: 'Pliki',
fileTrace: 'Śledzenie plików',
fileTraceEmpty: 'Brak operacji na plikach w tej sesji',
fileTraceRead: 'Odczyt',
fileTraceWrite: 'Zapis',
fileTraceEdit: 'Edycja',
fileTraceRunning: 'w toku',
fileTraceError: 'błąd',
fileTraceFold: '{count} wierszy… kliknij, aby rozwinąć',
fileTraceContext: 'kontekst',
fileTracePriorUnknown: 'Zawartość sprzed zmiany jest poza oknem; pokazana jako w całości nowa',
changesSessionEmpty: 'Brak operacji na plikach w tej sesji',
changesRead: 'Odczyt',
changesWrite: 'Zapis',
changesEdit: 'Edycja',
changesRunning: 'w toku',
changesError: 'błąd',
changesFold: '{count} wierszy… kliknij, aby rozwinąć',
changesContext: 'kontekst',
changesPriorUnknown: 'Zawartość sprzed zmiany jest poza oknem; pokazana jako w całości nowa',
explorer: 'Eksplorator',
git: 'Kontrola źródła',
terminal: 'Terminal',
editor: 'Edytor',
editorExplorer: 'Sposób otwierania plików',
@@ -140,7 +138,14 @@ export const pl: Record<string, string> = {
worktree: 'Drzewo robocze',
checkoutError: 'Przełączenie gałęzi nie powiodło się',
history: 'Historia',
changes: 'Zmiany',
changes: 'Zmiany plików',
changesGitLens: 'Git',
changesSessionLens: 'Sesja',
changesFilterAll: 'Wszystko',
changesFilterEmpty: 'Brak operacji tego rodzaju',
changesOpenDiffTab: 'Otwórz w osobnej karcie',
changesClosePreview: 'Zamknij podgląd',
changesResizePreview: 'Zmień wysokość podglądu',
staged: 'W indeksie',
unstaged: 'Poza indeksem',
cancel: 'Anuluj',
+16 -11
View File
@@ -1,17 +1,15 @@
export const pt: Record<string, string> = {
files: 'Arquivos',
fileTrace: 'Rastreio de arquivos',
fileTraceEmpty: 'Ainda sem operações de arquivo nesta sessão',
fileTraceRead: 'Leitura',
fileTraceWrite: 'Escrita',
fileTraceEdit: 'Edição',
fileTraceRunning: 'em execução',
fileTraceError: 'erro',
fileTraceFold: '{count} linhas… clique para expandir',
fileTraceContext: 'contexto',
fileTracePriorUnknown: 'O conteúdo anterior está fora da janela; mostrado como totalmente adicionado',
changesSessionEmpty: 'Ainda sem operações de arquivo nesta sessão',
changesRead: 'Leitura',
changesWrite: 'Escrita',
changesEdit: 'Edição',
changesRunning: 'em execução',
changesError: 'erro',
changesFold: '{count} linhas… clique para expandir',
changesContext: 'contexto',
changesPriorUnknown: 'O conteúdo anterior está fora da janela; mostrado como totalmente adicionado',
explorer: 'Explorador',
git: 'Controle de código-fonte',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'Comportamento de abertura de arquivos',
@@ -122,6 +120,13 @@ export const pt: Record<string, string> = {
checkoutError: 'Falha ao alternar de branch',
history: 'Histórico',
changes: 'Alterações',
changesGitLens: 'Git',
changesSessionLens: 'Sessão',
changesFilterAll: 'Tudo',
changesFilterEmpty: 'Nenhuma operação deste tipo',
changesOpenDiffTab: 'Abrir em uma aba de diff',
changesClosePreview: 'Fechar prévia',
changesResizePreview: 'Ajustar altura da prévia',
staged: 'Preparadas',
unstaged: 'Não preparadas',
cancel: 'Cancelar',
+17 -12
View File
@@ -13,18 +13,16 @@
export const ru: Record<string, string> = {
files: 'Файлы',
fileTrace: 'Отслеживание файлов',
fileTraceEmpty: 'В этой сессии пока нет файловых операций',
fileTraceRead: 'Чтение',
fileTraceWrite: 'Запись',
fileTraceEdit: 'Изменение',
fileTraceRunning: 'выполняется',
fileTraceError: 'ошибка',
fileTraceFold: '{count} строк… нажмите, чтобы развернуть',
fileTraceContext: 'контекст',
fileTracePriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное',
changesSessionEmpty: 'В этой сессии пока нет файловых операций',
changesRead: 'Чтение',
changesWrite: 'Запись',
changesEdit: 'Изменение',
changesRunning: 'выполняется',
changesError: 'ошибка',
changesFold: '{count} строк… нажмите, чтобы развернуть',
changesContext: 'контекст',
changesPriorUnknown: 'Содержимое до изменения вне окна; показано как полностью добавленное',
explorer: 'Проводник',
git: 'Контроль версий',
terminal: 'Терминал',
editor: 'Редактор',
editorExplorer: 'Способ открытия файлов',
@@ -134,7 +132,14 @@ export const ru: Record<string, string> = {
worktree: 'Рабочее дерево',
checkoutError: 'Сбой переключения ветки',
history: 'История',
changes: 'Изменения',
changes: 'Изменения файлов',
changesGitLens: 'Git',
changesSessionLens: 'Сессия',
changesFilterAll: 'Все',
changesFilterEmpty: 'Нет операций этого типа',
changesOpenDiffTab: 'Открыть в отдельной вкладке',
changesClosePreview: 'Закрыть просмотр',
changesResizePreview: 'Изменить высоту просмотра',
staged: 'В индексе',
unstaged: 'Не в индексе',
cancel: 'Отмена',
+17 -12
View File
@@ -1,17 +1,15 @@
export const sv: Record<string, string> = {
files: 'Filer',
fileTrace: 'Filspårning',
fileTraceEmpty: 'Inga filoperationer i den här sessionen ännu',
fileTraceRead: 'Läs',
fileTraceWrite: 'Skriv',
fileTraceEdit: 'Redigera',
fileTraceRunning: 'körs',
fileTraceError: 'fel',
fileTraceFold: '{count} rader… klicka för att expandera',
fileTraceContext: 'kontext',
fileTracePriorUnknown: 'Innehållet före ändringen ligger utanför fönstret; visas som helt nytt',
changesSessionEmpty: 'Inga filoperationer i den här sessionen ännu',
changesRead: 'Läs',
changesWrite: 'Skriv',
changesEdit: 'Redigera',
changesRunning: 'körs',
changesError: 'fel',
changesFold: '{count} rader… klicka för att expandera',
changesContext: 'kontext',
changesPriorUnknown: 'Innehållet före ändringen ligger utanför fönstret; visas som helt nytt',
explorer: 'Utforskare',
git: 'Källkodshantering',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'Filers öppningsbeteende',
@@ -121,7 +119,14 @@ export const sv: Record<string, string> = {
worktree: 'Arbetsträd',
checkoutError: 'Grenbyte misslyckades',
history: 'Historik',
changes: 'Ändringar',
changes: 'Filändringar',
changesGitLens: 'Git',
changesSessionLens: 'Session',
changesFilterAll: 'Alla',
changesFilterEmpty: 'Inga åtgärder av denna typ',
changesOpenDiffTab: 'Öppna i egen diff-flik',
changesClosePreview: 'Stäng förhandsvisning',
changesResizePreview: 'Ändra storlek på förhandsvisningen',
staged: 'Köade',
unstaged: 'Avköade',
cancel: 'Avbryt',
+17 -12
View File
@@ -17,18 +17,16 @@
/** The th dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const th: Record<string, string> = {
files: 'ไฟล์',
fileTrace: 'ติดตามไฟล์',
fileTraceEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
fileTraceRead: 'อ่าน',
fileTraceWrite: 'เขียน',
fileTraceEdit: 'แก้ไข',
fileTraceRunning: 'กำลังทำงาน',
fileTraceError: 'ข้อผิดพลาด',
fileTraceFold: '{count} บรรทัด… คลิกเพื่อขยาย',
fileTraceContext: 'บริบท',
fileTracePriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด',
changesSessionEmpty: 'ยังไม่มีการดำเนินการไฟล์ในเซสชันนี้',
changesRead: 'อ่าน',
changesWrite: 'เขียน',
changesEdit: 'แก้ไข',
changesRunning: 'กำลังทำงาน',
changesError: 'ข้อผิดพลาด',
changesFold: '{count} บรรทัด… คลิกเพื่อขยาย',
changesContext: 'บริบท',
changesPriorUnknown: 'เนื้อหาก่อนแก้ไขอยู่นอกหน้าต่าง แสดงเป็นเพิ่มใหม่ทั้งหมด',
explorer: 'ตัวสำรวจ',
git: 'การควบคุมซอร์ส',
terminal: 'เทอร์มินัล',
editor: 'ตัวแก้ไข',
editorExplorer: 'พฤติกรรมการเปิดไฟล์',
@@ -138,7 +136,14 @@ export const th: Record<string, string> = {
worktree: 'เวิร์กทรี',
checkoutError: 'สลับ branch ล้มเหลว',
history: 'ประวัติ',
changes: 'การเปลี่ยนแปลง',
changes: 'การเปลี่ยนแปลงไฟล์',
changesGitLens: 'Git',
changesSessionLens: 'เซสชัน',
changesFilterAll: 'ทั้งหมด',
changesFilterEmpty: 'ไม่มีการดำเนินการประเภทนี้',
changesOpenDiffTab: 'เปิดในแท็บ diff แยก',
changesClosePreview: 'ปิดตัวอย่าง',
changesResizePreview: 'ปรับขนาดตัวอย่าง',
staged: 'Staged',
unstaged: 'Unstaged',
cancel: 'ยกเลิก',
+17 -12
View File
@@ -17,18 +17,16 @@
/** The tr dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const tr: Record<string, string> = {
files: 'Dosyalar',
fileTrace: 'Dosya izleme',
fileTraceEmpty: 'Bu oturumda henüz dosya işlemi yok',
fileTraceRead: 'Okuma',
fileTraceWrite: 'Yazma',
fileTraceEdit: 'Düzenleme',
fileTraceRunning: 'çalışıyor',
fileTraceError: 'hata',
fileTraceFold: '{count} satır… genişletmek için tıklayın',
fileTraceContext: 'bağlam',
fileTracePriorUnknown: 'Değişiklik öncesi içerik pencere dışında; tamamen yeni eklenmiş olarak gösteriliyor',
changesSessionEmpty: 'Bu oturumda henüz dosya işlemi yok',
changesRead: 'Okuma',
changesWrite: 'Yazma',
changesEdit: 'Düzenleme',
changesRunning: 'çalışıyor',
changesError: 'hata',
changesFold: '{count} satır… genişletmek için tıklayın',
changesContext: 'bağlam',
changesPriorUnknown: 'Değişiklik öncesi içerik pencere dışında; tamamen yeni eklenmiş olarak gösteriliyor',
explorer: 'Gezgin',
git: 'Kaynak denetimi',
terminal: 'Terminal',
editor: 'Düzenleyici',
editorExplorer: 'Dosya açma davranışı',
@@ -138,7 +136,14 @@ export const tr: Record<string, string> = {
worktree: 'Çalışma ağacı',
checkoutError: 'Dal değiştirme başarısız',
history: 'Geçmiş',
changes: 'Değişiklikler',
changes: 'Dosya değişiklikleri',
changesGitLens: 'Git',
changesSessionLens: 'Oturum',
changesFilterAll: 'Tümü',
changesFilterEmpty: 'Bu türde işlem yok',
changesOpenDiffTab: 'Ayrı bir diff sekmesinde aç',
changesClosePreview: 'Önizlemeyi kapat',
changesResizePreview: 'Önizlemeyi boyutlandır',
staged: 'Sahnelendi',
unstaged: 'Sahnelenmedi',
cancel: 'İptal',
+17 -12
View File
@@ -17,18 +17,16 @@
/** The vi dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const vi: Record<string, string> = {
files: 'Tệp',
fileTrace: 'Theo dõi tệp',
fileTraceEmpty: 'Chưa có thao tác tệp nào trong phiên này',
fileTraceRead: 'Đọc',
fileTraceWrite: 'Ghi',
fileTraceEdit: 'Chỉnh sửa',
fileTraceRunning: 'đang chạy',
fileTraceError: 'lỗi',
fileTraceFold: '{count} dòng… bấm để mở rộng',
fileTraceContext: 'ngữ cảnh',
fileTracePriorUnknown: 'Nội dung trước thay đổi nằm ngoài cửa sổ; hiển thị như toàn bộ mới thêm',
changesSessionEmpty: 'Chưa có thao tác tệp nào trong phiên này',
changesRead: 'Đọc',
changesWrite: 'Ghi',
changesEdit: 'Chỉnh sửa',
changesRunning: 'đang chạy',
changesError: 'lỗi',
changesFold: '{count} dòng… bấm để mở rộng',
changesContext: 'ngữ cảnh',
changesPriorUnknown: 'Nội dung trước thay đổi nằm ngoài cửa sổ; hiển thị như toàn bộ mới thêm',
explorer: 'Trình khám phá',
git: 'Quản lý mã nguồn',
terminal: 'Terminal',
editor: 'Trình soạn thảo',
editorExplorer: 'Cách mở tệp',
@@ -138,7 +136,14 @@ export const vi: Record<string, string> = {
worktree: 'Worktree',
checkoutError: 'Chuyển branch thất bại',
history: 'Lịch sử',
changes: 'Thay đổi',
changes: 'Thay đổi tệp',
changesGitLens: 'Git',
changesSessionLens: 'Phiên',
changesFilterAll: 'Tất cả',
changesFilterEmpty: 'Không có thao tác thuộc loại này',
changesOpenDiffTab: 'Mở trong tab diff riêng',
changesClosePreview: 'Đóng xem trước',
changesResizePreview: 'Điều chỉnh kích thước xem trước',
staged: 'Đã stage',
unstaged: 'Chưa stage',
cancel: 'Hủy',
+17 -12
View File
@@ -32,18 +32,16 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhHK: Record<string, string> = {
files: '檔案',
fileTrace: '檔案追蹤',
fileTraceEmpty: '本工作階段還沒有檔案操作',
fileTraceRead: '讀取',
fileTraceWrite: '寫入',
fileTraceEdit: '編輯',
fileTraceRunning: '執行中',
fileTraceError: '出錯',
fileTraceFold: '{count} 行…點擊展開',
fileTraceContext: '上下文',
fileTracePriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesWrite: '寫入',
changesEdit: '編輯',
changesRunning: '執行中',
changesError: '出錯',
changesFold: '{count} 行…點擊展開',
changesContext: '上下文',
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管',
git: '原始碼管理',
terminal: '終端',
editor: '編輯器',
editorExplorer: '檔案開啟方式',
@@ -153,7 +151,14 @@ export const zhHK: Record<string, string> = {
worktree: '工作樹',
checkoutError: '切換分支失敗',
history: '歷史',
changes: '變更',
changes: '檔案變動',
changesGitLens: 'Git',
changesSessionLens: '本輪檔案',
changesFilterAll: '全部',
changesFilterEmpty: '沒有此類操作',
changesOpenDiffTab: '在獨立分頁中開啟',
changesClosePreview: '關閉預覽',
changesResizePreview: '調整預覽高度',
staged: '已暫存',
unstaged: '未暫存',
cancel: '取消',
+17 -12
View File
@@ -32,18 +32,16 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhMO: Record<string, string> = {
files: '檔案',
fileTrace: '檔案追蹤',
fileTraceEmpty: '本工作階段還沒有檔案操作',
fileTraceRead: '讀取',
fileTraceWrite: '寫入',
fileTraceEdit: '編輯',
fileTraceRunning: '執行中',
fileTraceError: '出錯',
fileTraceFold: '{count} 行…點擊展開',
fileTraceContext: '上下文',
fileTracePriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
changesSessionEmpty: '本工作階段還沒有檔案操作',
changesRead: '讀取',
changesWrite: '寫入',
changesEdit: '編輯',
changesRunning: '執行中',
changesError: '出錯',
changesFold: '{count} 行…點擊展開',
changesContext: '上下文',
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管',
git: '原始碼管理',
terminal: '終端',
editor: '編輯器',
editorExplorer: '檔案開啟方式',
@@ -153,7 +151,14 @@ export const zhMO: Record<string, string> = {
worktree: '工作樹',
checkoutError: '切換分支失敗',
history: '歷史',
changes: '變更',
changes: '檔案變動',
changesGitLens: 'Git',
changesSessionLens: '本輪檔案',
changesFilterAll: '全部',
changesFilterEmpty: '沒有此類操作',
changesOpenDiffTab: '在獨立分頁中開啟',
changesClosePreview: '關閉預覽',
changesResizePreview: '調整預覽高度',
staged: '已暫存',
unstaged: '未暫存',
cancel: '取消',
+17 -12
View File
@@ -32,18 +32,16 @@
/** The zh-HK dictionary (key-set-equal to zh, enforced by the type annotation in locales.ts). */
export const zhTW: Record<string, string> = {
files: '檔案',
fileTrace: '檔案追蹤',
fileTraceEmpty: '本作業階段還沒有檔案操作',
fileTraceRead: '讀取',
fileTraceWrite: '寫入',
fileTraceEdit: '編輯',
fileTraceRunning: '執行中',
fileTraceError: '出錯',
fileTraceFold: '{count} 行…點擊展開',
fileTraceContext: '上下文',
fileTracePriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
changesSessionEmpty: '本作業階段還沒有檔案操作',
changesRead: '讀取',
changesWrite: '寫入',
changesEdit: '編輯',
changesRunning: '執行中',
changesError: '出錯',
changesFold: '{count} 行…點擊展開',
changesContext: '上下文',
changesPriorUnknown: '變更前的內容不在視窗內,顯示為全部新增',
explorer: '檔案總管',
git: '原始碼管理',
terminal: '終端',
editor: '編輯器',
editorExplorer: '檔案開啟方式',
@@ -153,7 +151,14 @@ export const zhTW: Record<string, string> = {
worktree: '工作樹',
checkoutError: '切換分支失敗',
history: '歷史',
changes: '變更',
changes: '檔案變動',
changesGitLens: 'Git',
changesSessionLens: '本輪檔案',
changesFilterAll: '全部',
changesFilterEmpty: '沒有此類操作',
changesOpenDiffTab: '在獨立頁籤中開啟',
changesClosePreview: '關閉預覽',
changesResizePreview: '調整預覽高度',
staged: '已暫存',
unstaged: '未暫存',
cancel: '取消',
+33 -23
View File
@@ -24,18 +24,16 @@
/** The zh dictionary (also registered into the DSH locale registry under {@link LOCALE_NS}). */
export const zh = {
files: '文件',
fileTrace: '文件追踪',
fileTraceEmpty: '本会话还没有文件操作',
fileTraceRead: '读取',
fileTraceWrite: '写入',
fileTraceEdit: '编辑',
fileTraceRunning: '执行中',
fileTraceError: '出错',
fileTraceFold: '{count} 行…点击展开',
fileTraceContext: '上下文',
fileTracePriorUnknown: '变更前的内容不在窗口内,显示为全新增',
changesSessionEmpty: '本会话还没有文件操作',
changesRead: '读取',
changesWrite: '写入',
changesEdit: '编辑',
changesRunning: '执行中',
changesError: '出错',
changesFold: '{count} 行…点击展开',
changesContext: '上下文',
changesPriorUnknown: '变更前的内容不在窗口内,显示为全新增',
explorer: '资源管理器',
git: '源代码管理',
terminal: '终端',
editor: '编辑器',
editorExplorer: '文件打开方式',
@@ -144,7 +142,14 @@ export const zh = {
worktree: '工作树',
checkoutError: '切换分支失败',
history: '历史',
changes: '变更',
changes: '文件变动',
changesGitLens: 'Git',
changesSessionLens: '本轮文件',
changesFilterAll: '全部',
changesFilterEmpty: '没有此类操作',
changesOpenDiffTab: '在独立页签中打开',
changesClosePreview: '关闭预览',
changesResizePreview: '调整预览高度',
staged: '已暂存',
unstaged: '未暂存',
cancel: '取消',
@@ -402,18 +407,16 @@ export const zh = {
/** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
export const en: Record<keyof typeof zh, string> = {
files: 'Files',
fileTrace: 'File trace',
fileTraceEmpty: 'No file operations in this session yet',
fileTraceRead: 'Read',
fileTraceWrite: 'Write',
fileTraceEdit: 'Edit',
fileTraceRunning: 'running',
fileTraceError: 'error',
fileTraceFold: '{count} lines…click to expand',
fileTraceContext: 'context',
fileTracePriorUnknown: 'Prior content is outside the loaded window; shown as all-added',
changesSessionEmpty: 'No file operations in this session yet',
changesRead: 'Read',
changesWrite: 'Write',
changesEdit: 'Edit',
changesRunning: 'running',
changesError: 'error',
changesFold: '{count} lines…click to expand',
changesContext: 'context',
changesPriorUnknown: 'Prior content is outside the loaded window; shown as all-added',
explorer: 'Explorer',
git: 'Source Control',
terminal: 'Terminal',
editor: 'Editor',
editorExplorer: 'File open behavior',
@@ -523,6 +526,13 @@ export const en: Record<keyof typeof zh, string> = {
checkoutError: 'Branch switch failed',
history: 'History',
changes: 'Changes',
changesGitLens: 'Git',
changesSessionLens: 'Session',
changesFilterAll: 'All',
changesFilterEmpty: 'No operations of this kind',
changesOpenDiffTab: 'Open in a diff tab',
changesClosePreview: 'Close preview',
changesResizePreview: 'Resize preview',
staged: 'Staged',
unstaged: 'Unstaged',
cancel: 'Cancel',
+9
View File
@@ -61,3 +61,12 @@ export function isWithinWorkspace(base: string, target: string): boolean {
const lt = t.toLowerCase()
return lt === lb || lt.startsWith(`${lb}/`)
}
/**
* The last path segment of a '/'- or '\'-separated path (a diff tab title,
* a worktree label). Returns the whole string when no separator is present.
*/
export function baseName(path: string): string {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
return at === -1 ? path : path.slice(at + 1)
}
+6 -414
View File
@@ -2177,141 +2177,9 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
color: var(--dsw-alias-state-error-primary);
}
/* ── Git ───────────────────────────────────────────────────────────────── */
.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);
}
.gitRowSelected {
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);
}
.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);
}
/* ── Diff tab (generic states + shell; the changes tab owns the rest in
changes.module.css, and the diff rows themselves live in
diff/diff.module.css) ────────────────────────────────────────────────── */
.gitEmpty {
padding: 4px 12px 8px;
@@ -2333,13 +2201,9 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
white-space: pre-wrap;
}
.gitDiff {
border-top: 1px solid var(--dsw-alias-border-l1);
padding: 8px;
}
/* The diff tab (a change opened from the git panel, like VSCode's diff
editor): header + scrollable diff body, no horizontal panning. */
/* The diff tab (a change expanded from the changes tab's preview, like
VSCode's diff editor): header + scrollable diff body, no horizontal
panning; rows render through the shared diff module. */
.gitDiffTab {
flex: 1;
min-height: 0;
@@ -2370,276 +2234,12 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
color: var(--dsw-alias-label-primary);
}
/* One changed file's section: path header, hunk headers, aligned lines. */
.gitDiffFile {
width: 100%;
display: flex;
align-items: baseline;
gap: 6px;
padding: 8px 2px 2px;
border: 0;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.gitDiffFile:disabled {
cursor: default;
}
.gitDiffFile:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.gitDiffFileChevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
transform: rotate(0deg);
}
.gitDiffFileChevronExpanded {
transform: rotate(90deg);
}
.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;
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);
}
.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;
}
/* ── Intercepted produced-files row ────────────────────────────────────── */
@@ -2700,13 +2300,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.paneCard:focus-visible,
.explorerRow:focus-visible,
.explorerRef:focus-visible,
.gitRowMain:focus-visible,
.gitLink:focus-visible,
.gitCommitButton:focus-visible,
.gitLogRow:focus-visible,
.gitLogMore:focus-visible,
.gitDiffFile:focus-visible,
.gitDiffExpand:focus-visible,
.terminalRetry:focus-visible,
.editorModeButton:focus-visible,
.editorDownloadLink:focus-visible,
@@ -2728,7 +2321,6 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.tabBarPlus,
.paneCard,
.explorerRow,
.gitRow,
.divider,
.dividerRow::after,
.dividerCol::after {