dsh-remote-vs 0.0.0-stage → 0.8.3

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 (200) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +385 -0
  3. package/README.md +347 -2
  4. package/cordis.patch.yml +259 -0
  5. package/icon.svg +7 -0
  6. package/lib/client.js +62602 -0
  7. package/lib/index.js +4287 -0
  8. package/lib/p2-bridge-refs.js +10 -0
  9. package/lib/p2-bridge.js +16 -0
  10. package/lib/p2-router-fs.js +252 -0
  11. package/lib/p2-router-refs.js +466 -0
  12. package/lib/p2-router-spill.js +62 -0
  13. package/lib/p2-router-subprocess.js +64 -0
  14. package/lib/p2-shell-bash.js +24 -0
  15. package/lib/ssh-proc-arI5lm4W.js +632 -0
  16. package/lib/types/changes/recorder.d.ts +240 -0
  17. package/lib/types/changes/status.d.ts +201 -0
  18. package/lib/types/changes/text-diff.d.ts +65 -0
  19. package/lib/types/client/CodeEditor.d.ts +125 -0
  20. package/lib/types/client/Dialog.d.ts +14 -0
  21. package/lib/types/client/DirectoryFlow.d.ts +30 -0
  22. package/lib/types/client/EditorPane.d.ts +30 -0
  23. package/lib/types/client/HostsView.d.ts +10 -0
  24. package/lib/types/client/NativeExplorer.d.ts +39 -0
  25. package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
  26. package/lib/types/client/RemoteEditorView.d.ts +20 -0
  27. package/lib/types/client/RemoteExplorer.d.ts +18 -0
  28. package/lib/types/client/RemoteRoot.d.ts +16 -0
  29. package/lib/types/client/SearchView.d.ts +14 -0
  30. package/lib/types/client/SettingsHosts.d.ts +15 -0
  31. package/lib/types/client/TerminalView.d.ts +16 -0
  32. package/lib/types/client/WorkspaceWizard.d.ts +16 -0
  33. package/lib/types/client/WorkspacesView.d.ts +14 -0
  34. package/lib/types/client/XTerm.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +162 -0
  36. package/lib/types/client/changes/ChangesDock.d.ts +35 -0
  37. package/lib/types/client/changes/model.d.ts +36 -0
  38. package/lib/types/client/changes/undo-affordance.d.ts +48 -0
  39. package/lib/types/client/changes/undo-context.d.ts +25 -0
  40. package/lib/types/client/composer.d.ts +165 -0
  41. package/lib/types/client/i18n.d.ts +17 -0
  42. package/lib/types/client/index.d.ts +14 -0
  43. package/lib/types/client/remote-file-open-target.d.ts +247 -0
  44. package/lib/types/client/remote-file-open.d.ts +35 -0
  45. package/lib/types/client/reveal.d.ts +15 -0
  46. package/lib/types/client/scm/DiffView.d.ts +24 -0
  47. package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
  48. package/lib/types/client/scm/RepoPicker.d.ts +24 -0
  49. package/lib/types/client/scm/ScmPanel.d.ts +15 -0
  50. package/lib/types/client/scm/actions.d.ts +170 -0
  51. package/lib/types/client/scm/diff-model.d.ts +29 -0
  52. package/lib/types/client/scm/stage-queue.d.ts +99 -0
  53. package/lib/types/client/scm/status-map.d.ts +157 -0
  54. package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
  55. package/lib/types/client/selection-popup.d.ts +61 -0
  56. package/lib/types/client/store.d.ts +437 -0
  57. package/lib/types/client/theme.d.ts +15 -0
  58. package/lib/types/client/ui.d.ts +46 -0
  59. package/lib/types/client/upload-plan.d.ts +78 -0
  60. package/lib/types/client/upload-source.d.ts +46 -0
  61. package/lib/types/client/use-remote-chat-target.d.ts +17 -0
  62. package/lib/types/client/vsc/FindWidget.d.ts +81 -0
  63. package/lib/types/client/vsc/change-marks.d.ts +150 -0
  64. package/lib/types/client/vsc/editor-langs.d.ts +5 -0
  65. package/lib/types/client/vsc/editor-palette.d.ts +37 -0
  66. package/lib/types/client/vsc/editor-theme.d.ts +32 -0
  67. package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
  68. package/lib/types/client/vsc/kit.d.ts +195 -0
  69. package/lib/types/client/vsc/markdown.d.ts +11 -0
  70. package/lib/types/client/vsc/reveal-line.d.ts +38 -0
  71. package/lib/types/client/vsc/tokenize.d.ts +104 -0
  72. package/lib/types/deploy.d.ts +94 -0
  73. package/lib/types/fs-ops.d.ts +8 -0
  74. package/lib/types/git-format.d.ts +34 -0
  75. package/lib/types/git-ops.d.ts +183 -0
  76. package/lib/types/http.d.ts +14 -0
  77. package/lib/types/importers.d.ts +20 -0
  78. package/lib/types/index.d.ts +87 -0
  79. package/lib/types/keygen.d.ts +19 -0
  80. package/lib/types/loopback.d.ts +7 -0
  81. package/lib/types/p2/bridge-refs.d.ts +21 -0
  82. package/lib/types/p2/bridge.d.ts +24 -0
  83. package/lib/types/p2/file-refs.d.ts +73 -0
  84. package/lib/types/p2/helper-rpc.d.ts +63 -0
  85. package/lib/types/p2/in-flight-gate.d.ts +25 -0
  86. package/lib/types/p2/router-fs.d.ts +92 -0
  87. package/lib/types/p2/router-refs.d.ts +45 -0
  88. package/lib/types/p2/router-spill.d.ts +11 -0
  89. package/lib/types/p2/router-subprocess.d.ts +23 -0
  90. package/lib/types/p2/shell-bash.d.ts +16 -0
  91. package/lib/types/p2/ssh-proc.d.ts +22 -0
  92. package/lib/types/p2/worlds.d.ts +179 -0
  93. package/lib/types/paths.d.ts +12 -0
  94. package/lib/types/pool.d.ts +105 -0
  95. package/lib/types/protocol.d.ts +452 -0
  96. package/lib/types/routes.d.ts +27 -0
  97. package/lib/types/search-ops.d.ts +68 -0
  98. package/lib/types/sftp.d.ts +90 -0
  99. package/lib/types/store.d.ts +66 -0
  100. package/lib/types/terminal.d.ts +51 -0
  101. package/lib/types/timeouts.d.ts +83 -0
  102. package/lib/types/tools.d.ts +41 -0
  103. package/lib/worlds-PUumRIh7.js +18765 -0
  104. package/locale/en.json +6 -0
  105. package/locale/zh.json +6 -0
  106. package/package.json +189 -4
  107. package/src/changes/recorder.ts +902 -0
  108. package/src/changes/status.ts +448 -0
  109. package/src/changes/text-diff.ts +377 -0
  110. package/src/client/CodeEditor.tsx +923 -0
  111. package/src/client/Dialog.tsx +76 -0
  112. package/src/client/DirectoryFlow.tsx +431 -0
  113. package/src/client/EditorPane.tsx +512 -0
  114. package/src/client/HostsView.tsx +312 -0
  115. package/src/client/NativeExplorer.tsx +121 -0
  116. package/src/client/RemoteDirectoryPicker.tsx +157 -0
  117. package/src/client/RemoteEditorView.tsx +54 -0
  118. package/src/client/RemoteExplorer.tsx +1398 -0
  119. package/src/client/RemoteRoot.tsx +389 -0
  120. package/src/client/SearchView.tsx +407 -0
  121. package/src/client/SettingsHosts.tsx +136 -0
  122. package/src/client/TerminalView.tsx +308 -0
  123. package/src/client/WorkspaceWizard.tsx +199 -0
  124. package/src/client/WorkspacesView.tsx +93 -0
  125. package/src/client/XTerm.tsx +308 -0
  126. package/src/client/api.ts +331 -0
  127. package/src/client/changes/ChangesDock.tsx +348 -0
  128. package/src/client/changes/changes.css +230 -0
  129. package/src/client/changes/model.ts +347 -0
  130. package/src/client/changes/undo-affordance.ts +389 -0
  131. package/src/client/changes/undo-context.ts +34 -0
  132. package/src/client/composer.ts +436 -0
  133. package/src/client/css.d.ts +7 -0
  134. package/src/client/i18n.ts +702 -0
  135. package/src/client/index.tsx +286 -0
  136. package/src/client/remote-file-open-target.ts +553 -0
  137. package/src/client/remote-file-open.ts +261 -0
  138. package/src/client/reveal.ts +33 -0
  139. package/src/client/scm/DiffView.tsx +570 -0
  140. package/src/client/scm/HistoryPanel.tsx +353 -0
  141. package/src/client/scm/RepoPicker.tsx +238 -0
  142. package/src/client/scm/ScmPanel.tsx +857 -0
  143. package/src/client/scm/actions.ts +460 -0
  144. package/src/client/scm/diff-model.ts +206 -0
  145. package/src/client/scm/stage-queue.ts +264 -0
  146. package/src/client/scm/status-map.ts +381 -0
  147. package/src/client/scm/untracked-dirs.ts +139 -0
  148. package/src/client/selection-popup.ts +157 -0
  149. package/src/client/store.tsx +1670 -0
  150. package/src/client/theme.ts +54 -0
  151. package/src/client/ui.tsx +136 -0
  152. package/src/client/upload-plan.ts +153 -0
  153. package/src/client/upload-source.ts +145 -0
  154. package/src/client/use-remote-chat-target.ts +153 -0
  155. package/src/client/vsc/FindWidget.tsx +446 -0
  156. package/src/client/vsc/change-marks.ts +586 -0
  157. package/src/client/vsc/editor-langs.ts +137 -0
  158. package/src/client/vsc/editor-palette.ts +107 -0
  159. package/src/client/vsc/editor-theme.ts +93 -0
  160. package/src/client/vsc/fallback-highlight.ts +145 -0
  161. package/src/client/vsc/kit.tsx +777 -0
  162. package/src/client/vsc/markdown.tsx +172 -0
  163. package/src/client/vsc/reveal-line.ts +142 -0
  164. package/src/client/vsc/tokenize.ts +569 -0
  165. package/src/client/vsc/vsc.css +2669 -0
  166. package/src/deploy.ts +481 -0
  167. package/src/fs-ops.ts +57 -0
  168. package/src/git-format.ts +205 -0
  169. package/src/git-ops.ts +693 -0
  170. package/src/http.ts +55 -0
  171. package/src/importers.ts +141 -0
  172. package/src/index.ts +442 -0
  173. package/src/keygen.ts +77 -0
  174. package/src/loopback.ts +47 -0
  175. package/src/p2/bridge-refs.ts +27 -0
  176. package/src/p2/bridge.ts +32 -0
  177. package/src/p2/file-refs.ts +392 -0
  178. package/src/p2/helper-rpc.ts +394 -0
  179. package/src/p2/in-flight-gate.ts +63 -0
  180. package/src/p2/router-fs.ts +327 -0
  181. package/src/p2/router-refs.ts +166 -0
  182. package/src/p2/router-spill.ts +75 -0
  183. package/src/p2/router-subprocess.ts +92 -0
  184. package/src/p2/shell-bash.ts +30 -0
  185. package/src/p2/ssh-proc.ts +802 -0
  186. package/src/p2/worlds.ts +735 -0
  187. package/src/paths.ts +50 -0
  188. package/src/pool.ts +811 -0
  189. package/src/protocol.ts +460 -0
  190. package/src/routes.ts +802 -0
  191. package/src/search-ops.ts +220 -0
  192. package/src/sftp.ts +677 -0
  193. package/src/store.ts +364 -0
  194. package/src/terminal.ts +291 -0
  195. package/src/timeouts.ts +122 -0
  196. package/src/tools.ts +320 -0
  197. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
  198. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
  199. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
  200. package/vendor/dsh-remote-helpers/glibc-ceiling.patch +31 -0
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Stage / unstage coordinator: instant local feedback, ONE cheap verification.
3
+ *
4
+ * The reported problem: pressing `+` on a single file in a large repository
5
+ * (AOSP monorepo) fired FOUR repository-wide git commands — the `git add`
6
+ * itself, a full `git status` (through the panel's `refreshAll` →
7
+ * `store.afterWrite()`), and the three extras loaders (stash list, branches,
8
+ * remotes) — so one click cost minutes and locked the panel behind `gitBusy`.
9
+ * Nothing about staging one file needs any of them.
10
+ *
11
+ * What happens instead, per click:
12
+ * 1. the row moves between "changes" and "staged changes" IMMEDIATELY, in the
13
+ * local status model (`sink.apply`), before anything leaves the browser;
14
+ * 2. the real `git add` / `git restore --staged` goes out, serialized per
15
+ * repository so two clicks can never reorder each other;
16
+ * 3. clicks landing inside one window (250 ms) coalesce into ONE path-limited
17
+ * `git status -- …` probe, whose cost tracks the touched paths rather than
18
+ * the repository;
19
+ * 4. a path the probe contradicts is put back to what the repository actually
20
+ * says — and the user is told. An optimistic update may be fast; it may
21
+ * never quietly lie.
22
+ *
23
+ * No React, no fetch: the git verbs and the two local effects are injected, so
24
+ * the whole policy is drivable by `scripts/check-scm-stage.mjs` with a stubbed
25
+ * port and a fake clock.
26
+ */
27
+ import { parseStatusDetail } from '../../git-ops.ts'
28
+ import { stagedStateOf } from './status-map.ts'
29
+
30
+ /** The repository a stage/unstage acts on. */
31
+ export interface ScmTarget {
32
+ /** Host alias. */
33
+ alias: string
34
+ /** Absolute remote repository root (git runs here). */
35
+ root: string
36
+ }
37
+
38
+ /** The git verbs the coordinator drives. */
39
+ export interface StagePort {
40
+ /** `git add -A -- <paths>`. */
41
+ stage: (target: ScmTarget, paths: readonly string[]) => Promise<void>
42
+ /** `git restore --staged -- <paths>`. */
43
+ unstage: (target: ScmTarget, paths: readonly string[]) => Promise<void>
44
+ /** Raw `-z` stdout of the path-limited status (`probeStatusPaths`). */
45
+ probe: (target: ScmTarget, paths: readonly string[]) => Promise<string>
46
+ }
47
+
48
+ /** A verification outcome the user has to know about. */
49
+ export interface StageNotice {
50
+ /**
51
+ * `mismatch`: the repository disagreed with the local move, which was then
52
+ * corrected. `unverified`: the check itself could not run, so the local state
53
+ * stands unconfirmed.
54
+ */
55
+ kind: 'mismatch' | 'unverified'
56
+ /** Repo-relative paths the notice is about, in click order. */
57
+ paths: readonly string[]
58
+ }
59
+
60
+ /** The two local effects: the status model, and the user. */
61
+ export interface StageSink {
62
+ /** Move paths between the index and worktree halves of the local model. */
63
+ apply: (paths: readonly string[], staged: boolean) => void
64
+ /**
65
+ * Report one verification outcome.
66
+ *
67
+ * A structured notice rather than a sentence: the wording belongs to the
68
+ * caller (its own i18n keys, which have to be literal `t()` calls in the
69
+ * source for the i18n check to see them), while this module stays free of
70
+ * React, fetch and copy.
71
+ */
72
+ report: (notice: StageNotice) => void
73
+ }
74
+
75
+ /** Timer injection, so a check can drive the debounce deterministically. */
76
+ export interface StageClock {
77
+ set: (fn: () => void, ms: number) => unknown
78
+ clear: (handle: unknown) => void
79
+ }
80
+
81
+ /** The debounce window: how long clicks keep merging into one probe. */
82
+ export const DEFAULT_STAGE_WINDOW_MS = 250
83
+
84
+ /** One pending click. */
85
+ interface Job {
86
+ target: ScmTarget
87
+ paths: readonly string[]
88
+ staged: boolean
89
+ /** The real verb, already in flight (ordered per repository). */
90
+ write: Promise<void>
91
+ resolve: () => void
92
+ reject: (error: unknown) => void
93
+ }
94
+
95
+ /** Repository identity for grouping (alias + root, one line each). */
96
+ function targetKey(target: ScmTarget): string {
97
+ return `${target.alias}\n${target.root}`
98
+ }
99
+
100
+ /**
101
+ * Coordinates optimistic stage/unstage with a debounced, path-limited check.
102
+ *
103
+ * One instance per client: the panel's clicks, the store's status model and the
104
+ * user notifications all meet here.
105
+ */
106
+ export class StageCoordinator {
107
+ private readonly port: StagePort
108
+ private readonly sink: StageSink
109
+ private readonly clock: StageClock
110
+ private readonly windowMs: number
111
+ /** Clicks waiting for the current window to close. */
112
+ private pending: Job[] = []
113
+ private timer: unknown
114
+ /** Per-repository write chain: the second click waits for the first. */
115
+ private readonly chains = new Map<string, Promise<void>>()
116
+
117
+ constructor(
118
+ port: StagePort,
119
+ sink: StageSink,
120
+ options: { clock?: StageClock; windowMs?: number } = {},
121
+ ) {
122
+ this.port = port
123
+ this.sink = sink
124
+ this.clock = options.clock ?? {
125
+ set: (fn, ms) => setTimeout(fn, ms),
126
+ clear: handle => { clearTimeout(handle as ReturnType<typeof setTimeout>) },
127
+ }
128
+ this.windowMs = options.windowMs ?? DEFAULT_STAGE_WINDOW_MS
129
+ }
130
+
131
+ /**
132
+ * Stage or unstage paths: local first, real verb in flight, verification once
133
+ * the window closes.
134
+ *
135
+ * @param target - repository the paths belong to.
136
+ * @param paths - repo-relative paths (a directory row may keep its `/`).
137
+ * @param staged - true to stage, false to unstage.
138
+ * @returns a promise that settles when this click's verification is in:
139
+ * rejected when the git verb itself failed (the caller reports it), and
140
+ * resolved when the verb landed — whether or not the probe agreed (a
141
+ * disagreement is corrected and notified here, not thrown).
142
+ */
143
+ submit(target: ScmTarget, paths: readonly string[], staged: boolean): Promise<void> {
144
+ if (paths.length === 0) return Promise.resolve()
145
+ const list = [...paths]
146
+ // The write is queued BEFORE the local patch so the two orders cannot
147
+ // invert: the chain's first link runs on a microtask, after this frame.
148
+ const write = this.enqueue(target, () => (staged ? this.port.stage : this.port.unstage)(target, list))
149
+ let resolve: () => void = () => {}
150
+ let reject: (error: unknown) => void = () => {}
151
+ const settled = new Promise<void>((done, fail) => {
152
+ resolve = done
153
+ reject = fail
154
+ })
155
+ this.sink.apply(list, staged)
156
+ this.pending.push({ target, paths: list, staged, write, resolve, reject })
157
+ this.arm()
158
+ return settled
159
+ }
160
+
161
+ /**
162
+ * Verify everything submitted so far with ONE path-limited probe per
163
+ * repository. Called by the debounce timer; exposed so a caller (or a check)
164
+ * can force the window closed.
165
+ */
166
+ async flush(): Promise<void> {
167
+ if (this.timer !== undefined) {
168
+ this.clock.clear(this.timer)
169
+ this.timer = undefined
170
+ }
171
+ const batch = this.pending
172
+ this.pending = []
173
+ if (batch.length === 0) return
174
+ // The probe must never run before the writes it verifies have landed: on a
175
+ // slow repository a `git add` can outlast the debounce window by seconds.
176
+ const outcomes = await Promise.all(batch.map(job => job.write.then(() => undefined, (error: unknown) => error)))
177
+ batch.forEach((job, index) => {
178
+ const error = outcomes[index]
179
+ if (error === undefined) return
180
+ // The verb never landed: put the row back and let the caller report it.
181
+ this.sink.apply(job.paths, !job.staged)
182
+ job.reject(error)
183
+ })
184
+ const landed = batch.filter((_, index) => outcomes[index] === undefined)
185
+ if (landed.length > 0) await this.verify(landed)
186
+ for (const job of landed) job.resolve()
187
+ }
188
+
189
+ /** Drop the pending window (page teardown; the writes already went out). */
190
+ dispose(): void {
191
+ if (this.timer !== undefined) {
192
+ this.clock.clear(this.timer)
193
+ this.timer = undefined
194
+ }
195
+ }
196
+
197
+ /** Queue one write behind the previous one for the same repository. */
198
+ private enqueue(target: ScmTarget, run: () => Promise<void>): Promise<void> {
199
+ const key = targetKey(target)
200
+ const previous = this.chains.get(key) ?? Promise.resolve()
201
+ // A failed predecessor must not skip this write, hence the same handler on
202
+ // both sides; the stored chain swallows the failure so the next link runs.
203
+ const next = previous.then(run, run)
204
+ this.chains.set(key, next.then(() => undefined, () => undefined))
205
+ return next
206
+ }
207
+
208
+ /** (Re)start the debounce window. */
209
+ private arm(): void {
210
+ if (this.timer !== undefined) this.clock.clear(this.timer)
211
+ this.timer = this.clock.set(() => {
212
+ this.timer = undefined
213
+ void this.flush()
214
+ }, this.windowMs)
215
+ }
216
+
217
+ /**
218
+ * One probe per repository over every path the window touched, then correct
219
+ * whatever it contradicts.
220
+ */
221
+ private async verify(jobs: readonly Job[]): Promise<void> {
222
+ interface Group {
223
+ target: ScmTarget
224
+ paths: string[]
225
+ /** path → the staged-ness the local model now claims (last click wins). */
226
+ staged: Map<string, boolean>
227
+ }
228
+ const groups = new Map<string, Group>()
229
+ for (const job of jobs) {
230
+ const key = targetKey(job.target)
231
+ const group = groups.get(key) ?? { target: job.target, paths: [], staged: new Map<string, boolean>() }
232
+ for (const path of job.paths) {
233
+ if (!group.paths.includes(path)) group.paths.push(path)
234
+ // Two clicks on one path inside the window keep the LAST intent — that
235
+ // is the state the ordered writes left behind.
236
+ group.staged.set(path, job.staged)
237
+ }
238
+ groups.set(key, group)
239
+ }
240
+
241
+ for (const group of groups.values()) {
242
+ let stdout = ''
243
+ try {
244
+ stdout = await this.port.probe(group.target, group.paths)
245
+ } catch {
246
+ // A failed probe is not a failed operation: the writes landed, so the
247
+ // optimistic state stands. Say so rather than silently trusting it.
248
+ this.sink.report({ kind: 'unverified', paths: group.paths })
249
+ continue
250
+ }
251
+ const detail = parseStatusDetail(stdout)
252
+ const wrong: string[] = []
253
+ for (const path of group.paths) {
254
+ const expected = group.staged.get(path) === true
255
+ if (stagedStateOf(detail, path) !== expected) wrong.push(path)
256
+ }
257
+ if (wrong.length === 0) continue
258
+ // Staged-ness is binary, so the opposite of the optimistic value IS what
259
+ // the repository reports: apply the observation, not a guess.
260
+ for (const path of wrong) this.sink.apply([path], !(group.staged.get(path) === true))
261
+ this.sink.report({ kind: 'mismatch', paths: wrong })
262
+ }
263
+ }
264
+ }
@@ -0,0 +1,381 @@
1
+ /**
2
+ * Pure status model: buckets the detailed porcelain status into the SCM
3
+ * sections and derives the per-path / per-directory marks the file tree
4
+ * paints.
5
+ *
6
+ * Directory marks come from the STATUS PATH SET, never from materialized
7
+ * children: the remote tree is lazy (one SFTP listing per expanded folder),
8
+ * so a collapsed folder holding changes must still light up. Ignored state is
9
+ * inherited downward, because `git status --ignored=matching` reports an
10
+ * ignored directory once, unexpanded.
11
+ */
12
+ import type { GitPathStatus, GitStatusDetail } from '../../protocol.ts'
13
+
14
+ /** One changed path as the SCM panel shows it. */
15
+ export interface ChangeEntry {
16
+ path: string
17
+ /** Single letter (`M`, `A`, `D`, `R`, `C`, `U`, `T`, `?`). */
18
+ status: string
19
+ /** Rename/copy source. */
20
+ oldPath?: string
21
+ /** True when this entry belongs to the staged (index) half. */
22
+ staged: boolean
23
+ }
24
+
25
+ /** The SCM sections: conflicts, changes (unstaged + untracked), staged. */
26
+ export interface StatusBuckets {
27
+ conflicts: ChangeEntry[]
28
+ changes: ChangeEntry[]
29
+ staged: ChangeEntry[]
30
+ untracked: ChangeEntry[]
31
+ ignored: string[]
32
+ /** Badge count: staged + unstaged + untracked (conflicts excluded). */
33
+ total: number
34
+ }
35
+
36
+ /** True for a merge-conflict pair (UU/AA/DD/AU/UA/DU/UD). */
37
+ export function isConflictStatus(x: string, y: string): boolean {
38
+ return x === 'U' || y === 'U' || (x === 'A' && y === 'A') || (x === 'D' && y === 'D')
39
+ }
40
+
41
+ /** The spelling the rest of this module reads (exported for the row projection). */
42
+ const isConflict = isConflictStatus
43
+
44
+ /** The most descriptive letter for a path that has both halves changed. */
45
+ function pickLetter(staged: string, unstaged: string): string {
46
+ const rank = (letter: string): number => {
47
+ switch (letter) {
48
+ case 'D': return 60
49
+ case 'M': return 50
50
+ case 'T': return 45
51
+ case 'A': return 40
52
+ case 'R': return 30
53
+ case 'C': return 20
54
+ default: return 10
55
+ }
56
+ }
57
+ if (staged === '' || staged === ' ') return unstaged
58
+ if (unstaged === '' || unstaged === ' ') return staged
59
+ return rank(unstaged) >= rank(staged) ? unstaged : staged
60
+ }
61
+
62
+ /** Bucket the detailed status into the panel's sections. */
63
+ export function bucketStatus(detail: GitStatusDetail): StatusBuckets {
64
+ const conflicts: ChangeEntry[] = []
65
+ const changes: ChangeEntry[] = []
66
+ const staged: ChangeEntry[] = []
67
+ const untracked: ChangeEntry[] = []
68
+ const stagedByPath = new Map<string, string>()
69
+ const worktreeByPath = new Map<string, string>()
70
+
71
+ for (const entry of detail.entries) {
72
+ const { path, x, y, origPath } = entry
73
+ if (x === '?' && y === '?') {
74
+ const item: ChangeEntry = { path, status: '?', staged: false }
75
+ untracked.push(item)
76
+ changes.push(item)
77
+ continue
78
+ }
79
+ if (isConflict(x, y)) {
80
+ conflicts.push({ path, status: 'U', staged: false, ...(origPath === undefined ? {} : { oldPath: origPath }) })
81
+ continue
82
+ }
83
+ if (x !== ' ' && x !== '?') {
84
+ staged.push({ path, status: x, staged: true, ...(origPath === undefined ? {} : { oldPath: origPath }) })
85
+ stagedByPath.set(path, x)
86
+ }
87
+ if (y !== ' ' && y !== '?') {
88
+ changes.push({ path, status: y, staged: false, ...(origPath === undefined ? {} : { oldPath: origPath }) })
89
+ worktreeByPath.set(path, y)
90
+ }
91
+ }
92
+
93
+ // The panel lists changes with the worktree letter (VS Code semantics); the
94
+ // tree uses the same precedence so both surfaces agree.
95
+ const merged = changes.map(entry => {
96
+ const stagedLetter = stagedByPath.get(entry.path)
97
+ if (stagedLetter === undefined) return entry
98
+ return { ...entry, status: pickLetter(stagedLetter, entry.status) }
99
+ })
100
+
101
+ return {
102
+ conflicts,
103
+ changes: merged,
104
+ staged,
105
+ untracked,
106
+ ignored: detail.ignored,
107
+ // `untracked` is a SUBSET view of `changes` (an untracked row is pushed to
108
+ // both, see above), so adding it double-counted every untracked file: one
109
+ // untracked file made the rail badge read 2 while the list showed 1. The
110
+ // badge counts entries, and every entry is either staged, changed or
111
+ // conflicted — exactly the three sections the panel renders.
112
+ total: staged.length + merged.length + conflicts.length,
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Whether one porcelain record counts as STAGED for the panel.
118
+ *
119
+ * Mirrors `bucketStatus` exactly, because a verification that disagreed with
120
+ * the list would "correct" a change the user can see was applied: an untracked
121
+ * record is not in the index at all, and a conflict pair lives in its own
122
+ * section (never in "staged changes").
123
+ */
124
+ export function isStagedStatus(x: string, y: string): boolean {
125
+ if (x === '?' || y === '?') return false
126
+ if (isConflict(x, y)) return false
127
+ return x !== ' '
128
+ }
129
+
130
+ /**
131
+ * The verb one row's action button issues.
132
+ *
133
+ * The button is the OPPOSITE of the section the row sits in: a row in "staged
134
+ * changes" offers unstage, a row in "changes" offers stage. This exists because
135
+ * the two are trivially confusable — the row already KNOWS which section it is
136
+ * in (`isStaged`), and passing that flag straight through as the verb's target
137
+ * inverts the action, which is silent: the optimistic model has nothing to move
138
+ * (`git add` on an already-staged path, `git restore --staged` on an unstaged
139
+ * one), the probe agrees with the row it never changed, so the click reports
140
+ * neither success nor failure. That is exactly what shipped in 0.8.0 for both
141
+ * directions (stage was a no-op AND unstage was a no-op) while the section-level
142
+ * buttons, which pass a literal, stayed correct.
143
+ *
144
+ * A named verb instead of a boolean, so the call site reads as the action rather
145
+ * than as a state (`applyStage(paths, rowVerb(isStaged))`).
146
+ */
147
+ export function rowVerb(isStaged: boolean): 'stage' | 'unstage' {
148
+ return isStaged ? 'unstage' : 'stage'
149
+ }
150
+
151
+ /**
152
+ * The staged-ness of one path in a PATH-LIMITED status probe
153
+ * (`git status --porcelain -z -uno -- <paths>`).
154
+ *
155
+ * The probe runs with `--untracked-files=no`, so a path that is not in the
156
+ * index has no record at all: absence means "not staged" (measured against
157
+ * real git — untracked → no record, staged → `A `/`M `).
158
+ *
159
+ * A trailing slash is an untracked DIRECTORY row (`-u normal` reports the
160
+ * directory once): `git add -- dir/` puts every file inside into the index and
161
+ * the probe then reports those files individually, never `dir/` itself — so a
162
+ * directory path is answered by prefix (also measured).
163
+ */
164
+ export function stagedStateOf(detail: GitStatusDetail, path: string): boolean {
165
+ if (path.endsWith('/')) {
166
+ return detail.entries.some(entry => entry.path.startsWith(path) && isStagedStatus(entry.x, entry.y))
167
+ }
168
+ const hit = detail.entries.find(entry => entry.path === path || entry.origPath === path)
169
+ return hit === undefined ? false : isStagedStatus(hit.x, hit.y)
170
+ }
171
+
172
+ /**
173
+ * The local (optimistic) projection of one stage/unstage: what the detailed
174
+ * status looks like the moment the user clicks, before the host answers.
175
+ *
176
+ * The transform mirrors what git does to the two halves of a porcelain record
177
+ * (`git add` clears the worktree half, `git restore --staged` clears the index
178
+ * half) and was checked against real git for the cases that are not obvious:
179
+ * - staging an untracked path (`??`) makes it `A `;
180
+ * - unstaging a staged NEW file returns it to `??` (untracked rows are listed
181
+ * under "changes", so the record stays in the model);
182
+ * - unstaging a staged RENAME leaves HEAD's path deleted in the INDEX — the
183
+ * old path becomes `D ` and the new one untracked (measured, not assumed);
184
+ * - staging a conflict resolves it into the index.
185
+ *
186
+ * Pure, so the panel's instant feedback is assertable without a host: the
187
+ * caller decides WHEN to apply it (and when to apply the inverse).
188
+ *
189
+ * @param detail - the status the panel is showing.
190
+ * @param paths - repo-relative paths that were just staged/unstaged.
191
+ * @param staged - true for a stage, false for an unstage.
192
+ * @returns the projected status (the same object when no path matched).
193
+ */
194
+ export function optimisticStageDetail(
195
+ detail: GitStatusDetail,
196
+ paths: readonly string[],
197
+ staged: boolean,
198
+ ): GitStatusDetail {
199
+ if (paths.length === 0) return detail
200
+ const wanted = new Set(paths)
201
+ const entries: GitPathStatus[] = []
202
+ let touched = false
203
+ for (const entry of detail.entries) {
204
+ if (!wanted.has(entry.path)) {
205
+ entries.push(entry)
206
+ continue
207
+ }
208
+ touched = true
209
+ entries.push(...localStageEntry(entry, staged))
210
+ }
211
+ return touched ? { ...detail, entries } : detail
212
+ }
213
+
214
+ /** One record's local projection (a rename unstaged into two records). */
215
+ function localStageEntry(entry: GitPathStatus, staged: boolean): GitPathStatus[] {
216
+ const { path, x, y, origPath } = entry
217
+ const keep = origPath === undefined ? {} : { origPath }
218
+ if (staged) {
219
+ // The index half takes the most descriptive letter it can keep: what is
220
+ // already staged, else what the worktree showed, else a plain add.
221
+ const letter = x !== ' ' && x !== '?' ? x : (y !== ' ' && y !== '?' ? y : 'M')
222
+ return [{ path, x: letter, y: ' ', ...keep }]
223
+ }
224
+ // Unmerged paths: `git restore --staged` refuses them, so the local model
225
+ // shows nothing until the verb reports the failure.
226
+ if (isConflict(x, y)) return [entry]
227
+ // Unstage puts the index half back to HEAD. A path HEAD does not have — an
228
+ // addition, a rename/copy destination, an untracked row — therefore has no
229
+ // index entry left at all and becomes untracked again (measured against real
230
+ // git: unstaging a staged rename leaves the new name untracked and HEAD's old
231
+ // path deleted in the INDEX).
232
+ if (x === '?' || x === 'A' || x === 'R' || x === 'C') {
233
+ const out: GitPathStatus[] = [{ path, x: '?', y: '?' }]
234
+ if (x === 'R' && origPath !== undefined) out.push({ path: origPath, x: 'D', y: ' ' })
235
+ return out
236
+ }
237
+ // HEAD has the path: what is left of the record is the worktree half.
238
+ const worktree = y !== ' ' && y !== '?' ? y : x
239
+ return [{ path, x: ' ', y: worktree, ...keep }]
240
+ }
241
+
242
+ /**
243
+ * Add a path the detailed status does not list yet as a STAGED ADDITION.
244
+ *
245
+ * The panel can stage a file that its own status call never reported: a file
246
+ * inside an expanded untracked DIRECTORY (`-u normal` collapses the directory
247
+ * into one `dir/` row, so the file has no record of its own). Without this the
248
+ * row would simply vanish — staged, yet shown nowhere until the next full
249
+ * refresh. A path that already has a record is left alone.
250
+ *
251
+ * @param detail - the status the panel is showing.
252
+ * @param path - repo-relative path that was just staged.
253
+ * @returns the status with the added record (the same object when it is known).
254
+ */
255
+ export function insertStagedEntry(detail: GitStatusDetail, path: string): GitStatusDetail {
256
+ if (path === '' || detail.entries.some(entry => entry.path === path || entry.origPath === path)) return detail
257
+ return { ...detail, entries: [...detail.entries, { path, x: 'A', y: ' ' }] }
258
+ }
259
+
260
+ /** One local stage/unstage result a full status read may not have seen. */
261
+ export interface StageOverride {
262
+ staged: boolean
263
+ /** Monotonic sequence number of the local change. */
264
+ at: number
265
+ }
266
+
267
+ /**
268
+ * Re-apply the local stage results a FULL status read could not have seen.
269
+ *
270
+ * Skipping the repository-wide refresh after a stage is what makes the click
271
+ * instant, but another verb (a save, a discard, a commit) can still have a full
272
+ * `git status` in flight — and on a large repository that read started minutes
273
+ * ago. Landing it as-is would flip the row the user just staged back into
274
+ * "changes", i.e. the panel would contradict itself for as long as it takes to
275
+ * run the next full read. A read that STARTED before a local change cannot have
276
+ * seen it, so those paths keep the local value; a read that started after them
277
+ * is authoritative and prunes them.
278
+ *
279
+ * @param detail - the freshly read status.
280
+ * @param overrides - repo-relative path → local stage result (one repository).
281
+ * @param startedAt - the sequence number when the read was issued.
282
+ * @returns the detail with the newer local results re-applied.
283
+ */
284
+ export function applyStageOverrides(
285
+ detail: GitStatusDetail,
286
+ overrides: ReadonlyMap<string, StageOverride>,
287
+ startedAt: number,
288
+ ): GitStatusDetail {
289
+ let next = detail
290
+ for (const [path, override] of overrides) {
291
+ if (override.at <= startedAt) continue
292
+ next = optimisticStageDetail(next, [path], override.staged)
293
+ }
294
+ return next
295
+ }
296
+
297
+ /** Per-path marks the tree paints: file letter, directory roll-up, ignored. */
298
+ export interface TreeMarks {
299
+ /** Repo-relative path → single status letter (`M`, `A`, `D`, `U`, `?`). */
300
+ files: Map<string, string>
301
+ /** Repo-relative directory → `M` when any descendant changed. */
302
+ dirs: Map<string, string>
303
+ /** Repo-relative paths (files and directories) git ignores. */
304
+ ignored: Set<string>
305
+ /** Directory prefixes implied by the ignored set, for deep paths. */
306
+ ignoredDirs: Set<string>
307
+ }
308
+
309
+ /** Strip a trailing slash and a leading `./` so status paths match tree paths. */
310
+ function normalize(path: string): string {
311
+ return path.replace(/^\.\//, '').replace(/\/+$/, '')
312
+ }
313
+
314
+ /**
315
+ * Build tree marks from the detailed status.
316
+ *
317
+ * `buckets` may be supplied when the caller has already bucketed the same
318
+ * status. The marks need the same four lists, and recomputing them meant every
319
+ * store publish walked the whole change list twice.
320
+ */
321
+ export function buildTreeMarks(detail: GitStatusDetail, buckets: StatusBuckets = bucketStatus(detail)): TreeMarks {
322
+ const files = new Map<string, string>()
323
+ const dirs = new Map<string, string>()
324
+ const stagedLetters = new Map<string, string>()
325
+ const worktreeLetters = new Map<string, string>()
326
+
327
+ const put = (rawPath: string, letter: string): void => {
328
+ const path = normalize(rawPath)
329
+ if (path === '') return
330
+ // `-u normal` reports an untracked DIRECTORY as one `dir/` record: strip
331
+ // the slash and it becomes a directory mark, not a phantom file.
332
+ if (rawPath.endsWith('/')) dirs.set(path, letter === '?' ? 'M' : letter)
333
+ else files.set(path, letter)
334
+ }
335
+ for (const entry of buckets.staged) stagedLetters.set(normalize(entry.path), entry.status)
336
+ for (const entry of buckets.changes) worktreeLetters.set(normalize(entry.path), entry.status)
337
+ for (const entry of buckets.untracked) put(entry.path, '?')
338
+ for (const entry of buckets.conflicts) put(entry.path, 'U')
339
+
340
+ const all = new Set<string>([...stagedLetters.keys(), ...worktreeLetters.keys()])
341
+ for (const path of all) {
342
+ const stagedLetter = stagedLetters.get(path)
343
+ const worktreeLetter = worktreeLetters.get(path)
344
+ if (files.has(path) || dirs.has(path)) continue
345
+ files.set(path, stagedLetter === undefined
346
+ ? (worktreeLetter as string)
347
+ : worktreeLetter === undefined
348
+ ? stagedLetter
349
+ : pickLetter(stagedLetter, worktreeLetter))
350
+ }
351
+
352
+ // Every directory on the way to a changed path is itself changed.
353
+ const markParents = (path: string): void => {
354
+ const segments = path.split('/')
355
+ let acc = ''
356
+ for (let i = 0; i < segments.length - 1; i++) {
357
+ acc = acc === '' ? (segments[i] as string) : `${acc}/${segments[i]}`
358
+ if (dirs.get(acc) === 'M') continue
359
+ dirs.set(acc, 'M')
360
+ }
361
+ }
362
+ for (const path of [...files.keys()]) markParents(path)
363
+ for (const path of [...dirs.keys()]) markParents(path)
364
+
365
+ const ignored = new Set<string>()
366
+ const ignoredDirs = new Set<string>()
367
+ for (const raw of buckets.ignored) {
368
+ const path = normalize(raw)
369
+ if (path === '') continue
370
+ if (raw.endsWith('/')) ignoredDirs.add(path)
371
+ else ignored.add(path)
372
+ const segments = path.split('/')
373
+ let acc = ''
374
+ for (let i = 0; i < segments.length - 1; i++) {
375
+ acc = acc === '' ? (segments[i] as string) : `${acc}/${segments[i]}`
376
+ ignoredDirs.add(acc)
377
+ }
378
+ }
379
+
380
+ return { files, dirs, ignored, ignoredDirs }
381
+ }