@young1lin/dsh-ui-gitworkbench 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/CHANGELOG_EN.md +42 -0
  3. package/lib/apply-blocks.js +159 -0
  4. package/lib/atomic-json.js +23 -5
  5. package/lib/blame.js +83 -0
  6. package/lib/client.js +34811 -11818
  7. package/lib/fs-remove.js +73 -0
  8. package/lib/git-ops.js +25 -0
  9. package/lib/image-sniff.js +197 -0
  10. package/lib/index.js +404 -32
  11. package/lib/patch-model.js +223 -0
  12. package/lib/side-guard.js +55 -0
  13. package/lib/write-checked.js +164 -0
  14. package/package.json +7 -1
  15. package/src/apply-blocks.ts +215 -0
  16. package/src/atomic-json.ts +29 -5
  17. package/src/blame.ts +94 -0
  18. package/src/client/CodeEditor.tsx +317 -0
  19. package/src/client/FileBrowser.tsx +657 -0
  20. package/src/client/GitWorkbenchPanel.module.css +453 -7
  21. package/src/client/GitWorkbenchPanel.tsx +1465 -166
  22. package/src/client/ImageView.tsx +120 -0
  23. package/src/client/blame-gutter.ts +108 -0
  24. package/src/client/blame-view.ts +104 -0
  25. package/src/client/cm-diff.ts +108 -0
  26. package/src/client/cm-tokens.ts +79 -0
  27. package/src/client/diff-nav.ts +198 -0
  28. package/src/client/discard-flow.ts +82 -0
  29. package/src/client/file-icon.ts +190 -0
  30. package/src/client/file-rows.ts +184 -0
  31. package/src/client/files-place.ts +178 -0
  32. package/src/client/glyphs.tsx +86 -0
  33. package/src/client/highlight.ts +25 -0
  34. package/src/client/idle-value.ts +53 -0
  35. package/src/client/image-view.ts +106 -0
  36. package/src/client/indent.ts +74 -0
  37. package/src/client/index.ts +76 -9
  38. package/src/client/locales.ts +171 -4
  39. package/src/client/pane-size.ts +71 -0
  40. package/src/client/side-edit.ts +244 -0
  41. package/src/client/side-rows.ts +258 -0
  42. package/src/client/stable-list.ts +31 -0
  43. package/src/client/use-change-nav.ts +83 -0
  44. package/src/client/worktree-view.ts +11 -1
  45. package/src/fs-remove.ts +76 -0
  46. package/src/git-ops.ts +36 -1
  47. package/src/image-sniff.ts +204 -0
  48. package/src/index.ts +450 -32
  49. package/src/patch-model.ts +267 -0
  50. package/src/side-guard.ts +58 -0
  51. package/src/write-checked.ts +223 -0
package/src/index.ts CHANGED
@@ -42,18 +42,20 @@
42
42
  * @module @young1lin/dsh-ui-gitworkbench
43
43
  */
44
44
  import { randomBytes } from 'node:crypto'
45
- import { mkdir, readFile, realpath, rename, rm, writeFile } from 'node:fs/promises'
46
- import { homedir } from 'node:os'
47
- import { join, resolve, sep } from 'node:path'
45
+ import { mkdir, readFile, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'
46
+ import { homedir, tmpdir } from 'node:os'
47
+ import { join } from 'node:path'
48
48
  import type { Readable } from 'node:stream'
49
49
  import type { Context } from '@deepseek-ai/cordis'
50
50
  import { defineTool, type ToolRunContext } from '@deepseek-ai/dsh-tools'
51
51
  import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
52
+ import { runApplyBlocks, sha1Hex, type ApplyBlocksIo } from './apply-blocks.js'
52
53
  import { saveJsonAtomic } from './atomic-json.js'
54
+ import { runWriteChecked, type WriteCheckedIo, type WriteResult } from './write-checked.js'
53
55
  import { CommitPayloadCache, cacheKey } from './commit-cache.js'
54
56
  import {
55
57
  NETWORK_GRACE_MS, NON_INTERACTIVE_ENV, capBranches, classifyFailure, clipDiff,
56
- commitArgv, countBufferLines, fetchArgv, isBinaryPrefix, isNoMergeBaseError,
58
+ commitArgv, countBufferLines, decodesAsUtf8, fetchArgv, isBinaryPrefix, isNoMergeBaseError,
57
59
  isSafePathArg, parseNameStatus, parseNumstat, parseStatus, parseTracking,
58
60
  pullArgv, pushArgv, stageArgv, stageStateOf, unstageArgv,
59
61
  type GitFile, type GitFileStatus, type MutableGitFile,
@@ -63,6 +65,10 @@ import {
63
65
  planFromStatus,
64
66
  type DiscardEffect, type DiscardPlan,
65
67
  } from './discard-ops.js'
68
+ import { parseBlame, type BlameLine } from './blame.js'
69
+ import { removePathInside } from './fs-remove.js'
70
+ import { diffTooLarge, targetTooLarge, SIDE_BYTE_CAP, SIDE_LINE_CAP } from './side-guard.js'
71
+ import { IMAGE_BYTE_CAP, sniffImage } from './image-sniff.js'
66
72
  import { LOG_FORMAT, parseLog, type GitCommit } from './git-log.js'
67
73
  import { emptyLogFilter, logFilterArgs, type LogFilter } from './log-filter.js'
68
74
  import { parseShortlog, type AuthorEntry } from './shortlog.js'
@@ -79,6 +85,7 @@ export type { WorktreeBinding, WorktreeOpResult }
79
85
  export type { GitFile, GitFileStatus }
80
86
  export type { DiscardEffect }
81
87
  export type { StyleEntry }
88
+ export type { WriteResult }
82
89
 
83
90
  /** Cap the bundled unified diff so a huge change cannot blow the RPC response. */
84
91
  const DIFF_CHAR_CAP = 400_000
@@ -88,6 +95,9 @@ const UNTRACKED_FILE_BYTE_CAP = 1_000_000
88
95
  const UNTRACKED_TOTAL_CHAR_CAP = 160_000
89
96
  /** Files with a NUL byte in the first 8k are treated as binary. */
90
97
  const BINARY_SNIFF_BYTES = 8_000
98
+ /** Context radius that makes `git diff` emit ONE hunk covering the whole file —
99
+ * the artifact the side-by-side view aligns its two columns on. */
100
+ const FULL_CONTEXT = 1_000_000
91
101
  /** Untracked files measured at once. Enough to keep the disk busy, few enough
92
102
  * that a repository with thousands of them cannot exhaust the file table. */
93
103
  const UNTRACKED_READ_CONCURRENCY = 16
@@ -149,6 +159,64 @@ interface GitResult {
149
159
  readonly stderr: string
150
160
  }
151
161
 
162
+ /**
163
+ * `fileSides`: one layer of one file, as the side-by-side diff pane reads it.
164
+ *
165
+ * `diff` is the layer's full-context unified diff (index→worktree for
166
+ * `unstaged`, HEAD→index for `staged`) — one hunk covering the whole file,
167
+ * which is simultaneously the pane's row alignment and, through `patch-model`,
168
+ * the patch the block actions emit. `diffSha` is sha1 of exactly the string in
169
+ * `diff`; the block mutations echo it back to prove the file has not changed
170
+ * since the pane rendered it.
171
+ */
172
+ export interface FileSides {
173
+ /** Unified diff at full context; '' when the layer has no change. */
174
+ readonly diff: string
175
+ /** sha1 of `diff`, echoed back by mutations to prove the same snapshot. */
176
+ readonly diffSha: string
177
+ /** Whole right-hand text, the editor's initial buffer. */
178
+ readonly targetText: string
179
+ /** Blob sha of the right-hand side; '' when it does not exist. */
180
+ readonly targetSha: string
181
+ readonly binary: boolean
182
+ /** True when the file is past the size guard; the client shows the old view. */
183
+ readonly tooLarge: boolean
184
+ /** True when the working-tree file is NOT valid UTF-8 — GBK, Shift JIS,
185
+ * Latin-1. `targetText` is then a lossy decode of it, so the pane shows the
186
+ * diff but withholds the editor: saving that text back would replace every
187
+ * non-ASCII byte in the file. `writeChecked` refuses such a save anyway. */
188
+ readonly lossyEncoding: boolean
189
+ }
190
+
191
+ /**
192
+ * `fileImage`: one working-tree file's bytes, when the bytes really are an
193
+ * image a browser can draw.
194
+ *
195
+ * Every field is present in both outcomes rather than optional, because the
196
+ * gateway's payloads must be JSON-safe and an absent key and a `undefined` one
197
+ * are not the same thing over the wire. `reason` is '' exactly when `ok`.
198
+ */
199
+ export interface FileImage {
200
+ /** Whether `base64` holds a verified image. */
201
+ readonly ok: boolean
202
+ /** MIME type to label the blob with; '' when declined. */
203
+ readonly mime: string
204
+ /** Short label for the caption — 'PNG', 'WebP', 'SVG'; '' when declined. */
205
+ readonly kind: string
206
+ /** The whole file, base64. '' when declined. */
207
+ readonly base64: string
208
+ /** The file's size in bytes, reported either way: when the view declines,
209
+ * the size is usually the reason and always worth showing. */
210
+ readonly bytes: number
211
+ /** Why not: 'notImage', 'tooLarge', 'missing'. '' when ok. */
212
+ readonly reason: string
213
+ }
214
+
215
+ /** A `fileImage` answer that carries no picture, only why. */
216
+ function declined(reason: string, bytes: number): FileImage {
217
+ return { ok: false, mime: '', kind: '', base64: '', bytes, reason }
218
+ }
219
+
152
220
  /** What every write operation reports back. */
153
221
  export interface GitOpResult {
154
222
  readonly ok: boolean
@@ -305,7 +373,7 @@ export class GitWorkbenchService extends TypertRemoteService {
305
373
  execute: async (args: { name?: string }, exec: ToolRunContext) => {
306
374
  const session = exec.agent?.session
307
375
  if (session === undefined) return { ok: false, error: 'worktree tools require a calling session' }
308
- return this.worktreeEnter(session.id, session.header.cwd, args?.name, exec.signal)
376
+ return this.worktreeEnter(session.id, session.header.cwd ?? '', args?.name, exec.signal)
309
377
  },
310
378
  presentCall: () => ({ card: 'generic', title: 'Enter worktree', kind: 'other' }),
311
379
  }))
@@ -334,7 +402,7 @@ export class GitWorkbenchService extends TypertRemoteService {
334
402
  execute: async (_args: Record<string, never>, exec: ToolRunContext) => {
335
403
  const session = exec.agent?.session
336
404
  if (session === undefined) return { ok: false, error: 'worktree tools require a calling session' }
337
- return this.worktreeStatus(session.id, session.header.cwd, exec.signal)
405
+ return this.worktreeStatus(session.id, session.header.cwd ?? '', exec.signal)
338
406
  },
339
407
  presentCall: () => ({ card: 'generic', title: 'Worktree status', kind: 'read' }),
340
408
  }))
@@ -453,6 +521,342 @@ export class GitWorkbenchService extends TypertRemoteService {
453
521
  return { diff: await untrackedSegment(cwd, path) ?? '' }
454
522
  }
455
523
 
524
+ /**
525
+ * One layer of one file for the side-by-side diff pane: the layer's
526
+ * full-context diff, the right-hand text the editor starts from, and the
527
+ * shas later mutations check against (plain-identifier params; signal last).
528
+ *
529
+ * The two layers answer different questions about the same file — `unstaged`
530
+ * is index→worktree (the editable side), `staged` is HEAD→index (read-only:
531
+ * editing the index would mean writing a blob with no file behind it) — so
532
+ * the diff, the target text and the target sha each come from that layer's
533
+ * own sources. Untracked files have no index entry, so `git diff` reports
534
+ * nothing for them and the unstaged layer falls back to the synthesized
535
+ * new-file segment `fileDiff` already uses.
536
+ *
537
+ * @param worktreePath - directory to run in; empty falls back to the host cwd.
538
+ * @param path - repository-relative path, as the drawer lists it.
539
+ * @param layer - `unstaged` (index→worktree) or `staged` (HEAD→index).
540
+ * @param signal - abort signal.
541
+ */
542
+ @Remote('fileSides')
543
+ async fileSides(worktreePath: string, path: string, layer: string, signal: AbortSignal): Promise<FileSides> {
544
+ if (layer !== 'unstaged' && layer !== 'staged') {
545
+ throw new Error(`unknown layer "${String(layer)}"; expected 'unstaged' or 'staged'`)
546
+ }
547
+ if (typeof path !== 'string' || !isSafePathArg(path)) {
548
+ throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
549
+ }
550
+ const cwd = this.cwdOf(worktreePath)
551
+ return layer === 'unstaged'
552
+ ? await this.unstagedSides(cwd, path, signal)
553
+ : await this.stagedSides(cwd, path, signal)
554
+ }
555
+
556
+ /** The unstaged layer: diff index→worktree, target = the working-tree file. */
557
+ private async unstagedSides(cwd: string, path: string, signal: AbortSignal): Promise<FileSides> {
558
+ // Size guard first, off the stat rather than a read: declining a file past
559
+ // the cap must not mean loading a pathological one whole first. Bytes are
560
+ // all a stat knows; the line half of the guard needs the read below.
561
+ try {
562
+ const info = await stat(join(cwd, path))
563
+ if (info.isFile() && targetTooLarge(info.size, 0)) return { ...emptySides(), tooLarge: true }
564
+ } catch {
565
+ // Missing file: deleted in the working tree, which the diff below states.
566
+ }
567
+ let bytes: Buffer | null = null
568
+ try {
569
+ bytes = await readFile(join(cwd, path))
570
+ } catch {
571
+ bytes = null
572
+ }
573
+ if (bytes !== null && isBinaryPrefix(bytes, BINARY_SNIFF_BYTES)) {
574
+ return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(cwd, path, signal) }
575
+ }
576
+ if (bytes !== null && targetTooLarge(bytes.length, countBufferLines(bytes))) {
577
+ return { ...emptySides(), tooLarge: true }
578
+ }
579
+ const diff = await this.layerDiffText(cwd, path, 'unstaged', signal)
580
+ if (binaryDiffOutput(diff)) {
581
+ return { ...emptySides(), binary: true, targetSha: await this.worktreeBlobSha(cwd, path, signal) }
582
+ }
583
+ // The diff half of the guard, AFTER the artifact exists: the target-side
584
+ // checks above cannot see a worktree-deleted large file (no target to
585
+ // measure) or a huge left side behind a small target — but the patch text
586
+ // carries both, and it is the payload being bounded.
587
+ if (diffTooLarge(diff)) return { ...emptySides(), tooLarge: true }
588
+ return {
589
+ diff,
590
+ diffSha: sha1Hex(diff),
591
+ targetText: bytes === null ? '' : bytes.toString('utf8'),
592
+ targetSha: bytes === null ? '' : await this.worktreeBlobSha(cwd, path, signal),
593
+ binary: false,
594
+ tooLarge: false,
595
+ // Only the unstaged layer can report this, and only it needs to: the
596
+ // editor edits the working tree, and the staged layer is read-only.
597
+ lossyEncoding: bytes !== null && !decodesAsUtf8(bytes),
598
+ }
599
+ }
600
+
601
+ /** The staged layer: diff HEAD→index, target = the index blob. */
602
+ private async stagedSides(cwd: string, path: string, signal: AbortSignal): Promise<FileSides> {
603
+ // `:path` resolves the stage-0 index entry: the target text when it exists,
604
+ // and a failed resolution (no entry) is the empty target, not an error.
605
+ const shown = await this.git(cwd, ['show', `:${path}`], signal)
606
+ const sha = (await this.git(cwd, ['rev-parse', '--verify', '--quiet', `:${path}`], signal)).stdout.trim()
607
+ const targetText = shown.exitCode === 0 ? shown.stdout : ''
608
+ const targetBytes = Buffer.from(targetText, 'utf8')
609
+ if (targetText.length > 0 && isBinaryPrefix(targetBytes, BINARY_SNIFF_BYTES)) {
610
+ return { ...emptySides(), binary: true, targetSha: sha }
611
+ }
612
+ if (targetTooLarge(targetBytes.length, countBufferLines(targetBytes))) {
613
+ return { ...emptySides(), tooLarge: true, targetSha: sha }
614
+ }
615
+ const diff = await this.layerDiffText(cwd, path, 'staged', signal)
616
+ if (binaryDiffOutput(diff)) {
617
+ return { ...emptySides(), binary: true, targetSha: sha }
618
+ }
619
+ // Same reasoning as the unstaged layer's post-diff check: a huge HEAD side
620
+ // behind a small index target passes the target guard while the patch
621
+ // still carries the whole old file.
622
+ if (diffTooLarge(diff)) {
623
+ return { ...emptySides(), tooLarge: true, targetSha: sha }
624
+ }
625
+ return {
626
+ diff,
627
+ diffSha: sha1Hex(diff),
628
+ targetText,
629
+ targetSha: sha,
630
+ binary: false,
631
+ tooLarge: false,
632
+ lossyEncoding: false,
633
+ }
634
+ }
635
+
636
+ /** Blob sha of the working-tree file, '' when git cannot hash it. */
637
+ private async worktreeBlobSha(cwd: string, path: string, signal: AbortSignal): Promise<string> {
638
+ const hashed = await this.git(cwd, ['hash-object', '--', path], signal)
639
+ return hashed.exitCode === 0 ? hashed.stdout.trim() : ''
640
+ }
641
+
642
+ /**
643
+ * The layer's full-context diff — the one artifact the side pane aligns its
644
+ * rows on and `applyBlocks` re-checks its sha against.
645
+ *
646
+ * Both callers go through here by design: `fileSides` stamps the text it
647
+ * returns with {@link sha1Hex} and `applyBlocks` re-derives the stamp over
648
+ * its own fresh fetch, so the two ends of the stale comparison are over the
649
+ * SAME text by construction, not by two fetch sites staying in step.
650
+ */
651
+ private async layerDiffText(cwd: string, path: string, layer: 'unstaged' | 'staged', signal: AbortSignal): Promise<string> {
652
+ if (layer === 'staged') {
653
+ return (await this.git(cwd, ['diff', '--cached', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
654
+ }
655
+ const diff = (await this.git(cwd, ['diff', `-U${FULL_CONTEXT}`, '--', path], signal)).stdout
656
+ // Untracked files have no index entry, so `git diff` reports nothing for
657
+ // them; the synthesized new-file segment is their unstaged diff. The
658
+ // trailing newline is added here because every diff git prints carries
659
+ // one: this text is what `applyBlocks` re-emits as a patch file, and a
660
+ // patch whose last line has no LF is "corrupt patch" to `git apply` —
661
+ // which is exactly what a real-git drive of the untracked path caught.
662
+ if (diff.length === 0 && await this.isUntracked(cwd, path, signal)) {
663
+ const segment = await untrackedSegment(cwd, path, SIDE_BYTE_CAP)
664
+ return segment === null ? '' : `${segment}\n`
665
+ }
666
+ return diff
667
+ }
668
+
669
+ /**
670
+ * Apply one change block of a side-by-side diff: stage it into the index,
671
+ * unstage it back out, or roll it back out of the working tree
672
+ * (plain-identifier params; signal last).
673
+ *
674
+ * The client sends a selection — `path`, `layer`, the `diffSha` of the diff
675
+ * the pane rendered, and the block's hunk-line indices — never patch text.
676
+ * The sequence itself (stale check, emission, `--check`, apply, tmpfile
677
+ * cleanup) lives in `apply-blocks.ts`, where vitest can drive it against a
678
+ * real git; this method only binds the host's git helper, the layer fetch
679
+ * shared with `fileSides`, and the tmpfile pair. Every failure comes back as
680
+ * a result — the method never throws across the RPC boundary.
681
+ *
682
+ * @param worktreePath - directory to run in; empty falls back to the host cwd.
683
+ * @param path - repository-relative path, as the drawer lists it.
684
+ * @param layer - the layer the block was selected on; the mode decides which
685
+ * one that may be.
686
+ * @param diffSha - sha of the diff the pane rendered, re-derived and compared.
687
+ * @param lines - hunk-line indices of the block, as `side-rows.blockLines`
688
+ * produced them client-side.
689
+ * @param mode - `stage` | `unstage` | `discard`.
690
+ * @param signal - abort signal.
691
+ */
692
+ @Remote('applyBlocks')
693
+ async applyBlocks(worktreePath: string, path: string, layer: string, diffSha: string, lines: readonly number[], mode: string, signal: AbortSignal): Promise<GitOpResult> {
694
+ const cwd = this.cwdOf(worktreePath)
695
+ const io: ApplyBlocksIo = {
696
+ git: (dir, argv) => this.git(dir, argv, signal),
697
+ layerDiff: (file, which) => this.layerDiffText(cwd, file, which === 'staged' ? 'staged' : 'unstaged', signal),
698
+ writePatch: writeTmpPatch,
699
+ dropPatch: dropTmpPatch,
700
+ }
701
+ return runApplyBlocks(io, cwd, path, layer, String(diffSha ?? ''), lines, mode)
702
+ }
703
+
704
+ /**
705
+ * Save the side-by-side editor's buffer over the working-tree file it was
706
+ * opened from — the editable diff's one write (plain-identifier params;
707
+ * signal last).
708
+ *
709
+ * The buffer travels with the blob sha the editor opened with, and the host
710
+ * re-derives that sha from git at the moment of the write: a file that moved
711
+ * underneath the editor — an agent's write, another session's save — makes
712
+ * the save refuse with `failure: 'stale'` and NOTHING is written. The whole
713
+ * sequence (path lock, sha refusal, atomic temp+rename write, the fresh sha
714
+ * the next save checks against) lives in `write-checked.ts`, where vitest
715
+ * drives it against a real git; this method binds the host's git helper and
716
+ * the filesystem calls, plus the stat that keeps "file absent" from being
717
+ * read off a hash spawn's failure. Never throws across the RPC boundary.
718
+ *
719
+ * This is deliberately NOT a `writeFile(path, content)` primitive: the sha
720
+ * check and this method are one thing, and no unchecked write RPC exists or
721
+ * may be added in this plugin.
722
+ *
723
+ * @param worktreePath - directory to run in; empty falls back to the host cwd.
724
+ * @param path - repository-relative path, as the drawer lists it.
725
+ * @param text - the editor buffer, verbatim; written as bytes (LF as given).
726
+ * @param expectedSha - the `targetSha` the buffer was opened with ('' when
727
+ * the file did not exist then), or the sha a successful
728
+ * save last returned.
729
+ * @param signal - abort signal.
730
+ */
731
+ @Remote('writeChecked')
732
+ async writeChecked(worktreePath: string, path: string, text: string, expectedSha: string, signal: AbortSignal): Promise<WriteResult> {
733
+ const cwd = this.cwdOf(worktreePath)
734
+ const io: WriteCheckedIo = {
735
+ git: (dir, argv) => this.git(dir, argv, signal),
736
+ exists: async p => {
737
+ try {
738
+ await stat(p)
739
+ return true
740
+ } catch {
741
+ return false
742
+ }
743
+ },
744
+ readBytes: p => readFile(p),
745
+ writeBytes: async (p, bytes) => { await writeFile(p, bytes) },
746
+ rename: async (from, to) => { await rename(from, to) },
747
+ remove: async p => { await rm(p, { force: true }) },
748
+ delay: ms => new Promise(resolve => { setTimeout(resolve, ms) }),
749
+ }
750
+ return runWriteChecked(io, cwd, path, typeof text === 'string' ? text : '', typeof expectedSha === 'string' ? expectedSha : '')
751
+ }
752
+
753
+ /**
754
+ * One file's provenance, line by line — the side pane's blame gutter
755
+ * (plain-identifier params; signal last).
756
+ *
757
+ * Blames the WORKING TREE file, which is what the reader is looking at and
758
+ * what IDEA annotates: lines the reader has not committed come back flagged
759
+ * rather than missing. Read-only, no index or worktree is touched, so this
760
+ * needs none of the confirmation machinery the write paths carry.
761
+ *
762
+ * @param worktreePath - directory to run in; empty falls back to the host cwd.
763
+ * @param path - repository-relative path, as the drawer lists it.
764
+ * @param signal - abort signal.
765
+ */
766
+ @Remote('blame')
767
+ async blame(worktreePath: string, path: string, signal: AbortSignal): Promise<{ lines: BlameLine[]; truncated: boolean; error?: string }> {
768
+ if (typeof path !== 'string' || !isSafePathArg(path)) {
769
+ return { lines: [], truncated: false, error: `unsafe path argument: ${JSON.stringify(path)}` }
770
+ }
771
+ const cwd = this.cwdOf(worktreePath)
772
+ // `--` keeps a path that looks like a revision from being read as one, as
773
+ // every other pathspec in this plugin does.
774
+ const run = await this.git(cwd, ['blame', '--line-porcelain', '--', path], signal)
775
+ if (run.exitCode !== 0) {
776
+ // An untracked file has no blame, and git says so; that message is the
777
+ // honest thing to show rather than an empty gutter.
778
+ return { lines: [], truncated: false, error: (run.stderr || run.stdout).trim().slice(-500) }
779
+ }
780
+ const all = parseBlame(run.stdout)
781
+ // The same line cap the side pane declines a file at: past it the gutter
782
+ // is a payload nobody reads to the end of.
783
+ if (all.length > SIDE_LINE_CAP) return { lines: all.slice(0, SIDE_LINE_CAP), truncated: true }
784
+ return { lines: all, truncated: false }
785
+ }
786
+
787
+ /**
788
+ * One working-tree file's bytes, when those bytes really are an image.
789
+ *
790
+ * The Files tab's fallback for a picture used to be "binary file — no text
791
+ * diff", which is true and useless: a repository's icons and screenshots are
792
+ * content, and a browser is already the best image viewer on the machine.
793
+ *
794
+ * The EXTENSION does not decide. It cannot: a `.png` is a filename, and a
795
+ * view that trusted it would hand the browser whatever bytes happened to be
796
+ * under that name. {@link sniffImage} reads the signature the format's own
797
+ * specification mandates, and a file that fails it comes back `notImage` so
798
+ * the client falls back to the ordinary text path — a mislabelled file still
799
+ * opens, as itself.
800
+ *
801
+ * The size check runs off the stat rather than the read, the same way
802
+ * `fileSides` does it: declining an oversized file must not mean loading it
803
+ * whole first. Base64 rather than a binary frame because the RPC channel is
804
+ * JSON; the third it adds is why {@link IMAGE_BYTE_CAP} sits where it does.
805
+ *
806
+ * Read-only — nothing is spawned, nothing is written.
807
+ *
808
+ * @param worktreePath - directory to run in; empty falls back to the host cwd.
809
+ * @param path - repository-relative path, as the drawer lists it.
810
+ * @param signal - abort signal.
811
+ */
812
+ @Remote('fileImage')
813
+ async fileImage(worktreePath: string, path: string, signal: AbortSignal): Promise<FileImage> {
814
+ if (typeof path !== 'string' || !isSafePathArg(path)) {
815
+ throw new Error(`unsafe path argument: ${JSON.stringify(path)}`)
816
+ }
817
+ const full = join(this.cwdOf(worktreePath), path)
818
+ let size = 0
819
+ try {
820
+ const info = await stat(full)
821
+ if (!info.isFile()) return declined('missing', 0)
822
+ size = info.size
823
+ } catch {
824
+ return declined('missing', 0)
825
+ }
826
+ if (size > IMAGE_BYTE_CAP) return declined('tooLarge', size)
827
+ let bytes: Buffer
828
+ try {
829
+ bytes = await readFile(full)
830
+ } catch {
831
+ return declined('missing', 0)
832
+ }
833
+ // Re-checked against the bytes actually read: the stat above is a separate
834
+ // syscall, and the file can have grown between the two.
835
+ if (bytes.length > IMAGE_BYTE_CAP) return declined('tooLarge', bytes.length)
836
+ const found = sniffImage(bytes)
837
+ if (found === null) return declined('notImage', bytes.length)
838
+ return {
839
+ ok: true,
840
+ mime: found.mime,
841
+ kind: found.kind,
842
+ base64: bytes.toString('base64'),
843
+ bytes: bytes.length,
844
+ reason: '',
845
+ }
846
+ }
847
+
848
+ /**
849
+ * Whether git has never seen this path: no index entry and no HEAD entry.
850
+ * Those are the files whose diff has to be synthesized rather than asked of
851
+ * `git diff`, which reports nothing for them.
852
+ */
853
+ private async isUntracked(cwd: string, path: string, signal: AbortSignal): Promise<boolean> {
854
+ const listed = await this.git(cwd, ['ls-files', '--', path], signal)
855
+ if (listed.exitCode !== 0 || listed.stdout.trim().length > 0) return false
856
+ const head = await this.git(cwd, ['rev-parse', '--verify', '--quiet', `HEAD:${path}`], signal)
857
+ return head.exitCode !== 0
858
+ }
859
+
456
860
  /**
457
861
  * One commit's change set, in the SAME {@link WorkbenchStats} shape as the working-tree
458
862
  * view so the drawer's tree and diff panes render it with no separate code path.
@@ -1040,7 +1444,7 @@ export class GitWorkbenchService extends TypertRemoteService {
1040
1444
  continue
1041
1445
  }
1042
1446
  try {
1043
- await this.removeInside(cwd, step.path)
1447
+ await removePathInside(cwd, step.path)
1044
1448
  } catch (error) {
1045
1449
  return { ok: false, failure: 'unknown', error: error instanceof Error ? error.message : String(error) }
1046
1450
  }
@@ -1068,29 +1472,6 @@ export class GitWorkbenchService extends TypertRemoteService {
1068
1472
  return planFromStatus(status.stdout, path)
1069
1473
  }
1070
1474
 
1071
- /**
1072
- * Delete one file, having proven it is inside the worktree.
1073
- *
1074
- * `isSafeRelativePath` already rejected traversal in the plan, so this is the
1075
- * second lock rather than the only one: it re-checks the RESOLVED path,
1076
- * which is the form the filesystem actually acts on. `force` makes an absent
1077
- * file a success — the reader asked for it to be gone, and it is.
1078
- *
1079
- * A symlinked directory inside the worktree could still point outward; that
1080
- * is a repository someone already has write access to, and resolving link
1081
- * targets on every delete would cost a stat per segment for a case git
1082
- * itself does not defend against.
1083
- */
1084
- private async removeInside(cwd: string, relative: string): Promise<void> {
1085
- const root = resolve(cwd)
1086
- const target = resolve(root, relative)
1087
- if (target !== root && !target.startsWith(root + sep)) {
1088
- throw new Error(`refusing to delete outside the worktree: ${JSON.stringify(relative)}`)
1089
- }
1090
- if (target === root) throw new Error('refusing to delete the worktree root')
1091
- await rm(target, { force: true })
1092
- }
1093
-
1094
1475
  /**
1095
1476
  * Commit what is in the index.
1096
1477
  * @param worktreePath - directory to run in.
@@ -1342,9 +1723,11 @@ async function measureUntracked(cwd: string, path: string): Promise<UntrackedMea
1342
1723
  * `/dev/null` as a repo-relative path. Never throws.
1343
1724
  * @param cwd - worktree the path is relative to.
1344
1725
  * @param path - repository-relative file path.
1726
+ * @param byteCap - refuse files larger than this; defaults to the stats
1727
+ * payload's budget, which `fileSides` raises to its own.
1345
1728
  * @returns the segment, or null when the file is missing, binary, or oversized.
1346
1729
  */
1347
- async function untrackedSegment(cwd: string, path: string): Promise<string | null> {
1730
+ async function untrackedSegment(cwd: string, path: string, byteCap: number = UNTRACKED_FILE_BYTE_CAP): Promise<string | null> {
1348
1731
  let bytes: Buffer
1349
1732
  try {
1350
1733
  bytes = await readFile(join(cwd, path))
@@ -1352,7 +1735,7 @@ async function untrackedSegment(cwd: string, path: string): Promise<string | nul
1352
1735
  return null
1353
1736
  }
1354
1737
  if (isBinaryPrefix(bytes, BINARY_SNIFF_BYTES)) return null
1355
- if (bytes.length > UNTRACKED_FILE_BYTE_CAP) return null
1738
+ if (bytes.length > byteCap) return null
1356
1739
  const lines = countBufferLines(bytes)
1357
1740
  const text = bytes.toString('utf8')
1358
1741
  const body = text.endsWith('\n') ? text.slice(0, -1) : text
@@ -1379,6 +1762,41 @@ function randomHex(digits: number): string {
1379
1762
  return bytes.toString('hex').slice(0, digits)
1380
1763
  }
1381
1764
 
1765
+ /**
1766
+ * Park patch text where git can read it: a uniquely named file under the OS
1767
+ * temp dir, passed to `git apply` as its last argument.
1768
+ *
1769
+ * `git()` spawns with `stdin: 'ignore'`, so a tmpfile — not a pipe — is how a
1770
+ * patch reaches git without changing that helper, and the temp dir keeps patch
1771
+ * text (which can be a whole file's worth of context) out of the repository.
1772
+ * `apply-blocks.ts` deletes what this wrote from a `finally`, whichever way
1773
+ * the apply ended.
1774
+ */
1775
+ async function writeTmpPatch(text: string): Promise<string> {
1776
+ const file = join(tmpdir(), `gw-apply-${process.pid}-${randomHex(8)}.patch`)
1777
+ await writeFile(file, text, 'utf8')
1778
+ return file
1779
+ }
1780
+
1781
+ /** Remove a tmpfile `writeTmpPatch` made; a file already gone is a success. */
1782
+ async function dropTmpPatch(file: string): Promise<void> {
1783
+ await rm(file, { force: true })
1784
+ }
1785
+
1786
+ /** The `fileSides` payload for a file with nothing to show: no diff, no target. */
1787
+ function emptySides(): FileSides {
1788
+ return { diff: '', diffSha: sha1Hex(''), targetText: '', targetSha: '', binary: false, tooLarge: false, lossyEncoding: false }
1789
+ }
1790
+
1791
+ /**
1792
+ * Whether a diff git printed says `Binary files … differ` instead of hunks.
1793
+ * The line starts at column 0 — inside a hunk every body line carries a
1794
+ * marker, so a text file that mentions "Binary files" cannot match.
1795
+ */
1796
+ function binaryDiffOutput(diff: string): boolean {
1797
+ return /^Binary files /m.test(diff)
1798
+ }
1799
+
1382
1800
  function emptyStats(worktreePath: string): WorkbenchStats {
1383
1801
  return {
1384
1802
  worktreePath, branch: '', ahead: 0, behind: 0, detached: false,