@lijian-ui/dsh-file-manager 0.1.20

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 (169) hide show
  1. package/LICENSE +38 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +66 -0
  4. package/README.zh.md +66 -0
  5. package/cordis.patch.yml +13 -0
  6. package/lib/assets/mermaid.min.js +3587 -0
  7. package/lib/client.js +7621 -0
  8. package/lib/client.js.map +1 -0
  9. package/lib/index.js +3207 -0
  10. package/lib/tsconfig.client.tsbuildinfo +1 -0
  11. package/lib/tsconfig.host.tsbuildinfo +1 -0
  12. package/lib/types/client/FileManagerSettingsCard.d.ts +56 -0
  13. package/lib/types/client/FileManagerSettingsCard.d.ts.map +1 -0
  14. package/lib/types/client/PluginSettingsCard.d.ts +104 -0
  15. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -0
  16. package/lib/types/client/api.d.ts +75 -0
  17. package/lib/types/client/api.d.ts.map +1 -0
  18. package/lib/types/client/chat/AttachedFilesDock.d.ts +51 -0
  19. package/lib/types/client/chat/AttachedFilesDock.d.ts.map +1 -0
  20. package/lib/types/client/chat/file-ref.d.ts +35 -0
  21. package/lib/types/client/chat/file-ref.d.ts.map +1 -0
  22. package/lib/types/client/chat/mermaid-chat.d.ts +30 -0
  23. package/lib/types/client/chat/mermaid-chat.d.ts.map +1 -0
  24. package/lib/types/client/chat/placeholder-hint.d.ts +16 -0
  25. package/lib/types/client/chat/placeholder-hint.d.ts.map +1 -0
  26. package/lib/types/client/components/ExplorerPanel.d.ts +26 -0
  27. package/lib/types/client/components/ExplorerPanel.d.ts.map +1 -0
  28. package/lib/types/client/components/FileIcon.d.ts +15 -0
  29. package/lib/types/client/components/FileIcon.d.ts.map +1 -0
  30. package/lib/types/client/components/ScmPanel.d.ts +23 -0
  31. package/lib/types/client/components/ScmPanel.d.ts.map +1 -0
  32. package/lib/types/client/components/a11y.d.ts +12 -0
  33. package/lib/types/client/components/a11y.d.ts.map +1 -0
  34. package/lib/types/client/components/icons.d.ts +41 -0
  35. package/lib/types/client/components/icons.d.ts.map +1 -0
  36. package/lib/types/client/components/overlay.d.ts +45 -0
  37. package/lib/types/client/components/overlay.d.ts.map +1 -0
  38. package/lib/types/client/drag/DragFileInlay.d.ts +31 -0
  39. package/lib/types/client/drag/DragFileInlay.d.ts.map +1 -0
  40. package/lib/types/client/drag/file-drag.d.ts +47 -0
  41. package/lib/types/client/drag/file-drag.d.ts.map +1 -0
  42. package/lib/types/client/drag.d.ts +31 -0
  43. package/lib/types/client/drag.d.ts.map +1 -0
  44. package/lib/types/client/file-source.d.ts +29 -0
  45. package/lib/types/client/file-source.d.ts.map +1 -0
  46. package/lib/types/client/fileType.d.ts +31 -0
  47. package/lib/types/client/fileType.d.ts.map +1 -0
  48. package/lib/types/client/floating.d.ts +27 -0
  49. package/lib/types/client/floating.d.ts.map +1 -0
  50. package/lib/types/client/hooks/useResizableSplit.d.ts +37 -0
  51. package/lib/types/client/hooks/useResizableSplit.d.ts.map +1 -0
  52. package/lib/types/client/hooks/useStore.d.ts +10 -0
  53. package/lib/types/client/hooks/useStore.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +57 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/layout.d.ts +104 -0
  57. package/lib/types/client/layout.d.ts.map +1 -0
  58. package/lib/types/client/locales.d.ts +125 -0
  59. package/lib/types/client/locales.d.ts.map +1 -0
  60. package/lib/types/client/maximize.d.ts +24 -0
  61. package/lib/types/client/maximize.d.ts.map +1 -0
  62. package/lib/types/client/mention.d.ts +44 -0
  63. package/lib/types/client/mention.d.ts.map +1 -0
  64. package/lib/types/client/mount.d.ts +22 -0
  65. package/lib/types/client/mount.d.ts.map +1 -0
  66. package/lib/types/client/persist.d.ts +55 -0
  67. package/lib/types/client/persist.d.ts.map +1 -0
  68. package/lib/types/client/picker/FilePickerModal.d.ts +47 -0
  69. package/lib/types/client/picker/FilePickerModal.d.ts.map +1 -0
  70. package/lib/types/client/picker/file-picker.d.ts +62 -0
  71. package/lib/types/client/picker/file-picker.d.ts.map +1 -0
  72. package/lib/types/client/preview/PreviewPanel.d.ts +17 -0
  73. package/lib/types/client/preview/PreviewPanel.d.ts.map +1 -0
  74. package/lib/types/client/preview/PreviewTabs.d.ts +30 -0
  75. package/lib/types/client/preview/PreviewTabs.d.ts.map +1 -0
  76. package/lib/types/client/preview/PreviewToolbar.d.ts +37 -0
  77. package/lib/types/client/preview/PreviewToolbar.d.ts.map +1 -0
  78. package/lib/types/client/preview/content.d.ts +43 -0
  79. package/lib/types/client/preview/content.d.ts.map +1 -0
  80. package/lib/types/client/preview/markdown.d.ts +60 -0
  81. package/lib/types/client/preview/markdown.d.ts.map +1 -0
  82. package/lib/types/client/preview/mermaid.d.ts +78 -0
  83. package/lib/types/client/preview/mermaid.d.ts.map +1 -0
  84. package/lib/types/client/preview/selection.d.ts +26 -0
  85. package/lib/types/client/preview/selection.d.ts.map +1 -0
  86. package/lib/types/client/reference.d.ts +75 -0
  87. package/lib/types/client/reference.d.ts.map +1 -0
  88. package/lib/types/client/settings-form.d.ts +206 -0
  89. package/lib/types/client/settings-form.d.ts.map +1 -0
  90. package/lib/types/client/store.d.ts +279 -0
  91. package/lib/types/client/store.d.ts.map +1 -0
  92. package/lib/types/core/types.d.ts +132 -0
  93. package/lib/types/core/types.d.ts.map +1 -0
  94. package/lib/types/host/fs-service.d.ts +96 -0
  95. package/lib/types/host/fs-service.d.ts.map +1 -0
  96. package/lib/types/host/gate.d.ts +45 -0
  97. package/lib/types/host/gate.d.ts.map +1 -0
  98. package/lib/types/host/git-runner.d.ts +80 -0
  99. package/lib/types/host/git-runner.d.ts.map +1 -0
  100. package/lib/types/host/git-service.d.ts +130 -0
  101. package/lib/types/host/git-service.d.ts.map +1 -0
  102. package/lib/types/host/loopback.d.ts +25 -0
  103. package/lib/types/host/loopback.d.ts.map +1 -0
  104. package/lib/types/host/poll-guard.d.ts +55 -0
  105. package/lib/types/host/poll-guard.d.ts.map +1 -0
  106. package/lib/types/host/routes.d.ts +54 -0
  107. package/lib/types/host/routes.d.ts.map +1 -0
  108. package/lib/types/index.d.ts +35 -0
  109. package/lib/types/index.d.ts.map +1 -0
  110. package/lib/types/mount-once.d.ts +25 -0
  111. package/lib/types/mount-once.d.ts.map +1 -0
  112. package/package.json +85 -0
  113. package/src/client/FileManagerSettingsCard.tsx +117 -0
  114. package/src/client/PluginSettingsCard.tsx +337 -0
  115. package/src/client/api.ts +141 -0
  116. package/src/client/chat/file-ref.ts +144 -0
  117. package/src/client/chat/mermaid-chat.tsx +102 -0
  118. package/src/client/chat/placeholder-hint.tsx +46 -0
  119. package/src/client/components/ExplorerPanel.tsx +534 -0
  120. package/src/client/components/FileIcon.tsx +46 -0
  121. package/src/client/components/ScmPanel.tsx +480 -0
  122. package/src/client/components/a11y.ts +20 -0
  123. package/src/client/components/icons.tsx +274 -0
  124. package/src/client/components/overlay.tsx +225 -0
  125. package/src/client/css-modules.d.ts +5 -0
  126. package/src/client/drag/DragFileInlay.tsx +89 -0
  127. package/src/client/drag/file-drag.ts +67 -0
  128. package/src/client/drag.ts +154 -0
  129. package/src/client/file-source.ts +58 -0
  130. package/src/client/fileType.ts +129 -0
  131. package/src/client/floating.ts +63 -0
  132. package/src/client/hooks/useResizableSplit.ts +91 -0
  133. package/src/client/hooks/useStore.ts +15 -0
  134. package/src/client/index.ts +389 -0
  135. package/src/client/layout.ts +546 -0
  136. package/src/client/locales.ts +247 -0
  137. package/src/client/maximize.ts +34 -0
  138. package/src/client/mount.tsx +74 -0
  139. package/src/client/persist.ts +194 -0
  140. package/src/client/picker/FilePickerModal.tsx +506 -0
  141. package/src/client/picker/file-picker.ts +86 -0
  142. package/src/client/preview/PreviewPanel.tsx +233 -0
  143. package/src/client/preview/PreviewTabs.tsx +159 -0
  144. package/src/client/preview/PreviewToolbar.tsx +160 -0
  145. package/src/client/preview/content.tsx +617 -0
  146. package/src/client/preview/markdown.ts +351 -0
  147. package/src/client/preview/mermaid.ts +274 -0
  148. package/src/client/preview/selection.ts +82 -0
  149. package/src/client/reference.ts +161 -0
  150. package/src/client/settings-card.module.css +316 -0
  151. package/src/client/settings-form.ts +451 -0
  152. package/src/client/store.ts +1290 -0
  153. package/src/client/styles/chip.module.css +38 -0
  154. package/src/client/styles/drag.module.css +30 -0
  155. package/src/client/styles/explorer.module.css +371 -0
  156. package/src/client/styles/picker.module.css +344 -0
  157. package/src/client/styles/preview.module.css +904 -0
  158. package/src/client/styles/scm.module.css +380 -0
  159. package/src/client/styles/tokens.module.css +480 -0
  160. package/src/core/types.ts +162 -0
  161. package/src/host/fs-service.ts +513 -0
  162. package/src/host/gate.ts +74 -0
  163. package/src/host/git-runner.ts +116 -0
  164. package/src/host/git-service.ts +395 -0
  165. package/src/host/loopback.ts +63 -0
  166. package/src/host/poll-guard.ts +109 -0
  167. package/src/host/routes.ts +695 -0
  168. package/src/index.ts +97 -0
  169. package/src/mount-once.ts +48 -0
@@ -0,0 +1,116 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/git-runner.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Shared host git subprocess plumbing: the run result shape, the runner seam,
4
+ * the collected-output cap, and the production runner over the subprocess
5
+ * service. Packages receive this file as a generated copy via
6
+ * scripts/sync-shared.mjs; edit the shared source and re-run the sync instead
7
+ * of editing a copy.
8
+ *
9
+ * The context shape is declared structurally so this module stays
10
+ * self-contained (shared/ has no cordis dependency): any context whose
11
+ * `subprocess` satisfies SubprocessServiceLike works, which the plugin
12
+ * contexts do.
13
+ * @module dsh-web-ui-shared/host/git-runner
14
+ */
15
+
16
+ /** One finished git invocation. */
17
+ export interface GitRunResult {
18
+ exitCode: number | null
19
+ stdout: string
20
+ stderr: string
21
+ }
22
+
23
+ /** The spawn seam the service runs git through (subprocess service in production). */
24
+ export interface GitRunner {
25
+ run(argv: readonly string[], cwd: string): Promise<GitRunResult>
26
+ }
27
+
28
+ /** Collected-output cap for one git command. */
29
+ export const OUTPUT_CAP_BYTES = 1 << 20
30
+
31
+ /** The subprocess service surface this runner consumes (structural). */
32
+ export interface SubprocessServiceLike {
33
+ spawn(spec: {
34
+ argv: readonly string[]
35
+ cwd: string
36
+ stdio: {
37
+ stdin: 'ignore'
38
+ stdout: { maxBytes: number }
39
+ stderr: { maxBytes: number }
40
+ }
41
+ graceMs: number
42
+ }): {
43
+ done: Promise<{ exitCode: number | null }>
44
+ collected: {
45
+ stdout?: { readFrom(offset: number): { text: string } }
46
+ stderr?: { readFrom(offset: number): { text: string } }
47
+ }
48
+ }
49
+ }
50
+
51
+ /** Per-package knobs for the shared production runner. */
52
+ export interface GitRunnerOptions {
53
+ /** Build the full spawn argv from the git args (default ['git', ...argv]). */
54
+ spawnArgv?: (argv: readonly string[]) => readonly string[]
55
+ /** degrade turns spawn/run failures into exitCode 127 results instead of throwing. */
56
+ failureMode?: 'throw' | 'degrade'
57
+ /** console.error tag for degrade mode. */
58
+ errorTag?: string
59
+ }
60
+
61
+ /**
62
+ * Production runner over the subprocess service: one managed child per
63
+ * command, bounded collect on both streams. Degrade mode keeps the SCM tab
64
+ * showing the friendly "not a git repository" state instead of a bare 400
65
+ * when git is missing or the subprocess service fails.
66
+ * @param ctx - context carrying the subprocess service.
67
+ * @param options - per-package behavior knobs.
68
+ * @returns the runner.
69
+ */
70
+ export function subprocessRunner(ctx: { subprocess: SubprocessServiceLike }, options: GitRunnerOptions = {}): GitRunner {
71
+ const spawnArgv = options.spawnArgv ?? ((argv) => ['git', ...argv])
72
+ const degrade = options.failureMode === 'degrade'
73
+ const errorTag = options.errorTag ?? 'git'
74
+ const failure = (prefix: string, error: unknown): GitRunResult => ({
75
+ exitCode: 127,
76
+ stdout: '',
77
+ stderr: prefix + (error instanceof Error ? error.message : String(error)),
78
+ })
79
+ return {
80
+ async run(argv, cwd) {
81
+ const spec = {
82
+ argv: spawnArgv(argv),
83
+ cwd,
84
+ stdio: {
85
+ stdin: 'ignore' as const,
86
+ stdout: { maxBytes: OUTPUT_CAP_BYTES },
87
+ stderr: { maxBytes: OUTPUT_CAP_BYTES },
88
+ },
89
+ graceMs: 10_000,
90
+ }
91
+ if (degrade) {
92
+ let handle
93
+ try {
94
+ handle = ctx.subprocess.spawn(spec)
95
+ } catch (error) {
96
+ console.error('[' + errorTag + '] git spawn failed:', error)
97
+ return failure('git: spawn failed: ', error)
98
+ }
99
+ try {
100
+ const outcome = await handle.done
101
+ const stdout = handle.collected.stdout?.readFrom(0).text ?? ''
102
+ const stderr = handle.collected.stderr?.readFrom(0).text ?? ''
103
+ return { exitCode: outcome.exitCode, stdout, stderr }
104
+ } catch (error) {
105
+ console.error('[' + errorTag + '] git run failed:', error)
106
+ return failure('git: run failed: ', error)
107
+ }
108
+ }
109
+ const handle = ctx.subprocess.spawn(spec)
110
+ const outcome = await handle.done
111
+ const stdout = handle.collected.stdout?.readFrom(0).text ?? ''
112
+ const stderr = handle.collected.stderr?.readFrom(0).text ?? ''
113
+ return { exitCode: outcome.exitCode, stdout, stderr }
114
+ },
115
+ }
116
+ }
@@ -0,0 +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
+ }
@@ -0,0 +1,63 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/loopback.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Loopback trust fence shared by the host route families: socket address,
4
+ * Host header, and browser same-origin markers. Packages receive this file as
5
+ * a generated copy via scripts/sync-shared.mjs; edit the shared source and
6
+ * re-run the sync instead of editing a copy.
7
+ *
8
+ * Semantics: RFC 5735 IPv4 127/8, ::1, IPv4-mapped ::ffff:127/8 (matching the
9
+ * remote-web-ui gate), localhost hostnames, plus the browser same-origin
10
+ * markers (sec-fetch-site and Origin) for the request-level fence.
11
+ * @module dsh-web-ui-shared/host/loopback
12
+ */
13
+
14
+ import type { IncomingMessage } from 'node:http'
15
+
16
+ /** IPv4 127/8 predicate (four decimal octets, first == 127). */
17
+ export function isIPv4Loopback(v4: string): boolean {
18
+ const parts = v4.split('.')
19
+ return parts.length === 4
20
+ && parts[0] === '127'
21
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
22
+ }
23
+
24
+ /** Whether a socket remote address names the loopback range (127/8, ::1, IPv4-mapped). */
25
+ export function isLoopbackAddress(address: string | undefined): boolean {
26
+ if (address === undefined) return false
27
+ const normalized = address.toLowerCase()
28
+ if (normalized === '::1') return true
29
+ if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
30
+ return isIPv4Loopback(normalized)
31
+ }
32
+
33
+ /** Whether a normalized URL hostname names the loopback authority (localhost, [::1], 127/8). */
34
+ export function isLoopbackHostname(hostname: string): boolean {
35
+ if (hostname === 'localhost' || hostname === '[::1]') return true
36
+ return isIPv4Loopback(hostname)
37
+ }
38
+
39
+ /**
40
+ * Request-level trust fence: a loopback socket address AND a loopback Host
41
+ * header, plus browser same-origin markers. The socket address is
42
+ * authoritative; X-Forwarded-For is never trusted.
43
+ */
44
+ export function isLoopbackRequest(request: IncomingMessage): boolean {
45
+ if (!isLoopbackAddress(request.socket.remoteAddress)) return false
46
+ const host = request.headers.host
47
+ if (typeof host !== 'string') return false
48
+ let hostUrl: URL
49
+ try {
50
+ hostUrl = new URL('http://' + host)
51
+ } catch {
52
+ return false
53
+ }
54
+ if (!isLoopbackHostname(hostUrl.hostname)) return false
55
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
56
+ const origin = request.headers.origin
57
+ if (origin === undefined) return true
58
+ try {
59
+ return new URL(origin).host === hostUrl.host
60
+ } catch {
61
+ return false
62
+ }
63
+ }
@@ -0,0 +1,109 @@
1
+ // Generated by scripts/sync-shared.mjs from shared/host/poll-guard.ts. Do not edit this copy; edit the shared source and run "node scripts/sync-shared.mjs".
2
+ /**
3
+ * Poll-loop guard shared by the plugin family's Host halves (git-graph,
4
+ * filemgr): one bounded refresh loop that never overlaps itself,
5
+ * backs off on consecutive failures, and stops at a global deadline. Pure
6
+ * logic with injectable timers, so consumers test it without wall clocks.
7
+ */
8
+
9
+ /** Timer injection seam; defaults to the global setTimeout/clearTimeout pair. */
10
+ export interface PollTimers {
11
+ /** Schedule fn(ms). */
12
+ set: (fn: () => void, ms: number) => unknown
13
+ /** Cancel a scheduled handle. */
14
+ clear: (handle: unknown) => void
15
+ }
16
+
17
+ const DEFAULT_TIMERS: PollTimers = {
18
+ set: (fn, ms) => setTimeout(fn, ms),
19
+ clear: (handle) => { clearTimeout(handle as ReturnType<typeof setTimeout>) },
20
+ }
21
+
22
+ export interface PollGuardOptions {
23
+ /** Base interval between runs. */
24
+ intervalMs: number
25
+ /** Runs stop entirely once this much wall time passed since start(). */
26
+ deadlineMs: number
27
+ /** Cap for the failure backoff; each consecutive failure doubles the delay up to this. */
28
+ maxBackoffMs: number
29
+ /** Optional timer seam for tests. */
30
+ timers?: PollTimers
31
+ /** Invoked once when the deadline fires; no run starts afterwards. */
32
+ onDeadline?: () => void
33
+ /** Invoked after a run settles with the consecutive-failure count. */
34
+ onSettled?: (consecutiveFailures: number) => void
35
+ /** The task each tick runs; a rejection counts as a failure. */
36
+ onRun: () => Promise<void>
37
+ }
38
+
39
+ /**
40
+ * Owns one bounded poll loop.
41
+ *
42
+ * Guarantees: at most one task runs at a time (a scheduled tick whose turn
43
+ * arrives while a run is in flight is dropped); consecutive failures double
44
+ * the delay up to maxBackoffMs and reset on the first success; the loop
45
+ * stops forever at deadlineMs and cancels its timer.
46
+ */
47
+ export class PollGuard {
48
+ private readonly options: Required<PollGuardOptions>
49
+ private handle: unknown
50
+ private running = false
51
+ private startedAt = 0
52
+ private stopped = false
53
+ private failures = 0
54
+
55
+ /** @param options - loop bounds; interval/deadline/backoff/onRun are required, the rest optional. */
56
+ constructor(options: PollGuardOptions) {
57
+ this.options = {
58
+ timers: DEFAULT_TIMERS,
59
+ onDeadline: () => {},
60
+ onSettled: () => {},
61
+ ...options,
62
+ }
63
+ }
64
+
65
+ /** Start the loop. Safe to call once; later calls are ignored. */
66
+ start(): void {
67
+ if (this.startedAt !== 0) return
68
+ this.startedAt = Date.now()
69
+ this.schedule(this.options.intervalMs)
70
+ }
71
+
72
+ /** Stop the loop permanently and drop any pending tick. */
73
+ stop(): void {
74
+ this.stopped = true
75
+ this.options.timers.clear(this.handle)
76
+ this.handle = undefined
77
+ }
78
+
79
+ private schedule(delayMs: number): void {
80
+ if (this.stopped) return
81
+ this.handle = this.options.timers.set(() => { void this.tick() }, delayMs)
82
+ }
83
+
84
+ private delay(): number {
85
+ const backoff = this.options.intervalMs * 2 ** Math.min(this.failures, 8)
86
+ return Math.min(backoff, this.options.maxBackoffMs)
87
+ }
88
+
89
+ private async tick(): Promise<void> {
90
+ if (this.stopped) return
91
+ if (this.running) return // Anti-overlap: drop ticks that arrive mid-run.
92
+ if (Date.now() - this.startedAt >= this.options.deadlineMs) {
93
+ this.stopped = true
94
+ this.options.onDeadline()
95
+ return
96
+ }
97
+ this.running = true
98
+ try {
99
+ await this.options.onRun()
100
+ this.failures = 0
101
+ } catch {
102
+ this.failures += 1
103
+ } finally {
104
+ this.running = false
105
+ this.options.onSettled(this.failures)
106
+ this.schedule(this.delay())
107
+ }
108
+ }
109
+ }