feat!: 合并 Git 面板与文件追踪为统一「文件变动」tab(双视角 + 统一 diff 栈)
在 PR #471(file-trace)基础上的演进: - 合并 tab:git tab(id 保留,兼容持久化布局)升级为「文件变动」双视角 tab——Git 视角(原 GitView 全部能力:staged/unstaged/提交/历史/worktree· 子仓库选择/右键菜单/确认弹窗)+ 本轮文件视角(原 file-trace:会话事件 日志实时折叠的模型读写编辑记录,按文件分组、新增类型筛选 chips、相对 时间);file-trace tab 移除(未随任何版本发布)。 - 统一 diff 组件:新 src/client/diff/ 模块(rows.ts 引擎 + DiffRows/ DiffFiles 渲染器 + highlight.ts 语法着色)——git diff 与会话操作 diff 共用一套渲染:mod 改写配对(del-run/add-run 对半标蓝)、行内字符级 高亮(修复了旧实现旧侧失配不高亮的问题,改为行对象身份键)、上下文 hunk 折叠、git hunk 间未输出区间按行号范围生成不可展开折叠、语法着色 色相落位 diff 三色空档、+n −m 统计 chips、行底色 tint;DiffTab 改用 该栈(git diff tab 从此也有高亮)。 - 交互:两视角共用底部可拖拽预览面板(点击文件/提交/操作即预览,不再 直接开 diff tab;面板可刷新、可键盘/拖拽调高、git 目标可一键展开为 独立 diff tab);视角选择与面板高度持久化在 tab.meta;git 行 XY 徽章 按状态字母着色;选中行高亮。 - i18n:fileTrace* 十键重命名为 changes* 前缀,git 键值更新为「文件 变动」,新增 7 键(视角/筛选/面板操作),全部 21 个词典同步。 - 结构:src/client/changes/(ChangesTab/GitLens/SessionLens/DiffPane/ ops)+ src/client/diff/;GitView/DiffView/filetrace/ 删除;git 样式 从 sidebar.module.css 迁至 changes.module.css。 - 测试:builtins 8→7(git tab 承接 badge pin)、diff-rows(unified 转换/mod 配对/间隙折叠/统计)、changes-tab(壳交互 jsdom)、 diff-files-collapse(原 DiffView 折叠行为钉住)、changes-ops(原 filetrace 数据层)、git-view-worktree 迁移至 GitLens;全量 1122 通过。 - 文档:README ×2 功能矩阵合并、external-plugin-guide §4.4 与内置清单 7 tab、mount e2e 菜单标题同步。
This commit is contained in:
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
Reference in New Issue
Block a user