@lijian-ui/dsh-file-manager 0.2.3 → 0.2.4

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 (96) hide show
  1. package/LICENSE +38 -38
  2. package/README.i18n.yaml +6 -6
  3. package/README.md +66 -66
  4. package/README.zh.md +66 -66
  5. package/cordis.patch.yml +13 -13
  6. package/lib/client.js +27305 -270
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +474 -42
  9. package/lib/tsconfig.client.tsbuildinfo +1 -1
  10. package/lib/tsconfig.host.tsbuildinfo +1 -1
  11. package/lib/types/client/file-source.d.ts +10 -0
  12. package/lib/types/client/file-source.d.ts.map +1 -1
  13. package/lib/types/client/fileType.d.ts +19 -4
  14. package/lib/types/client/fileType.d.ts.map +1 -1
  15. package/lib/types/client/index.d.ts.map +1 -1
  16. package/lib/types/client/locales.d.ts +3 -0
  17. package/lib/types/client/locales.d.ts.map +1 -1
  18. package/lib/types/client/mount.d.ts +1 -1
  19. package/lib/types/client/mount.d.ts.map +1 -1
  20. package/lib/types/client/preview/PreviewPanel.d.ts +2 -1
  21. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -1
  22. package/lib/types/client/preview/PreviewToolbar.d.ts +3 -1
  23. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -1
  24. package/lib/types/client/preview/content.d.ts +3 -1
  25. package/lib/types/client/preview/content.d.ts.map +1 -1
  26. package/lib/types/client/preview/office.d.ts +23 -0
  27. package/lib/types/client/preview/office.d.ts.map +1 -0
  28. package/lib/types/client/reference.d.ts +17 -0
  29. package/lib/types/client/reference.d.ts.map +1 -1
  30. package/lib/types/client/store.d.ts.map +1 -1
  31. package/lib/types/host/fs-service.d.ts.map +1 -1
  32. package/lib/types/host/office-preview.d.ts +13 -0
  33. package/lib/types/host/office-preview.d.ts.map +1 -0
  34. package/lib/types/host/routes.d.ts.map +1 -1
  35. package/lib/types/index.d.ts.map +1 -1
  36. package/package.json +19 -4
  37. package/src/client/FileManagerSettingsCard.tsx +117 -117
  38. package/src/client/PluginSettingsCard.tsx +337 -337
  39. package/src/client/chat/file-ref.ts +144 -144
  40. package/src/client/chat/mermaid-chat.tsx +102 -102
  41. package/src/client/chat/placeholder-hint.tsx +46 -46
  42. package/src/client/components/ExplorerPanel.tsx +534 -534
  43. package/src/client/components/FileIcon.tsx +46 -46
  44. package/src/client/components/ScmPanel.tsx +480 -480
  45. package/src/client/components/a11y.ts +20 -20
  46. package/src/client/components/icons.tsx +274 -274
  47. package/src/client/components/overlay.tsx +225 -225
  48. package/src/client/drag/DragFileInlay.tsx +89 -89
  49. package/src/client/drag/file-drag.ts +67 -67
  50. package/src/client/drag.ts +154 -154
  51. package/src/client/file-source.ts +34 -0
  52. package/src/client/fileType.ts +32 -5
  53. package/src/client/hooks/useResizableSplit.ts +91 -91
  54. package/src/client/hooks/useStore.ts +15 -15
  55. package/src/client/index.ts +42 -3
  56. package/src/client/layout.ts +523 -523
  57. package/src/client/locales.ts +6 -0
  58. package/src/client/maximize.ts +34 -34
  59. package/src/client/mount.tsx +2 -1
  60. package/src/client/persist.ts +194 -194
  61. package/src/client/picker/FilePickerModal.tsx +526 -526
  62. package/src/client/picker/file-picker.ts +86 -86
  63. package/src/client/preview/PreviewPanel.tsx +7 -1
  64. package/src/client/preview/PreviewTabs.tsx +159 -159
  65. package/src/client/preview/PreviewToolbar.tsx +3 -2
  66. package/src/client/preview/content.tsx +7 -2
  67. package/src/client/preview/markdown.ts +351 -351
  68. package/src/client/preview/mermaid.ts +274 -274
  69. package/src/client/preview/office.tsx +321 -0
  70. package/src/client/reference.ts +49 -0
  71. package/src/client/settings-card.module.css +316 -316
  72. package/src/client/settings-form.ts +451 -451
  73. package/src/client/store.ts +17 -12
  74. package/src/client/styles/drag.module.css +30 -30
  75. package/src/client/styles/explorer.module.css +371 -371
  76. package/src/client/styles/picker.module.css +344 -344
  77. package/src/client/styles/preview.module.css +3 -1
  78. package/src/client/styles/scm.module.css +380 -380
  79. package/src/client/styles/tokens.module.css +376 -371
  80. package/src/core/types.ts +162 -162
  81. package/src/host/fs-service.ts +7 -1
  82. package/src/host/gate.ts +73 -73
  83. package/src/host/git-runner.ts +116 -116
  84. package/src/host/git-service.ts +395 -395
  85. package/src/host/loopback.ts +63 -63
  86. package/src/host/office-preview.ts +399 -0
  87. package/src/host/poll-guard.ts +109 -109
  88. package/src/host/routes.ts +72 -1
  89. package/src/index.ts +3 -1
  90. package/src/mount-once.ts +48 -48
  91. package/lib/types/client/chat/AttachedFilesDock.d.ts +0 -51
  92. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +0 -1
  93. package/lib/types/client/floating.d.ts +0 -27
  94. package/lib/types/client/floating.d.ts.map +0 -1
  95. package/lib/types/client/mention.d.ts +0 -44
  96. package/lib/types/client/mention.d.ts.map +0 -1
@@ -1,395 +1,395 @@
1
- /**
2
- * Host git service for the SCM tab: working-tree status (porcelain v1, -z),
3
- * stage/unstage/discard batches, all scoped to the gated project root and
4
- * executed through the managed subprocess seam. Parsing is pure and exported
5
- * for tests; the service only wraps the runner. Discard never touches the
6
- * staged side (the index is only ever rewritten by stage/unstage), matching
7
- * the "discard = worktree side" contract.
8
- * @module dsh-filemgr/host/git-service
9
- */
10
-
11
- import { join, relative } from 'node:path'
12
- import { realpath } from 'node:fs/promises'
13
- import type { Context } from '@deepseek-ai/cordis'
14
- import type {} from '@deepseek-ai/dsh-subprocess'
15
- import { subprocessRunner as sharedSubprocessRunner, type GitRunResult, type GitRunner } from './git-runner.ts'
16
- import type { GitBatchResult, GitChangeRow, GitFileState, GitStatusView, PanelError } from '../core/types.ts'
17
- import { isPathInside, type WorkspaceGate } from './gate.ts'
18
-
19
- /** One finished git invocation (shared runner plumbing). */
20
- export type { GitRunResult, GitRunner } from './git-runner.ts'
21
-
22
- /** TTL for a positive repo-top-level verdict. */
23
- const REPO_CACHE_TTL_MS = 60_000
24
- /** TTL for a negative (null) repo-top-level verdict. */
25
- const NO_REPO_CACHE_TTL_MS = 30_000
26
-
27
- /** Production runner over `ctx.subprocess`: shared plumbing, degrade mode for the SCM tab. */
28
- export function subprocessRunner(ctx: Context): GitRunner {
29
- return sharedSubprocessRunner(ctx, { failureMode: 'degrade', errorTag: 'dsh-filemgr' })
30
- }
31
-
32
- /** Map one porcelain letter to the row state (unknown letters stay unknown). */
33
- export function porcelainState(letter: string): GitFileState {
34
- switch (letter) {
35
- case 'A': return 'created'
36
- case 'M': return 'modified'
37
- case 'D': return 'deleted'
38
- case 'R': return 'renamed'
39
- case 'C': return 'created'
40
- case 'U': return 'conflicted'
41
- case '?': return 'untracked'
42
- default: return 'unknown'
43
- }
44
- }
45
-
46
- /**
47
- * Parse `git status --porcelain=v1 -z` output into staged/unstaged/untracked
48
- * rows. With -z every entry is NUL-terminated; rename entries carry two paths
49
- * (old and new). Pure — exported for tests.
50
- * @param output - raw porcelain v1 -z output.
51
- * @returns the three change groups.
52
- */
53
- export function parsePorcelain(output: string): {
54
- staged: GitChangeRow[]
55
- unstaged: GitChangeRow[]
56
- untracked: GitChangeRow[]
57
- } {
58
- const staged: GitChangeRow[] = []
59
- const unstaged: GitChangeRow[] = []
60
- const untracked: GitChangeRow[] = []
61
- if (output === '') return { staged, unstaged, untracked }
62
- const fields = output.split('\0')
63
- for (let i = 0; i < fields.length; i += 1) {
64
- const field = fields[i]
65
- if (field === '') continue
66
- const x = field[0] ?? ' '
67
- const y = field[1] ?? ' '
68
- const path = field.slice(3)
69
- if (x === '?' && y === '?') {
70
- untracked.push({ path, state: 'untracked', staged: false })
71
- continue
72
- }
73
- if (x === 'R' || x === 'C') {
74
- // -z rename entries: XY old\0new — the path field holds the old path.
75
- const oldPath = path
76
- const newPath = fields[i + 1] ?? oldPath
77
- i += 1
78
- staged.push({ path: newPath, oldPath, state: porcelainState(x), staged: true })
79
- if (y !== ' ') {
80
- unstaged.push({ path: newPath, oldPath, state: porcelainState(y), staged: false })
81
- }
82
- continue
83
- }
84
- if (x !== ' ') {
85
- staged.push({ path, state: porcelainState(x), staged: true })
86
- }
87
- if (y !== ' ') {
88
- unstaged.push({ path, state: porcelainState(y), staged: false })
89
- }
90
- }
91
- return { staged, unstaged, untracked }
92
- }
93
-
94
- /** Parse the porcelain row set into the status view shape. */
95
- export function parseStatusView(root: string, branch: string, output: string): GitStatusView {
96
- const { staged, unstaged, untracked } = parsePorcelain(output)
97
- return { root, branch, staged, unstaged, untracked }
98
- }
99
-
100
- /** The not-a-repository verdict for status reads. */
101
- const NO_REPO: PanelError = { code: 'git-unavailable', message: 'not a git repository' }
102
-
103
- /**
104
- * Workspace-scoped git operations. Gated methods pass the gate, resolve the
105
- * repository root, and reject non-repositories with a stable error; the
106
- * `Canonical` variants trust an already-gated canonical root (the SSE poll)
107
- * and skip the gate.
108
- * @param runner - the spawn seam.
109
- * @param gate - workspace-membership gate.
110
- * @param fsDelete - delete seam for untracked discard (host: FsService.delete).
111
- */
112
- export class GitService {
113
- constructor(
114
- private readonly runner: GitRunner,
115
- private readonly gate: WorkspaceGate,
116
- private readonly fsDelete: (root: string, rel: string) => Promise<{ ok: true } | PanelError>,
117
- ) {}
118
-
119
- /** Cached one-shot git binary probe; never re-probes after the first call. */
120
- private availablePromise: Promise<boolean> | undefined
121
-
122
- /**
123
- * Cached repo-top-level resolution per canonical workspace, with a TTL so
124
- * running `git init` (positive self-heal) or deleting `.git` (negative
125
- * self-heal) is discovered by a later probe. Positive verdicts live 60s,
126
- * negative (null) verdicts 30s; exitCode 127 is never cached because it
127
- * means spawn/run failed rather than "not a repository".
128
- */
129
- private readonly repoCache = new Map<string, { value: Promise<string | null>; expiresAt: number }>()
130
-
131
- /**
132
- * Probe the git binary once (git --version) and cache the verdict for the
133
- * service lifetime. A machine without git then degrades every operation to
134
- * the stable "not a git repository" state after a single failed spawn,
135
- * instead of re-spawning ENOENT on every poll tick. The cache stays false
136
- * even if git is installed later; the host restart picks it up.
137
- */
138
- gitAvailable(): Promise<boolean> {
139
- if (this.availablePromise === undefined) {
140
- this.availablePromise = this.runner
141
- .run(['--version'], '/')
142
- .then((result) => result.exitCode === 0)
143
- .catch(() => false)
144
- }
145
- return this.availablePromise
146
- }
147
-
148
- /**
149
- * Resolve the repo top-level for one canonical root. Verdicts are cached
150
- * with a TTL: a positive repo path for 60s, a negative null for 30s. After
151
- * expiry the next call re-runs `rev-parse --show-toplevel`, so a repo
152
- * created or removed while the host is running is picked up later. An
153
- * exitCode 127 means the spawn/run itself failed; it returns null but is
154
- * deliberately not cached so the next call retries. Any other failure is
155
- * cached as a negative verdict for its TTL.
156
- */
157
- private repoOf(root: string): Promise<string | null> {
158
- const now = Date.now()
159
- const cached = this.repoCache.get(root)
160
- if (cached !== undefined && cached.expiresAt > now) return cached.value
161
- // Infinity while the probe is in flight: concurrent callers share the
162
- // same promise, and the real TTL is stamped once the verdict settles.
163
- const entry: { value: Promise<string | null>; expiresAt: number } = {
164
- value: Promise.resolve(null),
165
- expiresAt: Number.POSITIVE_INFINITY,
166
- }
167
- entry.value = this.run(['rev-parse', '--show-toplevel'], root)
168
- .then((result) => {
169
- if (result.exitCode === 127) {
170
- // Spawn/run failure is not a repo verdict: leave nothing cached.
171
- if (this.repoCache.get(root) === entry) this.repoCache.delete(root)
172
- return null
173
- }
174
- if (result.exitCode !== 0) {
175
- entry.expiresAt = now + NO_REPO_CACHE_TTL_MS
176
- return null
177
- }
178
- const repo = result.stdout.trim()
179
- const found = repo !== '' && isPathInside(repo, root) ? repo : null
180
- entry.expiresAt = now + (found === null ? NO_REPO_CACHE_TTL_MS : REPO_CACHE_TTL_MS)
181
- return found
182
- })
183
- .catch(() => {
184
- entry.expiresAt = now + NO_REPO_CACHE_TTL_MS
185
- return null
186
- })
187
- this.repoCache.set(root, entry)
188
- return entry.value
189
- }
190
-
191
- /**
192
- * Whether an already-gated canonical root is a git repository. Skips the
193
- * workspace gate so the SSE poll does not double-gate every 2s tick; the
194
- * underlying repoOf cache keeps rev-parse probes at TTL cadence.
195
- */
196
- isRepositoryCanonical(canonicalRoot: string): Promise<boolean> {
197
- return this.repoOf(canonicalRoot).then((repo) => repo !== null)
198
- }
199
-
200
- /**
201
- * Whether a workspace root is a git repository. Gates the root first (POST
202
- * route entry point); the SSE poll should use `isRepositoryCanonical`.
203
- */
204
- async isRepository(root: string): Promise<boolean> {
205
- const gated = await this.gate(root)
206
- if (!gated.ok) return false
207
- return await this.isRepositoryCanonical(gated.canonical)
208
- }
209
-
210
- /** Resolve the gated canonical root and the repository top-level. */
211
- private async repo(root: string): Promise<{ ok: true; root: string; repo: string } | { ok: false; error: PanelError }> {
212
- const gated = await this.gate(root)
213
- if (!gated.ok) return { ok: false, error: gated.error }
214
- const repo = await this.repoOf(gated.canonical)
215
- if (repo === null) return { ok: false, error: NO_REPO }
216
- return { ok: true, root: gated.canonical, repo }
217
- }
218
-
219
- /** Run one git invocation and classify failures. */
220
- private async run(argv: readonly string[], cwd: string): Promise<GitRunResult> {
221
- return this.runner.run(argv, cwd)
222
- }
223
-
224
- /** The repo status view; null when the root is not a repository. */
225
- async status(root: string): Promise<GitStatusView | null | PanelError> {
226
- // A missing git binary answers before any spawn: the probe runs once per
227
- // service lifetime, so a git-less machine never re-spawns ENOENT here.
228
- if (!(await this.gitAvailable())) return null
229
- const repo = await this.repo(root)
230
- if (!repo.ok) return repo.error.code === 'git-unavailable' ? null : repo.error
231
- return this.statusAt(repo.root, repo.repo)
232
- }
233
-
234
- /**
235
- * The repo status view for an already-gated canonical root; null when it is
236
- * not a repository. Skips the workspace gate (SSE subscribers were gated at
237
- * connect) and reuses the same repoOf cache + status parsing as `status`.
238
- */
239
- async statusCanonical(canonicalRoot: string): Promise<GitStatusView | null> {
240
- const repo = await this.repoOf(canonicalRoot)
241
- if (repo === null) return null
242
- return this.statusAt(canonicalRoot, repo)
243
- }
244
-
245
- /** Run branch + porcelain status for one resolved repo and parse the view. */
246
- private async statusAt(root: string, repo: string): Promise<GitStatusView> {
247
- const [branchResult, statusResult] = await Promise.all([
248
- this.run(['rev-parse', '--abbrev-ref', 'HEAD'], repo),
249
- this.run(['status', '--porcelain=v1', '-z', '--untracked-files=all'], repo),
250
- ])
251
- const branch = branchResult.stdout.trim() === 'HEAD' ? '' : branchResult.stdout.trim()
252
- return parseStatusView(root, branch, statusResult.stdout)
253
- }
254
-
255
- /** The repo root for the watch layer (null when not a repository). */
256
- async repoRoot(root: string): Promise<string | null> {
257
- const repo = await this.repo(root)
258
- return repo.ok ? repo.repo : null
259
- }
260
-
261
- /**
262
- * The unified diff of one path ('' when there is no diff to show). Staged
263
- * paths diff the index against HEAD (`--cached`); unstaged paths diff the
264
- * worktree against the index. Untracked paths have no index/HEAD entry, so
265
- * they diff against /dev/null (the canonical new-file shape); its exit code
266
- * is 1 — differences exist — which is a success here, not a failure.
267
- */
268
- async diff(root: string, path: string, staged: boolean): Promise<{ content: string } | PanelError> {
269
- const repo = await this.repo(root)
270
- if (!repo.ok) return repo.error
271
- const abs = join(repo.repo, path)
272
- if (!isPathInside(repo.repo, abs)) return { code: 'path-outside-root', message: 'path outside the repository' }
273
- const rel = relative(repo.repo, abs)
274
- const tracked = await this.run(['ls-files', '--error-unmatch', '--', rel], repo.repo)
275
- const result = tracked.exitCode !== 0
276
- ? await this.run(['diff', '--no-index', '--', '/dev/null', rel], repo.repo)
277
- : staged
278
- ? await this.run(['diff', '--cached', '--', rel], repo.repo)
279
- : await this.run(['diff', '--', rel], repo.repo)
280
- // --no-index reports exit 1 when differences exist; a plain diff exits 0.
281
- if (result.exitCode !== 0 && result.exitCode !== 1) {
282
- return { code: 'git-failed', message: 'git diff failed' }
283
- }
284
- return { content: result.stdout }
285
- }
286
-
287
- /** Verify paths stay inside the repo root (defense in depth). */
288
- private pathsInside(repo: string, paths: string[]): string[] {
289
- const abs = paths.map((p) => join(repo, p))
290
- return abs.filter((p) => isPathInside(repo, p)).map((p) => p)
291
- }
292
-
293
- /** Stage paths (git add). Batch result reflects the post-op status. */
294
- async stage(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
295
- return this.batch(root, paths, async (repo, inside) => {
296
- const result = await this.run(['add', '--', ...inside], repo)
297
- return result.exitCode === 0
298
- })
299
- }
300
-
301
- /** Unstage paths (git restore --staged). */
302
- async unstage(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
303
- return this.batch(root, paths, async (repo, inside) => {
304
- const result = await this.run(['restore', '--staged', '--', ...inside], repo)
305
- return result.exitCode === 0
306
- })
307
- }
308
-
309
- /**
310
- * Discard paths (worktree side only). Tracked paths are restored from the
311
- * index; untracked paths are deleted through the fs seam. The batch reports
312
- * applied/failed per path.
313
- */
314
- async discard(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
315
- const repo = await this.repo(root)
316
- if (!repo.ok) return repo.error
317
- const inside = this.pathsInside(repo.repo, paths)
318
- const applied: string[] = []
319
- const failed: string[] = []
320
- // Membership is checked on the ABSOLUTE path (pathsInside resolves); the
321
- // git commands below run with the repo-relative path (cwd = repo).
322
- const eligible: string[] = []
323
- for (const p of paths) {
324
- if (inside.includes(join(repo.repo, p))) eligible.push(p)
325
- else failed.push(p)
326
- }
327
- // One ls-files classifies every eligible path (its output IS the tracked
328
- // set) instead of one --error-unmatch spawn per path.
329
- const trackedSet = new Set<string>()
330
- if (eligible.length > 0) {
331
- const listed = await this.run(['ls-files', '-z', '--', ...eligible.map(p => ':(literal)' + p)], repo.repo)
332
- for (const entry of listed.stdout.split('\0')) if (entry !== '') trackedSet.add(entry)
333
- }
334
- const tracked = eligible.filter(p => trackedSet.has(p))
335
- const untracked = eligible.filter(p => !trackedSet.has(p))
336
- // Tracked paths restore in one spawn; a batch failure falls back to
337
- // per-path restores so the applied/failed split stays exact.
338
- if (tracked.length > 0) {
339
- const restored = await this.run(['restore', '--worktree', '--', ...tracked.map(p => ':(literal)' + p)], repo.repo)
340
- if (restored.exitCode === 0) {
341
- applied.push(...tracked)
342
- } else {
343
- for (const p of tracked) {
344
- const single = await this.run(['restore', '--worktree', '--', ':(literal)' + p], repo.repo)
345
- if (single.exitCode === 0) applied.push(p)
346
- else failed.push(p)
347
- }
348
- }
349
- }
350
- for (const p of untracked) {
351
- // Untracked paths are not restored by git restore; delete them directly.
352
- // A symlink entry pointing outside the repo must not be deleted — the
353
- // fs delete would follow the link away. Realpath-check before deleting.
354
- try {
355
- const real = await realpath(join(repo.repo, p))
356
- if (!isPathInside(repo.repo, real)) {
357
- failed.push(p)
358
- continue
359
- }
360
- } catch {
361
- // The path does not exist on disk (ENOENT): nothing to escape.
362
- }
363
- // The fs seam addresses the project ROOT (which may be a subdir of
364
- // the repo); derive the root-relative path from the absolute one.
365
- const rel = relative(repo.root, join(repo.repo, p))
366
- // Untracked files that lie outside the session root cannot be deleted
367
- // through the fs seam; refuse rather than delete a look-alike path.
368
- if (rel === '..' || rel.startsWith('../')) {
369
- failed.push(p)
370
- continue
371
- }
372
- const deleted = await this.fsDelete(repo.root, rel)
373
- if ('ok' in deleted && deleted.ok) applied.push(p)
374
- else failed.push(p)
375
- }
376
- return { applied, failed }
377
- }
378
-
379
- /** Shared batch plumbing: gate, repo resolve, path filter, run the op. */
380
- private async batch(
381
- root: string,
382
- paths: string[],
383
- op: (repo: string, inside: string[]) => Promise<boolean>,
384
- ): Promise<GitBatchResult | PanelError> {
385
- const repo = await this.repo(root)
386
- if (!repo.ok) return repo.error
387
- const inside = this.pathsInside(repo.repo, paths)
388
- const ok = inside.length > 0 ? await op(repo.repo, inside) : true
389
- if (!ok) return { code: 'git-failed', message: 'git operation failed' }
390
- // Report the REQUESTED (repo-relative) spellings, not the resolved absolutes.
391
- const applied = ok ? paths.filter((p) => inside.includes(join(repo.repo, p))) : []
392
- const failed = paths.filter((p) => !inside.includes(join(repo.repo, p)))
393
- return { applied, failed }
394
- }
395
- }
1
+ /**
2
+ * Host git service for the SCM tab: working-tree status (porcelain v1, -z),
3
+ * stage/unstage/discard batches, all scoped to the gated project root and
4
+ * executed through the managed subprocess seam. Parsing is pure and exported
5
+ * for tests; the service only wraps the runner. Discard never touches the
6
+ * staged side (the index is only ever rewritten by stage/unstage), matching
7
+ * the "discard = worktree side" contract.
8
+ * @module dsh-filemgr/host/git-service
9
+ */
10
+
11
+ import { join, relative } from 'node:path'
12
+ import { realpath } from 'node:fs/promises'
13
+ import type { Context } from '@deepseek-ai/cordis'
14
+ import type {} from '@deepseek-ai/dsh-subprocess'
15
+ import { subprocessRunner as sharedSubprocessRunner, type GitRunResult, type GitRunner } from './git-runner.ts'
16
+ import type { GitBatchResult, GitChangeRow, GitFileState, GitStatusView, PanelError } from '../core/types.ts'
17
+ import { isPathInside, type WorkspaceGate } from './gate.ts'
18
+
19
+ /** One finished git invocation (shared runner plumbing). */
20
+ export type { GitRunResult, GitRunner } from './git-runner.ts'
21
+
22
+ /** TTL for a positive repo-top-level verdict. */
23
+ const REPO_CACHE_TTL_MS = 60_000
24
+ /** TTL for a negative (null) repo-top-level verdict. */
25
+ const NO_REPO_CACHE_TTL_MS = 30_000
26
+
27
+ /** Production runner over `ctx.subprocess`: shared plumbing, degrade mode for the SCM tab. */
28
+ export function subprocessRunner(ctx: Context): GitRunner {
29
+ return sharedSubprocessRunner(ctx, { failureMode: 'degrade', errorTag: 'dsh-filemgr' })
30
+ }
31
+
32
+ /** Map one porcelain letter to the row state (unknown letters stay unknown). */
33
+ export function porcelainState(letter: string): GitFileState {
34
+ switch (letter) {
35
+ case 'A': return 'created'
36
+ case 'M': return 'modified'
37
+ case 'D': return 'deleted'
38
+ case 'R': return 'renamed'
39
+ case 'C': return 'created'
40
+ case 'U': return 'conflicted'
41
+ case '?': return 'untracked'
42
+ default: return 'unknown'
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Parse `git status --porcelain=v1 -z` output into staged/unstaged/untracked
48
+ * rows. With -z every entry is NUL-terminated; rename entries carry two paths
49
+ * (old and new). Pure — exported for tests.
50
+ * @param output - raw porcelain v1 -z output.
51
+ * @returns the three change groups.
52
+ */
53
+ export function parsePorcelain(output: string): {
54
+ staged: GitChangeRow[]
55
+ unstaged: GitChangeRow[]
56
+ untracked: GitChangeRow[]
57
+ } {
58
+ const staged: GitChangeRow[] = []
59
+ const unstaged: GitChangeRow[] = []
60
+ const untracked: GitChangeRow[] = []
61
+ if (output === '') return { staged, unstaged, untracked }
62
+ const fields = output.split('\0')
63
+ for (let i = 0; i < fields.length; i += 1) {
64
+ const field = fields[i]
65
+ if (field === '') continue
66
+ const x = field[0] ?? ' '
67
+ const y = field[1] ?? ' '
68
+ const path = field.slice(3)
69
+ if (x === '?' && y === '?') {
70
+ untracked.push({ path, state: 'untracked', staged: false })
71
+ continue
72
+ }
73
+ if (x === 'R' || x === 'C') {
74
+ // -z rename entries: XY old\0new — the path field holds the old path.
75
+ const oldPath = path
76
+ const newPath = fields[i + 1] ?? oldPath
77
+ i += 1
78
+ staged.push({ path: newPath, oldPath, state: porcelainState(x), staged: true })
79
+ if (y !== ' ') {
80
+ unstaged.push({ path: newPath, oldPath, state: porcelainState(y), staged: false })
81
+ }
82
+ continue
83
+ }
84
+ if (x !== ' ') {
85
+ staged.push({ path, state: porcelainState(x), staged: true })
86
+ }
87
+ if (y !== ' ') {
88
+ unstaged.push({ path, state: porcelainState(y), staged: false })
89
+ }
90
+ }
91
+ return { staged, unstaged, untracked }
92
+ }
93
+
94
+ /** Parse the porcelain row set into the status view shape. */
95
+ export function parseStatusView(root: string, branch: string, output: string): GitStatusView {
96
+ const { staged, unstaged, untracked } = parsePorcelain(output)
97
+ return { root, branch, staged, unstaged, untracked }
98
+ }
99
+
100
+ /** The not-a-repository verdict for status reads. */
101
+ const NO_REPO: PanelError = { code: 'git-unavailable', message: 'not a git repository' }
102
+
103
+ /**
104
+ * Workspace-scoped git operations. Gated methods pass the gate, resolve the
105
+ * repository root, and reject non-repositories with a stable error; the
106
+ * `Canonical` variants trust an already-gated canonical root (the SSE poll)
107
+ * and skip the gate.
108
+ * @param runner - the spawn seam.
109
+ * @param gate - workspace-membership gate.
110
+ * @param fsDelete - delete seam for untracked discard (host: FsService.delete).
111
+ */
112
+ export class GitService {
113
+ constructor(
114
+ private readonly runner: GitRunner,
115
+ private readonly gate: WorkspaceGate,
116
+ private readonly fsDelete: (root: string, rel: string) => Promise<{ ok: true } | PanelError>,
117
+ ) {}
118
+
119
+ /** Cached one-shot git binary probe; never re-probes after the first call. */
120
+ private availablePromise: Promise<boolean> | undefined
121
+
122
+ /**
123
+ * Cached repo-top-level resolution per canonical workspace, with a TTL so
124
+ * running `git init` (positive self-heal) or deleting `.git` (negative
125
+ * self-heal) is discovered by a later probe. Positive verdicts live 60s,
126
+ * negative (null) verdicts 30s; exitCode 127 is never cached because it
127
+ * means spawn/run failed rather than "not a repository".
128
+ */
129
+ private readonly repoCache = new Map<string, { value: Promise<string | null>; expiresAt: number }>()
130
+
131
+ /**
132
+ * Probe the git binary once (git --version) and cache the verdict for the
133
+ * service lifetime. A machine without git then degrades every operation to
134
+ * the stable "not a git repository" state after a single failed spawn,
135
+ * instead of re-spawning ENOENT on every poll tick. The cache stays false
136
+ * even if git is installed later; the host restart picks it up.
137
+ */
138
+ gitAvailable(): Promise<boolean> {
139
+ if (this.availablePromise === undefined) {
140
+ this.availablePromise = this.runner
141
+ .run(['--version'], '/')
142
+ .then((result) => result.exitCode === 0)
143
+ .catch(() => false)
144
+ }
145
+ return this.availablePromise
146
+ }
147
+
148
+ /**
149
+ * Resolve the repo top-level for one canonical root. Verdicts are cached
150
+ * with a TTL: a positive repo path for 60s, a negative null for 30s. After
151
+ * expiry the next call re-runs `rev-parse --show-toplevel`, so a repo
152
+ * created or removed while the host is running is picked up later. An
153
+ * exitCode 127 means the spawn/run itself failed; it returns null but is
154
+ * deliberately not cached so the next call retries. Any other failure is
155
+ * cached as a negative verdict for its TTL.
156
+ */
157
+ private repoOf(root: string): Promise<string | null> {
158
+ const now = Date.now()
159
+ const cached = this.repoCache.get(root)
160
+ if (cached !== undefined && cached.expiresAt > now) return cached.value
161
+ // Infinity while the probe is in flight: concurrent callers share the
162
+ // same promise, and the real TTL is stamped once the verdict settles.
163
+ const entry: { value: Promise<string | null>; expiresAt: number } = {
164
+ value: Promise.resolve(null),
165
+ expiresAt: Number.POSITIVE_INFINITY,
166
+ }
167
+ entry.value = this.run(['rev-parse', '--show-toplevel'], root)
168
+ .then((result) => {
169
+ if (result.exitCode === 127) {
170
+ // Spawn/run failure is not a repo verdict: leave nothing cached.
171
+ if (this.repoCache.get(root) === entry) this.repoCache.delete(root)
172
+ return null
173
+ }
174
+ if (result.exitCode !== 0) {
175
+ entry.expiresAt = now + NO_REPO_CACHE_TTL_MS
176
+ return null
177
+ }
178
+ const repo = result.stdout.trim()
179
+ const found = repo !== '' && isPathInside(repo, root) ? repo : null
180
+ entry.expiresAt = now + (found === null ? NO_REPO_CACHE_TTL_MS : REPO_CACHE_TTL_MS)
181
+ return found
182
+ })
183
+ .catch(() => {
184
+ entry.expiresAt = now + NO_REPO_CACHE_TTL_MS
185
+ return null
186
+ })
187
+ this.repoCache.set(root, entry)
188
+ return entry.value
189
+ }
190
+
191
+ /**
192
+ * Whether an already-gated canonical root is a git repository. Skips the
193
+ * workspace gate so the SSE poll does not double-gate every 2s tick; the
194
+ * underlying repoOf cache keeps rev-parse probes at TTL cadence.
195
+ */
196
+ isRepositoryCanonical(canonicalRoot: string): Promise<boolean> {
197
+ return this.repoOf(canonicalRoot).then((repo) => repo !== null)
198
+ }
199
+
200
+ /**
201
+ * Whether a workspace root is a git repository. Gates the root first (POST
202
+ * route entry point); the SSE poll should use `isRepositoryCanonical`.
203
+ */
204
+ async isRepository(root: string): Promise<boolean> {
205
+ const gated = await this.gate(root)
206
+ if (!gated.ok) return false
207
+ return await this.isRepositoryCanonical(gated.canonical)
208
+ }
209
+
210
+ /** Resolve the gated canonical root and the repository top-level. */
211
+ private async repo(root: string): Promise<{ ok: true; root: string; repo: string } | { ok: false; error: PanelError }> {
212
+ const gated = await this.gate(root)
213
+ if (!gated.ok) return { ok: false, error: gated.error }
214
+ const repo = await this.repoOf(gated.canonical)
215
+ if (repo === null) return { ok: false, error: NO_REPO }
216
+ return { ok: true, root: gated.canonical, repo }
217
+ }
218
+
219
+ /** Run one git invocation and classify failures. */
220
+ private async run(argv: readonly string[], cwd: string): Promise<GitRunResult> {
221
+ return this.runner.run(argv, cwd)
222
+ }
223
+
224
+ /** The repo status view; null when the root is not a repository. */
225
+ async status(root: string): Promise<GitStatusView | null | PanelError> {
226
+ // A missing git binary answers before any spawn: the probe runs once per
227
+ // service lifetime, so a git-less machine never re-spawns ENOENT here.
228
+ if (!(await this.gitAvailable())) return null
229
+ const repo = await this.repo(root)
230
+ if (!repo.ok) return repo.error.code === 'git-unavailable' ? null : repo.error
231
+ return this.statusAt(repo.root, repo.repo)
232
+ }
233
+
234
+ /**
235
+ * The repo status view for an already-gated canonical root; null when it is
236
+ * not a repository. Skips the workspace gate (SSE subscribers were gated at
237
+ * connect) and reuses the same repoOf cache + status parsing as `status`.
238
+ */
239
+ async statusCanonical(canonicalRoot: string): Promise<GitStatusView | null> {
240
+ const repo = await this.repoOf(canonicalRoot)
241
+ if (repo === null) return null
242
+ return this.statusAt(canonicalRoot, repo)
243
+ }
244
+
245
+ /** Run branch + porcelain status for one resolved repo and parse the view. */
246
+ private async statusAt(root: string, repo: string): Promise<GitStatusView> {
247
+ const [branchResult, statusResult] = await Promise.all([
248
+ this.run(['rev-parse', '--abbrev-ref', 'HEAD'], repo),
249
+ this.run(['status', '--porcelain=v1', '-z', '--untracked-files=all'], repo),
250
+ ])
251
+ const branch = branchResult.stdout.trim() === 'HEAD' ? '' : branchResult.stdout.trim()
252
+ return parseStatusView(root, branch, statusResult.stdout)
253
+ }
254
+
255
+ /** The repo root for the watch layer (null when not a repository). */
256
+ async repoRoot(root: string): Promise<string | null> {
257
+ const repo = await this.repo(root)
258
+ return repo.ok ? repo.repo : null
259
+ }
260
+
261
+ /**
262
+ * The unified diff of one path ('' when there is no diff to show). Staged
263
+ * paths diff the index against HEAD (`--cached`); unstaged paths diff the
264
+ * worktree against the index. Untracked paths have no index/HEAD entry, so
265
+ * they diff against /dev/null (the canonical new-file shape); its exit code
266
+ * is 1 — differences exist — which is a success here, not a failure.
267
+ */
268
+ async diff(root: string, path: string, staged: boolean): Promise<{ content: string } | PanelError> {
269
+ const repo = await this.repo(root)
270
+ if (!repo.ok) return repo.error
271
+ const abs = join(repo.repo, path)
272
+ if (!isPathInside(repo.repo, abs)) return { code: 'path-outside-root', message: 'path outside the repository' }
273
+ const rel = relative(repo.repo, abs)
274
+ const tracked = await this.run(['ls-files', '--error-unmatch', '--', rel], repo.repo)
275
+ const result = tracked.exitCode !== 0
276
+ ? await this.run(['diff', '--no-index', '--', '/dev/null', rel], repo.repo)
277
+ : staged
278
+ ? await this.run(['diff', '--cached', '--', rel], repo.repo)
279
+ : await this.run(['diff', '--', rel], repo.repo)
280
+ // --no-index reports exit 1 when differences exist; a plain diff exits 0.
281
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
282
+ return { code: 'git-failed', message: 'git diff failed' }
283
+ }
284
+ return { content: result.stdout }
285
+ }
286
+
287
+ /** Verify paths stay inside the repo root (defense in depth). */
288
+ private pathsInside(repo: string, paths: string[]): string[] {
289
+ const abs = paths.map((p) => join(repo, p))
290
+ return abs.filter((p) => isPathInside(repo, p)).map((p) => p)
291
+ }
292
+
293
+ /** Stage paths (git add). Batch result reflects the post-op status. */
294
+ async stage(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
295
+ return this.batch(root, paths, async (repo, inside) => {
296
+ const result = await this.run(['add', '--', ...inside], repo)
297
+ return result.exitCode === 0
298
+ })
299
+ }
300
+
301
+ /** Unstage paths (git restore --staged). */
302
+ async unstage(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
303
+ return this.batch(root, paths, async (repo, inside) => {
304
+ const result = await this.run(['restore', '--staged', '--', ...inside], repo)
305
+ return result.exitCode === 0
306
+ })
307
+ }
308
+
309
+ /**
310
+ * Discard paths (worktree side only). Tracked paths are restored from the
311
+ * index; untracked paths are deleted through the fs seam. The batch reports
312
+ * applied/failed per path.
313
+ */
314
+ async discard(root: string, paths: string[]): Promise<GitBatchResult | PanelError> {
315
+ const repo = await this.repo(root)
316
+ if (!repo.ok) return repo.error
317
+ const inside = this.pathsInside(repo.repo, paths)
318
+ const applied: string[] = []
319
+ const failed: string[] = []
320
+ // Membership is checked on the ABSOLUTE path (pathsInside resolves); the
321
+ // git commands below run with the repo-relative path (cwd = repo).
322
+ const eligible: string[] = []
323
+ for (const p of paths) {
324
+ if (inside.includes(join(repo.repo, p))) eligible.push(p)
325
+ else failed.push(p)
326
+ }
327
+ // One ls-files classifies every eligible path (its output IS the tracked
328
+ // set) instead of one --error-unmatch spawn per path.
329
+ const trackedSet = new Set<string>()
330
+ if (eligible.length > 0) {
331
+ const listed = await this.run(['ls-files', '-z', '--', ...eligible.map(p => ':(literal)' + p)], repo.repo)
332
+ for (const entry of listed.stdout.split('\0')) if (entry !== '') trackedSet.add(entry)
333
+ }
334
+ const tracked = eligible.filter(p => trackedSet.has(p))
335
+ const untracked = eligible.filter(p => !trackedSet.has(p))
336
+ // Tracked paths restore in one spawn; a batch failure falls back to
337
+ // per-path restores so the applied/failed split stays exact.
338
+ if (tracked.length > 0) {
339
+ const restored = await this.run(['restore', '--worktree', '--', ...tracked.map(p => ':(literal)' + p)], repo.repo)
340
+ if (restored.exitCode === 0) {
341
+ applied.push(...tracked)
342
+ } else {
343
+ for (const p of tracked) {
344
+ const single = await this.run(['restore', '--worktree', '--', ':(literal)' + p], repo.repo)
345
+ if (single.exitCode === 0) applied.push(p)
346
+ else failed.push(p)
347
+ }
348
+ }
349
+ }
350
+ for (const p of untracked) {
351
+ // Untracked paths are not restored by git restore; delete them directly.
352
+ // A symlink entry pointing outside the repo must not be deleted — the
353
+ // fs delete would follow the link away. Realpath-check before deleting.
354
+ try {
355
+ const real = await realpath(join(repo.repo, p))
356
+ if (!isPathInside(repo.repo, real)) {
357
+ failed.push(p)
358
+ continue
359
+ }
360
+ } catch {
361
+ // The path does not exist on disk (ENOENT): nothing to escape.
362
+ }
363
+ // The fs seam addresses the project ROOT (which may be a subdir of
364
+ // the repo); derive the root-relative path from the absolute one.
365
+ const rel = relative(repo.root, join(repo.repo, p))
366
+ // Untracked files that lie outside the session root cannot be deleted
367
+ // through the fs seam; refuse rather than delete a look-alike path.
368
+ if (rel === '..' || rel.startsWith('../')) {
369
+ failed.push(p)
370
+ continue
371
+ }
372
+ const deleted = await this.fsDelete(repo.root, rel)
373
+ if ('ok' in deleted && deleted.ok) applied.push(p)
374
+ else failed.push(p)
375
+ }
376
+ return { applied, failed }
377
+ }
378
+
379
+ /** Shared batch plumbing: gate, repo resolve, path filter, run the op. */
380
+ private async batch(
381
+ root: string,
382
+ paths: string[],
383
+ op: (repo: string, inside: string[]) => Promise<boolean>,
384
+ ): Promise<GitBatchResult | PanelError> {
385
+ const repo = await this.repo(root)
386
+ if (!repo.ok) return repo.error
387
+ const inside = this.pathsInside(repo.repo, paths)
388
+ const ok = inside.length > 0 ? await op(repo.repo, inside) : true
389
+ if (!ok) return { code: 'git-failed', message: 'git operation failed' }
390
+ // Report the REQUESTED (repo-relative) spellings, not the resolved absolutes.
391
+ const applied = ok ? paths.filter((p) => inside.includes(join(repo.repo, p))) : []
392
+ const failed = paths.filter((p) => !inside.includes(join(repo.repo, p)))
393
+ return { applied, failed }
394
+ }
395
+ }