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