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
+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; }
}
+383
View File
@@ -0,0 +1,383 @@
/**
* 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. */
export type TokenType =
| 'plain'
| 'comment'
| 'string'
| 'keyword'
| 'number'
| 'type'
| 'function'
| 'macro'
/** One token span: a run of one line sharing one token class. */
export interface CodeToken {
readonly text: string
readonly type: TokenType
}
/** Token classes that imply their own color; `plain` inherits the row color. */
const COLORED: ReadonlySet<TokenType> = new Set(['comment', 'string', 'keyword', 'number', 'type', 'function', 'macro'])
/** Per-language scanner configuration. */
interface LangConfig {
/** Start-of-line (after blanks) or after-token comment prefixes. */
readonly lineComments: readonly string[]
/** Multi-line comment delimiters; the opener may appear anywhere. */
readonly blockComment?: readonly [open: string, close: string]
/** String delimiter characters; the escape character precedes any literal. */
readonly strings?: readonly string[]
/** Reserved words colored as keywords. */
readonly keywords: ReadonlySet<string>
/** Constant literals colored as keywords (true/false/null). */
readonly constants: ReadonlySet<string>
/** '#' starts a preprocessor directive / directive-like annotation. */
readonly macro: boolean
/** Identifier chars (word body). */
readonly wordStart: RegExp
readonly wordBody: RegExp
}
const LETTER = /[A-Za-z_$]/u
const WORD = /[A-Za-z0-9_$]/u
const ANYWORD = { wordStart: LETTER, wordBody: WORD }
const kw = (words: string): ReadonlySet<string> => new Set(words.split(/\s+/))
const C_FAMILY = (words: string): LangConfig => ({
lineComments: ['//'],
blockComment: ['/*', '*/'],
strings: ['"', "'", '`'],
keywords: kw(words),
constants: kw('true false null NULL nullptr TRUE FALSE'),
macro: true,
...ANYWORD,
})
const HASH_FAMILY = (words: string, constants = ''): LangConfig => ({
lineComments: ['#'],
strings: ['"', "'"],
keywords: kw(words),
constants: kw(constants.length > 0 ? constants : 'True False None true false null'),
macro: false,
...ANYWORD,
})
const CONFIG_LANG: LangConfig = {
lineComments: ['#'],
strings: ['"', "'"],
keywords: new Set(),
constants: new Set(),
macro: false,
wordStart: LETTER,
wordBody: WORD,
}
/** SQL: '--' line comments plus '#', quoting with single quotes. */
const SQL_LANG: LangConfig = {
lineComments: ['--', '#'],
strings: ["'"],
keywords: kw(`select from where insert into values update set delete create table drop alter add column
primary key foreign references index view join inner left right outer on as order by group having
limit offset distinct union all and or not in exists between like is null asc desc count sum avg
min max case when then else end begin commit rollback transaction default constraint unique`),
constants: kw('true false null'),
macro: false,
...ANYWORD,
}
/** Windows batch: 'REM'/'::' comments, '%' variable quoting. */
const CMD_LANG: LangConfig = {
lineComments: ['::'],
strings: ['"'],
keywords: kw(`rem if else for in do goto call exit echo set setlocal endlocal shift
exist defined errorlevel not equ neq lss leq gtr geq nul con defined enabledelayedexpansion`),
constants: new Set(),
macro: false,
wordStart: LETTER,
wordBody: WORD,
}
/** PowerShell: '#' comments, quoted strings including here-string quotes. */
const PS_LANG: LangConfig = {
lineComments: ['#'],
blockComment: ['<#', '#>'],
strings: ['"', "'"],
keywords: kw(`function param begin process end if elseif else foreach for while do until switch
try catch finally throw return break continue filter in workflow class enum interface
dynamicparam data checkpoint systemlanguage default expand`),
constants: kw('true false null'),
macro: false,
...ANYWORD,
}
/** Markdown: no tokenizer; the whole line stays plain. */
const MD_LANG: LangConfig = {
lineComments: [],
strings: [],
keywords: new Set(),
constants: new Set(),
macro: false,
wordStart: LETTER,
wordBody: WORD,
}
/** Extension → language id, mirroring the read tool's hint table. */
const LANGS: Readonly<Record<string, LangConfig>> = {
ts: C_FAMILY(`abstract any as asserts async await boolean break case catch class const constructor
continue debugger declare default delete do else enum export extends false finally for from
function get if implements import in infer instanceof interface is keyof let module namespace
never new null number object of override private protected public readonly return satisfies set
static string super switch symbol this throw true try type typeof undefined union unknown var
void while with yield`),
tsx: C_FAMILY(`abstract any as asserts async await boolean break case catch class const constructor
continue declare default delete do else enum export extends false finally for from function get
if implements import in infer instanceof interface is keyof let module namespace never new null
number object of override private protected public readonly return satisfies set static string
super switch symbol this throw true try type typeof undefined union unknown var void while with
yield`),
js: C_FAMILY(`async await break case catch class const continue debugger default delete do else
export extends false finally for from function get if implements import in instanceof interface
let new null of return set static super switch this throw true try typeof undefined var void
while with yield`),
jsx: C_FAMILY(`async await break case catch class const continue debugger default delete do else
export extends false finally for from function get if implements import in instanceof interface
let new null of return set static super switch this throw true try typeof undefined var void
while with yield`),
json: CONFIG_LANG,
py: HASH_FAMILY(`and as assert async await break class continue def del elif else except finally
for from global if import in is lambda nonlocal not or pass raise return try while with yield
match case`, 'True False None self cls NotImplemented __name__ __main__'),
go: C_FAMILY(`break case chan const continue default defer else fallthrough for func go goto if
import interface map package range return select struct switch type var nil iota make new len
cap append copy close delete panic print println recover`),
rs: C_FAMILY(`as async await break const continue crate dyn else enum extern false fn for if impl
in let loop match mod move mut pub ref return self Self static struct super trait true type
unsafe use where while`),
java: C_FAMILY(`abstract assert boolean break byte case catch char class const continue default do
double else enum extends final finally float for goto if implements import instanceof int
interface long native new package private protected public return short static strictfp super
switch synchronized this throw throws transient try void volatile while var record sealed
permits yield`),
c: C_FAMILY(`auto break case char const continue default do double else enum extern float for
goto if inline int long register restrict return short signed sizeof static struct switch
typedef union unsigned void volatile while _Bool _Complex _Atomic`),
cpp: C_FAMILY(`alignas alignof and auto break case catch char class co_await co_return co_yield
concept const consteval constexpr constinit const_cast continue decltype default delete
do double dynamic_cast else enum explicit export extern false final float for friend goto if
inline int long mutable namespace new noexcept not nullptr operator or override private
protected public register reinterpret_cast requires return short signed sizeof static
static_assert static_cast struct switch template this thread_local throw true try typedef
typeid typename union unsigned using virtual void volatile wchar_t while`),
cs: C_FAMILY(`abstract as async await base bool break byte case catch char checked class const
continue decimal default delegate do double dynamic else enum event explicit extern false
finally fixed float for foreach get goto if implicit in init int interface internal is lock
long namespace new null not null forgiving object operator out override params partial
private protected public readonly record ref return sbyte sealed set short sizeof stackalloc
static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort
using var virtual void volatile when where while with yield`),
kt: C_FAMILY(`as break by catch class companion const constructor continue crossinline data do
dynamic else enum external false final finally for fun get if import in infix init inline
interface internal is lateinit lazy null object open operator out override package private
protected public reified return sealed set super suspend tailrec this throw true try typealias
val var vararg when where while`),
swift: C_FAMILY(`actor as associatedtype async await break case catch class continue
convenience default defer deinit didSet do dynamic else enum extension fallthrough false
final for func get guard if import in indirect infix init inout internal is lazy let nil
nonmutating open operator optional override postfix precedencegroup prefix private protocol
public repeat required rethrows return self set some static struct subscript super switch
throw throws true try typealias unowned var weak where while willSet`),
php: C_FAMILY(`abstract and array as break callable case catch class clone const continue declare
default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile enum
extends final finally fn for foreach function global goto if implements include
include_once instanceof insteadof interface isset list match namespace new or print private
protected public readonly require require_once return static switch throw trait try unset use
var while xor yield true false null int string bool float void mixed never self parent`),
sh: HASH_FAMILY(`if then else elif fi for while until do done case esac function in select time
coproc return break continue local export readonly declare typeset unset shift eval exec trap
exit source alias set`, 'true false'),
bash: HASH_FAMILY(`if then else elif fi for while until do done case esac function in select time
coproc return break continue local export readonly declare typeset unset shift eval exec trap
exit source alias set`, 'true false'),
zsh: HASH_FAMILY(`if then else elif fi for while until do done case esac function in select time
coproc return break continue local export readonly declare typeset unset shift eval exec trap
exit source alias set`, 'true false'),
yaml: CONFIG_LANG,
yml: CONFIG_LANG,
toml: CONFIG_LANG,
ini: CONFIG_LANG,
sql: SQL_LANG,
cmd: CMD_LANG,
bat: CMD_LANG,
ps1: PS_LANG,
psm1: PS_LANG,
psd1: PS_LANG,
md: MD_LANG,
markdown: MD_LANG,
mdx: MD_LANG,
html: CONFIG_LANG,
htm: CONFIG_LANG,
css: HASH_FAMILY(''),
scss: HASH_FAMILY(''),
less: HASH_FAMILY(''),
lua: HASH_FAMILY(`and break do else elseif end false for function goto if in local nil not or
repeat return then true until while`),
}
/**
* Language id for a file path's extension (the read tool's mapping): the
* lowercase extension without its dot; dotfiles and unknown extensions map to
* undefined (plain text).
* @param path - the op's file path exactly as recorded.
* @returns the language id, or undefined for plain text.
*/
export function langOfPath(path: string): string | undefined {
const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
const dot = base.lastIndexOf('.')
if (dot <= 0) return undefined
const ext = base.slice(dot + 1).toLowerCase()
return Object.hasOwn(LANGS, ext) ? ext : undefined
}
/** One line's scan output: its tokens plus whether a block comment stays open. */
export interface ScanResult {
readonly tokens: CodeToken[]
readonly inBlock: boolean
}
/** Scan one line, entering from (and reporting) block-comment state. */
export function scanLine(line: string, lang: string | undefined, inBlock = false): ScanResult {
if (line.length === 0) return { tokens: [], inBlock }
const cfg = lang !== undefined ? LANGS[lang] : undefined
if (cfg === undefined || (cfg.lineComments.length === 0 && cfg.keywords.size === 0)) {
return { tokens: [{ text: line, type: 'plain' }], inBlock: false }
}
// A language without block delimiters never carries block state forward.
let inComment = inBlock && cfg.blockComment !== undefined
const tokens: CodeToken[] = []
const push = (text: string, type: TokenType): void => {
if (text.length === 0) return
const last = tokens[tokens.length - 1]
if (last !== undefined && last.type === type) tokens[tokens.length - 1] = { text: last.text + text, type }
else tokens.push({ text, type })
}
let i = 0
const atLineComment = (): string | undefined => {
for (const lead of cfg.lineComments) {
if (line.startsWith(lead, i)) return lead
}
return undefined
}
const atBlockOpen = (): string | undefined => cfg.blockComment !== undefined && line.startsWith(cfg.blockComment[0], i) ? cfg.blockComment[0] : undefined
const readString = (): void => {
const quote = line[i]!
i += 1
while (i < line.length && line[i] !== quote) {
if (line[i] === '\\') i += 1
i += 1
}
i = Math.min(i + 1, line.length)
}
while (i < line.length) {
if (inComment) {
const closeIdx = cfg.blockComment !== undefined ? line.indexOf(cfg.blockComment[1], i) : -1
if (closeIdx === -1) {
push(line.slice(i), 'comment')
return { tokens, inBlock: true }
}
const end = closeIdx + (cfg.blockComment?.[1].length ?? 0)
push(line.slice(i, end), 'comment')
i = end
inComment = false
continue
}
const ch = line[i]!
const wsMatch = /\s/u.exec(line.slice(i))
if (wsMatch !== null && wsMatch.index === 0) {
push(ch, 'plain')
i += 1
continue
}
const commentLead = atLineComment()
if (commentLead !== undefined) {
push(line.slice(i), 'comment')
break
}
const blockOpen = atBlockOpen()
if (blockOpen !== undefined && cfg.blockComment !== undefined) {
const close = line.indexOf(cfg.blockComment[1], i + blockOpen.length)
const end = close === -1 ? line.length : close + cfg.blockComment[1].length
push(line.slice(i, end), 'comment')
i = end
inComment = close === -1
continue
}
if (cfg.strings !== undefined && cfg.strings.includes(ch)) {
const start = i
readString()
push(line.slice(start, i), 'string')
continue
}
if (/[0-9]/u.test(ch)) {
const m = /^(?:0[xXbo][0-9a-fA-F_]+|[0-9][0-9_]*(?:\.[0-9_]+)?(?:[eE][+-]?[0-9_]+)?)/u.exec(line.slice(i))
const len = m !== null ? m[0].length : 1
push(line.slice(i, i + len), 'number')
i += len
continue
}
if (cfg.wordStart.test(ch)) {
let j = i + 1
while (j < line.length && cfg.wordBody.test(line[j]!)) j += 1
const word = line.slice(i, j)
// Batch 'REM whatever' runs to end of line as a comment.
if ((lang === 'cmd' || lang === 'bat') && word.toLowerCase() === 'rem') {
push(line.slice(i), 'comment')
break
}
let k = j
while (k < line.length && (line[k] === ' ' || line[k] === '\t')) k += 1
if (cfg.constants.has(word)) push(word, 'keyword')
else if (cfg.keywords.has(word)) push(word, 'keyword')
else if (line[k] === '(') push(word, 'function')
else if (/^[A-Z]/u.test(word) && word.length > 1) push(word, 'type')
else push(word, 'plain')
i = j
continue
}
if (cfg.macro && ch === '#' && (i === 0 || /\s/u.test(line[i - 1]!))) {
let j = i + 1
while (j < line.length && cfg.wordBody.test(line[j]!)) j += 1
push(line.slice(i, j), 'macro')
i = j
continue
}
push(ch, 'plain')
i += 1
}
return { tokens, inBlock: inComment }
}
/** Tokenize one line with no incoming block state (standalone lines). */
export function tokenizeLine(line: string, lang: string | undefined): CodeToken[] {
return scanLine(line, lang).tokens
}
/** True when the language has multi-line block-comment delimiters. */
export function hasBlockComment(lang: string | undefined): boolean {
return lang !== undefined && LANGS[lang]?.blockComment !== undefined
}
/** Token classes worth wrapping in a span; plain runs join the parent text node. */
export function isColored(token: CodeToken): boolean {
return COLORED.has(token.type)
}
+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 }
}