Files
deepseek-harness/packages/experimental/client-ui-forge/src/diff.ts
T
Yichen Jiang d0fc1f6458 feat(forge): show the changes of a Session and of its change requests
The forge panel opens a changes view in the right sidebar for the
checkout's local changes and for the diff of each change request.

- ForgePanelController reads local diffs in the branch, uncommitted,
  unstaged, and staged scopes through Git in the Session's read-only
  sandbox, change request diffs through the forge provider, and the text
  of a changed file to expand unchanged lines. Change request patches stay
  cached by head commit and base branch within maxCachedDiffBytes. A local
  diff over maxDiffBytes fails as forgePanel/diff-too-large, and a provider
  read over its output limit fails as too-large, which the view explains
  with a link to the code host; a gh timeout fails as unavailable.
- forge adds the diff, fileText, and fileUrl provider methods and the
  too-large failure code, and forge-github implements them with gh.
- The panel shows a local changes card that opens the view and asks the
  Agent for a change request. The view collapses files past a line
  budget, draws lines side by side or in one column with optional
  wrapping, expands unchanged lines on request, and collapses or expands
  every file from its toolbar; file headers use a bare chevron.
- The checkout row copies the worktree or project directory path, the
  refresh glyph turns while a refresh runs, change request cards keep
  their actions while collapsed, and the header entry uses the shared
  compact button.
- client-ui-forge classifies changeRequestKey and FsError as
  peer-required Host imports, which keeps forge and dsh-fs as its peers.
2026-10-20 13:34:27 +08:00

130 lines
4.7 KiB
TypeScript

/** Unified patch parsing into the panel's browser-safe diff model, and the diff and size of an untracked file. */
import { parsePatch } from 'diff'
import type { ForgeChangedFile, ForgeDiffFile, ForgeDiffStatus } from './types.ts'
/** Git's name for the missing side of an added or deleted file. */
const NULL_PATH = '/dev/null'
/** Bytes Git inspects for a NUL byte before it calls content binary. */
const BINARY_PROBE_BYTES = 8000
/**
* Parse a Git-style unified patch, as `git diff` or a code host prints it,
* with `a/` and `b/` path prefixes. Hunk ranges follow jsdiff: a range that
* covers no line starts at the line it comes before.
* @param patch - the patch text.
* @returns one file per patch section in patch order.
*/
export function parseDiff(patch: string): ForgeDiffFile[] {
return parsePatch(patch).flatMap((section) => {
const before = pathOf(section.oldFileName, 'a/')
const after = pathOf(section.newFileName, 'b/')
const path = after ?? before
if (path === null) return []
const status: ForgeDiffStatus = section.isCreate === true || before === null ? 'added'
: section.isDelete === true || after === null ? 'deleted'
: section.isRename === true ? 'renamed' : 'modified'
let additions = 0
let deletions = 0
for (const hunk of section.hunks) {
for (const line of hunk.lines) {
if (line.startsWith('+')) additions++
else if (line.startsWith('-')) deletions++
}
}
return [{
path,
previousPath: status === 'renamed' ? before : null,
status,
binary: section.isBinary === true,
oversized: false,
untracked: false,
additions,
deletions,
hunks: section.hunks.map(({ oldStart, oldLines, newStart, newLines, lines }) => ({ oldStart, oldLines, newStart, newLines, lines })),
}]
})
}
/**
* Describe an untracked file as added in full.
* @param path - path relative to the repository root.
* @param content - the file's bytes.
* @returns the file with one hunk of added lines, none when it is empty, or none and `binary` when its content is not text.
*/
export function untrackedFile(path: string, content: Uint8Array): ForgeDiffFile {
const file: ForgeDiffFile = {
path, previousPath: null, status: 'added', binary: false, oversized: false, untracked: true, additions: 0, deletions: 0, hunks: [],
}
const text = decodeText(content)
if (text === undefined) return { ...file, binary: true }
if (text.length === 0) return file
const lines = text.split('\n')
const ended = lines.at(-1) === ''
if (ended) lines.pop()
return {
...file,
additions: lines.length,
hunks: [{
oldStart: 1,
oldLines: 0,
newStart: 1,
newLines: lines.length,
lines: [...lines.map(line => `+${line}`), ...ended ? [] : ['\\ No newline at end of file']],
}],
}
}
/**
* Decode file content as text the way Git decides it is not binary: no NUL
* byte among the first 8000 bytes, and, here, valid UTF-8 throughout.
* @param content - the bytes.
* @returns the text, or undefined for binary content.
*/
export function decodeText(content: Uint8Array): string | undefined {
if (content.subarray(0, BINARY_PROBE_BYTES).includes(0)) return undefined
try {
return new TextDecoder('utf-8', { fatal: true }).decode(content)
} catch (error) {
void error // Bytes that are not UTF-8 render as binary, as a NUL byte does.
return undefined
}
}
/**
* Parse `git diff --numstat -z` output, in which a rename prints its counts,
* an empty path, and then the old and new paths as separate fields.
* @param output - the command's standard output.
* @returns one file per record, named by its new path; a binary file counts no lines.
*/
export function parseNumstat(output: string): ForgeChangedFile[] {
const fields = output.split('\0')
const files: ForgeChangedFile[] = []
let skip = 0
for (const [index, record] of fields.entries()) {
if (skip > 0) {
skip--
continue
}
if (record.length === 0) continue
const [added = '', deleted = ''] = record.split('\t', 2)
let path = record.slice(added.length + deleted.length + 2)
if (path.length === 0) {
const renamed = fields[index + 2]
// A rename record that the output limit cut off ends the list.
if (renamed === undefined) break
path = renamed
skip = 2
}
const binary = added === '-'
files.push({ path, additions: binary ? 0 : Number(added), deletions: binary ? 0 : Number(deleted), binary })
}
return files
}
function pathOf(name: string | undefined, prefix: string): string | null {
if (name === undefined || name === NULL_PATH) return null
return name.startsWith(prefix) ? name.slice(prefix.length) : name
}