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,1670 @@
1
+ /**
2
+ * Shared explorer state.
3
+ *
4
+ * This is a MODULE-LEVEL singleton rather than a React context on purpose:
5
+ * the plugin has two mount points — the sidebar panel (explorer / search /
6
+ * source control / history / terminal) and the conversation-view editor tab
7
+ * next to 轨迹 — and both must see the same binding, git status and tab strip.
8
+ * Components subscribe through `useExplorer()`, a `useSyncExternalStore` read
9
+ * of an immutable snapshot rebuilt on every mutation, so either surface can
10
+ * repaint the other.
11
+ *
12
+ * Remote work stays lazy: a directory is listed once per expansion, a diff
13
+ * loads both revisions on demand, and git status is one NUL-separated call.
14
+ */
15
+ import { useEffect, useSyncExternalStore } from 'react'
16
+ import type { Context } from '@deepseek-ai/cordis'
17
+ import type {
18
+ DiffModel,
19
+ GitStatusDetail,
20
+ RemoteHostSummary,
21
+ RemoteRoutingStatus,
22
+ RemoteWorkspaceBinding,
23
+ } from '../protocol.ts'
24
+ import { api, RemoteApiError, configureTimeouts, fileReadTimeoutMs, gitTimeoutMs } from './api.ts'
25
+ import { DEFAULT_GIT_TIMEOUT_MS, clientDeadlineFor } from '../timeouts.ts'
26
+ import { normalizeRepoSubdir, repoSubdirOf, repoWorkdir } from '../git-ops.ts'
27
+ import { buildDiffModel } from './scm/diff-model.ts'
28
+ import type { ChangeMark, ChangeSet } from './vsc/change-marks.ts'
29
+ import { changeSetFromHunks } from './changes/model.ts'
30
+ import { undoContext, type UndoContext } from './changes/undo-context.ts'
31
+ import { applyStageOverrides, bucketStatus, buildTreeMarks, insertStagedEntry, optimisticStageDetail, type ChangeEntry, type StageOverride, type StatusBuckets, type TreeMarks } from './scm/status-map.ts'
32
+ import { loadCommitFileDiff, findRepos, loadDiff, loadHead, loadStatusDetail, probeStatusDir, probeStatusPaths, resolveRepoRoot, applyHunk, stagePaths, unstagePaths, type DiffPayload } from './scm/actions.ts'
33
+ import { StageCoordinator } from './scm/stage-queue.ts'
34
+ import { collapseDir, dropExpandedRows, expandDir, pruneDirExpansions, type DirExpansions } from './scm/untracked-dirs.ts'
35
+ import { detectLang, makeT, type Lang } from './i18n.ts'
36
+ import { planWorkspaceFollow, selectionIsSessionBinding, type WorkspaceFollowDecision } from './remote-file-open-target.ts'
37
+ import { revealViewTab } from './reveal.ts'
38
+ import { isHtmlFile, isImageFile, isMarkdownFile, toast } from './vsc/kit.tsx'
39
+
40
+ /** One editor tab (code file, preview or diff). */
41
+ export interface EditorTab {
42
+ id: string
43
+ kind: 'file' | 'diff'
44
+ /** Absolute remote path. */
45
+ fullPath: string
46
+ /** Path relative to the workspace root (what the UI shows). */
47
+ rel: string
48
+ loading: boolean
49
+ error: string | undefined
50
+ /** File payload. */
51
+ text: string
52
+ dirty: boolean
53
+ saving: boolean
54
+ binary: boolean
55
+ truncated: boolean
56
+ image: boolean
57
+ markdown: boolean
58
+ html: boolean
59
+ /** Preview vs source for markdown/html. */
60
+ preview: boolean
61
+ /** Staged side for a diff. */
62
+ staged: boolean
63
+ /** Untracked files diff against an empty left side. */
64
+ untracked: boolean
65
+ /** Set for a commit drill-down: the diff compares `ref^` with `ref`. */
66
+ ref?: string
67
+ /** 1-based line to reveal after load (a content-search hit). */
68
+ line?: number
69
+ /**
70
+ * Bumped on every reveal request. The line number alone cannot re-trigger a
71
+ * jump to the line the caret is already on.
72
+ */
73
+ lineNonce?: number
74
+ /**
75
+ * 「本会话改了什么」的行标记(来自会话改动数据层,见 `vsc/change-marks.ts`)。
76
+ * `undefined` = 这个 tab 根本没有改动数据(编辑器连 gutter 都不会多装一栏)。
77
+ */
78
+ changeSet?: ChangeSet | null
79
+ /** 最近一次跳到的改动点在 `changeSet.hunks` 里的下标(0-based);`<0` = 还没跳过。 */
80
+ activeHunk?: number
81
+ /** 编辑器报告文档已与 `changeSet` 脱钩(用户敲过字),上层该重新拉数据。 */
82
+ changeStale?: boolean
83
+ /** 改动点跳转目标行(1-based)。与 `line` 分开,两条跳转链路互不干扰。 */
84
+ revealHunkLine?: number
85
+ /** 改动点跳转的 nonce,语义同 `lineNonce`。 */
86
+ revealHunkNonce?: number
87
+ /**
88
+ * 重读(`reloadTab`)成功后递增的 nonce。
89
+ *
90
+ * 它存在的唯一理由:`CodeEditor` 的 `initial` **只在挂载时读一次**,所以
91
+ * "远端内容变了" 必须靠**重挂**才能进到编辑器缓冲区(详见 `reloadTab` 的注释)。
92
+ * `EditorPane` 把它拼进 `key`,这里的每次递增就是一次重挂。
93
+ */
94
+ reloadNonce?: number
95
+ diff: DiffPayload | undefined
96
+ model: DiffModel | undefined
97
+ view: 'split' | 'before' | 'after'
98
+ }
99
+
100
+ export interface HeadInfo {
101
+ branch: string
102
+ detached: boolean
103
+ ahead: number
104
+ behind: number
105
+ }
106
+
107
+ interface State {
108
+ ctx: Context | undefined
109
+ ready: boolean
110
+ error: string | undefined
111
+ hosts: RemoteHostSummary[]
112
+ workspaces: RemoteWorkspaceBinding[]
113
+ routing: RemoteRoutingStatus
114
+ workspaceId: string
115
+ status: GitStatusDetail | undefined
116
+ head: HeadInfo | undefined
117
+ repoRoot: string | undefined
118
+ /** Workspace-relative directory git runs in ('' = the workspace root). */
119
+ repoSubdir: string
120
+ repoMissing: boolean
121
+ gitBusy: boolean
122
+ gitError: string | undefined
123
+ /** Effective per-git-command budget (ms), from the Host's config. */
124
+ gitDeadlineMs: number
125
+ tabs: EditorTab[]
126
+ activeTabId: string | undefined
127
+ revision: number
128
+ /** Where the editor pane is mounted ('center' = conversation view). */
129
+ editorSurface: 'center' | 'sidebar'
130
+ /** The active sidebar panel (shared so any surface can switch it). */
131
+ view: RailView
132
+ /** A pending search request raised by the explorer's context menu. */
133
+ searchRequest: SearchRequest | undefined
134
+ /**
135
+ * Expanded untracked DIRECTORY rows (`-u normal` collapses a directory into
136
+ * one row, so the panel asks for its contents on demand — see
137
+ * `scm/untracked-dirs.ts`). Kept in the store, not in the panel: it has to
138
+ * outlive every status the panel adopts, and only the store sees those.
139
+ */
140
+ untrackedDirs: DirExpansions
141
+ }
142
+
143
+ /** The sidebar rail destinations. */
144
+ export type RailView = 'explorer' | 'search' | 'scm' | 'history' | 'editor' | 'terminal' | 'workspaces'
145
+
146
+ /** One "search this folder" request coming from the tree. */
147
+ export interface SearchRequest {
148
+ /** Monotonic id so a repeated request still re-triggers the view. */
149
+ id: number
150
+ mode: 'name' | 'content'
151
+ /** Pre-filled query (usually empty). */
152
+ query: string
153
+ /** Absolute remote directory the search is scoped to. */
154
+ scope: string
155
+ }
156
+
157
+ const WS_KEY = 'dsh-remote-vs.files.ws'
158
+ const VIEW_KEY = 'dsh-remote-vs.view'
159
+ /** Per-workspace repository sub-directory, keyed by workspace id. */
160
+ const REPO_SUBDIR_KEY = 'dsh-remote-vs.repo.subdir'
161
+
162
+ function readWorkspaceId(): string {
163
+ try {
164
+ return localStorage.getItem(WS_KEY) ?? ''
165
+ } catch {
166
+ return ''
167
+ }
168
+ }
169
+
170
+ /** Stored repository sub-directory for one binding ('' = the workspace root). */
171
+ export function readRepoSubdir(workspaceId: string): string {
172
+ try {
173
+ const raw = localStorage.getItem(REPO_SUBDIR_KEY)
174
+ if (raw === null) return ''
175
+ const saved: unknown = JSON.parse(raw)
176
+ if (typeof saved !== 'object' || saved === null) return ''
177
+ const value = (saved as Record<string, unknown>)[workspaceId]
178
+ return typeof value === 'string' ? normalizeRepoSubdir(value) : ''
179
+ } catch {
180
+ return ''
181
+ }
182
+ }
183
+
184
+ function writeRepoSubdir(workspaceId: string, subdir: string): void {
185
+ try {
186
+ const raw = localStorage.getItem(REPO_SUBDIR_KEY)
187
+ const saved: unknown = raw === null ? {} : JSON.parse(raw)
188
+ const map: Record<string, string> = (typeof saved === 'object' && saved !== null)
189
+ ? { ...(saved as Record<string, string>) }
190
+ : {}
191
+ if (subdir === '') delete map[workspaceId]
192
+ else map[workspaceId] = subdir
193
+ localStorage.setItem(REPO_SUBDIR_KEY, JSON.stringify(map))
194
+ } catch {
195
+ // Private mode: the choice simply does not survive a reload.
196
+ }
197
+ }
198
+
199
+ function readStoredView(): RailView {
200
+ try {
201
+ const saved = localStorage.getItem(VIEW_KEY)
202
+ if (saved === 'explorer' || saved === 'search' || saved === 'scm' || saved === 'history'
203
+ || saved === 'editor' || saved === 'terminal' || saved === 'workspaces') {
204
+ return saved
205
+ }
206
+ } catch {
207
+ // Private mode: ignore.
208
+ }
209
+ return 'explorer'
210
+ }
211
+
212
+ const t = makeT(detectLang())
213
+ const lang: Lang = detectLang()
214
+
215
+ const EMPTY_STATUS: GitStatusDetail = { entries: [], ignored: [], truncated: false }
216
+
217
+ // ------------------------------------------------- optimistic stage/unstage
218
+
219
+ /**
220
+ * Local stage/unstage results, for ONE repository, that a full status read may
221
+ * not have seen yet.
222
+ *
223
+ * Skipping the repository-wide refresh after a stage is what makes the click
224
+ * instant, but another verb (a save, a discard, a commit) can still have a full
225
+ * `git status` in flight — and on a large repository that read started minutes
226
+ * ago. Landing it as-is would flip the row the user just staged back into
227
+ * "changes", so `refreshGit` re-applies whatever started after the read did
228
+ * (see `applyStageOverrides`) and prunes the rest.
229
+ */
230
+ let stageOverrides = new Map<string, StageOverride>()
231
+ /** Repository the overrides above belong to (`alias\nroot`); '' = none. */
232
+ let stageOverrideTarget = ''
233
+ /** Monotonic ticket: what a status read started at (see `applyStageOverrides`). */
234
+ let stageSeq = 0
235
+
236
+ /** Repository identity for the override bookkeeping. */
237
+ function stageTargetKey(target: { alias: string; root: string }): string {
238
+ return `${target.alias}\n${target.root}`
239
+ }
240
+
241
+ /**
242
+ * The stage/unstage coordinator (see `scm/stage-queue.ts`).
243
+ *
244
+ * One instance per page: the panel's clicks, this store's status model and the
245
+ * user notifications meet here. Every verb it drives is the panel's own action
246
+ * layer, so nothing about it needs a host-side change.
247
+ */
248
+ const stageCoordinator = new StageCoordinator(
249
+ {
250
+ stage: (target, paths) => stagePaths(target.alias, target.root, paths),
251
+ unstage: (target, paths) => unstagePaths(target.alias, target.root, paths),
252
+ probe: (target, paths) => probeStatusPaths(target.alias, target.root, paths),
253
+ },
254
+ {
255
+ apply: applyLocalStage,
256
+ report: (notice) => {
257
+ // The wording lives HERE, as two literal `t()` calls: the coordinator has
258
+ // no i18n (it is driven by a check with no React), and a key reached
259
+ // through a lookup would look unreferenced to `.spike/i18n-check.mjs`.
260
+ const paths = noticePaths(notice.paths)
261
+ if (notice.kind === 'mismatch') toast(`${t('scm.stageMismatch')} · ${paths}`, true)
262
+ else toast(`${t('scm.stageUnverified')} · ${paths}`, true)
263
+ },
264
+ },
265
+ )
266
+
267
+ /** How many affected paths a notice names before it summarises them. */
268
+ const NOTICE_PATHS = 3
269
+
270
+ /** A short, readable path list for a stage/unstage notice. */
271
+ function noticePaths(paths: readonly string[]): string {
272
+ if (paths.length <= NOTICE_PATHS) return paths.join(', ')
273
+ return `${paths.slice(0, NOTICE_PATHS).join(', ')} (+${paths.length - NOTICE_PATHS})`
274
+ }
275
+
276
+ const state: State = {
277
+ ctx: undefined,
278
+ ready: false,
279
+ error: undefined,
280
+ hosts: [],
281
+ workspaces: [],
282
+ routing: { mode: 'off', aliasPaths: {}, tombstones: [] },
283
+ workspaceId: readWorkspaceId(),
284
+ status: undefined,
285
+ head: undefined,
286
+ repoRoot: undefined,
287
+ repoSubdir: '',
288
+ repoMissing: false,
289
+ gitBusy: false,
290
+ gitError: undefined,
291
+ gitDeadlineMs: DEFAULT_GIT_TIMEOUT_MS,
292
+ tabs: [],
293
+ activeTabId: undefined,
294
+ revision: 0,
295
+ editorSurface: 'sidebar',
296
+ view: readStoredView(),
297
+ searchRequest: undefined,
298
+ untrackedDirs: new Map(),
299
+ }
300
+
301
+ /** Everything a view can read, plus the actions. */
302
+ export interface ExplorerValue {
303
+ ctx: Context | undefined
304
+ t: (key: string) => string
305
+ lang: Lang
306
+ ready: boolean
307
+ error: string | undefined
308
+ hosts: RemoteHostSummary[]
309
+ workspaces: RemoteWorkspaceBinding[]
310
+ routing: RemoteRoutingStatus
311
+ workspaceId: string
312
+ workspace: RemoteWorkspaceBinding | undefined
313
+ /** Binding id → materialized local alias dir (the session↔binding gate). */
314
+ aliasPaths: Record<string, string>
315
+ alias: string
316
+ /** Remote workspace root (absolute). */
317
+ root: string
318
+ /** Enclosing git repository root, when the binding is inside one. */
319
+ repoRoot: string | undefined
320
+ /** Workspace-relative directory git runs in ('' = the workspace root). */
321
+ repoSubdir: string
322
+ /** Absolute directory git runs in (workspace root + repoSubdir). */
323
+ repoWorkdir: string
324
+ repoMissing: boolean
325
+ status: GitStatusDetail | undefined
326
+ buckets: StatusBuckets
327
+ marks: TreeMarks
328
+ head: HeadInfo | undefined
329
+ gitBusy: boolean
330
+ gitError: string | undefined
331
+ /** Effective per-git-command budget (ms), for the loading hint. */
332
+ gitDeadlineMs: number
333
+ tabs: EditorTab[]
334
+ activeTab: EditorTab | undefined
335
+ activeTabId: string | undefined
336
+ revision: number
337
+ editorSurface: 'center' | 'sidebar'
338
+ view: RailView
339
+ searchRequest: SearchRequest | undefined
340
+ setView: (view: RailView) => void
341
+ /** Switch to the search panel (optionally scoped to a folder) — the tree's
342
+ "find in folder" entry point. */
343
+ requestSearch: (request: { mode: 'name' | 'content'; query?: string; scope: string }) => void
344
+ /**
345
+ * The USER's pick — the workspace dropdowns and the wizard's "open files".
346
+ *
347
+ * Only this entry point records a choice: it is what lets a pending follow
348
+ * know the user has taken over. Switches the plugin makes on its own (opening
349
+ * a file that lives in another binding, the follow itself) go through the
350
+ * store's internal auto path instead and never claim to be a choice.
351
+ */
352
+ selectWorkspace: (id: string) => void
353
+ /** Point git at a directory inside the workspace (repositories often live in
354
+ a subdirectory of the binding rather than at its root). */
355
+ setRepoSubdir: (subdir: string) => Promise<void>
356
+ /** Repositories found under the workspace root, as workspace-relative dirs. */
357
+ discoverRepos: () => Promise<string[]>
358
+ refreshAll: () => void
359
+ refreshGit: () => Promise<void>
360
+ afterWrite: () => Promise<void>
361
+ /**
362
+ * Stage / unstage paths: the row moves at once, and ONE path-limited status
363
+ * confirms it (no repository-wide status, no extras — see `stageOptimistic`).
364
+ */
365
+ stageOptimistic: (paths: readonly string[], staged: boolean) => Promise<void>
366
+ /** Expanded untracked directory rows: directory path → the rows under it. */
367
+ untrackedDirs: DirExpansions
368
+ /** Expand / collapse one untracked directory row (one path-limited status). */
369
+ toggleUntrackedDir: (dirPath: string) => Promise<void>
370
+ openFile: (fullPath: string, options?: { line?: number }) => void
371
+ openDiff: (rel: string, staged: boolean, untracked?: boolean) => void
372
+ openCommitFile: (hash: string, rel: string) => void
373
+ activateTab: (id: string) => void
374
+ closeTab: (id: string) => void
375
+ updateTab: (id: string, patch: Partial<EditorTab>) => void
376
+ /** 换一份会话改动标记(`activeHunk` 归零)。 */
377
+ applyChangeSet: (tabId: string, set: ChangeSet | null) => void
378
+ /** 上一个 / 下一个改动点(绕回),返回目标行号并驱动编辑器跳过去。 */
379
+ gotoChange: (tabId: string, dir: 1 | -1) => number | undefined
380
+ /** 编辑器报告文档已脱钩:标记作废,等上层重新拉数据。 */
381
+ markChangeStale: (tabId: string) => void
382
+ /**
383
+ * 撤销本会话在某个文件里的一处改动(悬浮按钮那一动作;`mark` 来自
384
+ * `changeSet.marks`,`hunk` 是 `patches` 的下标)。
385
+ */
386
+ undoChange: (tabId: string, mark: ChangeMark) => Promise<void>
387
+ saveTab: (id: string, content: string) => Promise<void>
388
+ reloadTab: (id: string) => Promise<void>
389
+ info: (text: string) => void
390
+ fail: (text: string) => void
391
+ }
392
+
393
+ /** Stable action identities — `build()` runs on every publish. */
394
+ const info = (text: string): void => toast(text, false)
395
+ const fail = (text: string): void => toast(text, true)
396
+
397
+ /**
398
+ * The derived git views for one status value, memoised on that value's identity.
399
+ *
400
+ * `build()` runs on EVERY publish — including the ones that only flip a UI bit
401
+ * (`gitBusy`, the active tab, a preview toggle, a panel switch). Bucketing and
402
+ * marking the whole change list twice per publish was pure waste, and handing
403
+ * out fresh objects each time also defeated every `useMemo` downstream that
404
+ * keys on `buckets`/`marks`, because their identities never survived a render.
405
+ *
406
+ * The status object is replaced only when git actually answers, so caching on
407
+ * its identity is exactly the right key: same status in, same two objects out.
408
+ *
409
+ * DECLARATION ORDER IS LOAD-BEARING: this block must stay ABOVE the
410
+ * `snapshot = build()` line below. `build()` is invoked during module
411
+ * evaluation, so a `let` declared after that call is still in its temporal dead
412
+ * zone and the module throws "Cannot access 'derivedViews' before
413
+ * initialization" — which neither `tsc` nor the bundler can see.
414
+ */
415
+ let derivedStatus: GitStatusDetail | undefined
416
+ let derivedViews: { buckets: StatusBuckets; marks: TreeMarks } | undefined
417
+
418
+ function deriveViews(status: GitStatusDetail): { buckets: StatusBuckets; marks: TreeMarks } {
419
+ if (derivedViews === undefined || derivedStatus !== status) {
420
+ const buckets = bucketStatus(status)
421
+ derivedStatus = status
422
+ derivedViews = { buckets, marks: buildTreeMarks(status, buckets) }
423
+ }
424
+ return derivedViews
425
+ }
426
+
427
+ const listeners = new Set<() => void>()
428
+ let snapshot: ExplorerValue = build()
429
+
430
+ function build(): ExplorerValue {
431
+ const workspace = state.workspaces.find(item => item.id === state.workspaceId) ?? state.workspaces[0]
432
+ const status = state.status ?? EMPTY_STATUS
433
+ const views = deriveViews(status)
434
+ return {
435
+ ctx: state.ctx,
436
+ t,
437
+ lang,
438
+ ready: state.ready,
439
+ error: state.error,
440
+ hosts: state.hosts,
441
+ workspaces: state.workspaces,
442
+ routing: state.routing,
443
+ workspaceId: state.workspaceId,
444
+ workspace,
445
+ aliasPaths: state.routing.aliasPaths,
446
+ alias: workspace?.alias ?? '',
447
+ root: workspace?.remotePath ?? '',
448
+ repoRoot: state.repoRoot,
449
+ repoSubdir: state.repoSubdir,
450
+ repoWorkdir: repoWorkdir(workspace?.remotePath ?? '', state.repoSubdir),
451
+ repoMissing: state.repoMissing,
452
+ status: state.status,
453
+ buckets: views.buckets,
454
+ marks: views.marks,
455
+ head: state.head,
456
+ gitBusy: state.gitBusy,
457
+ gitError: state.gitError,
458
+ gitDeadlineMs: state.gitDeadlineMs,
459
+ tabs: state.tabs,
460
+ activeTab: state.tabs.find(item => item.id === state.activeTabId),
461
+ activeTabId: state.activeTabId,
462
+ revision: state.revision,
463
+ editorSurface: state.editorSurface,
464
+ view: state.view,
465
+ searchRequest: state.searchRequest,
466
+ setView,
467
+ requestSearch,
468
+ selectWorkspace,
469
+ setRepoSubdir,
470
+ discoverRepos,
471
+ refreshAll,
472
+ refreshGit,
473
+ afterWrite,
474
+ stageOptimistic,
475
+ untrackedDirs: state.untrackedDirs,
476
+ toggleUntrackedDir,
477
+ openFile,
478
+ openDiff,
479
+ openCommitFile,
480
+ activateTab,
481
+ closeTab,
482
+ updateTab,
483
+ applyChangeSet,
484
+ gotoChange,
485
+ markChangeStale,
486
+ undoChange,
487
+ saveTab,
488
+ reloadTab,
489
+ info,
490
+ fail,
491
+ }
492
+ }
493
+
494
+ function publish(): void {
495
+ snapshot = build()
496
+ for (const listener of listeners) listener()
497
+ }
498
+
499
+ function patch(next: Partial<State>): void {
500
+ Object.assign(state, next)
501
+ publish()
502
+ }
503
+
504
+ /** Subscribe to the explorer store. */
505
+ export function useExplorer(): ExplorerValue {
506
+ return useSyncExternalStore(
507
+ (listener) => {
508
+ listeners.add(listener)
509
+ return () => {
510
+ listeners.delete(listener)
511
+ }
512
+ },
513
+ () => snapshot,
514
+ )
515
+ }
516
+
517
+ /** The repo-relative path of an absolute remote path ('' when outside). */
518
+ export function repoRelative(repoRoot: string | undefined, fullPath: string): string {
519
+ if (repoRoot === undefined) return ''
520
+ const base = repoRoot.replace(/\/+$/, '')
521
+ if (fullPath === base) return ''
522
+ if (fullPath.startsWith(`${base}/`)) return fullPath.slice(base.length + 1)
523
+ return ''
524
+ }
525
+
526
+ function currentWorkspace(): RemoteWorkspaceBinding | undefined {
527
+ return state.workspaces.find(item => item.id === state.workspaceId) ?? state.workspaces[0]
528
+ }
529
+
530
+ /**
531
+ * The active binding PLUS the id its per-workspace localStorage is keyed by.
532
+ *
533
+ * The id is the resolved binding's own id, not `state.workspaceId`: on a
534
+ * fresh browser profile `state.workspaceId` is '' while `currentWorkspace()`
535
+ * has already auto-selected `workspaces[0]` (and the UI shows it). Reading
536
+ * per-binding state under '' while writing it under the resolved id loses the
537
+ * value on every reload — and reading a stale id hands a deleted binding's
538
+ * setting to a different workspace.
539
+ */
540
+ function activeBinding(): { workspace: RemoteWorkspaceBinding | undefined; key: string } {
541
+ const workspace = currentWorkspace()
542
+ return { workspace, key: workspace?.id ?? state.workspaceId }
543
+ }
544
+
545
+ /**
546
+ * Monotonic ticket for git refreshes. Two refreshes can overlap whenever the
547
+ * binding changes or a write lands mid-read, and both panels stay mounted, so
548
+ * an older response arriving last would otherwise write ITS repository into
549
+ * the state the newer binding is using — after which every staged/discard
550
+ * button would act on the wrong repo (scm/actions.ts takes alias + repoRoot).
551
+ */
552
+ let gitGeneration = 0
553
+
554
+ // -------------------------------------------------------------- lifecycle
555
+
556
+ let booted = false
557
+
558
+ /** Install the context and load hosts/workspaces once per page. */
559
+ export function bootstrap(ctx: Context): void {
560
+ if (booted) {
561
+ if (state.ctx !== ctx) patch({ ctx })
562
+ return
563
+ }
564
+ booted = true
565
+ patch({ ctx })
566
+ refreshAll()
567
+ }
568
+
569
+ /** Reload hosts + workspace bindings and the routing status. */
570
+ export function refreshAll(): void {
571
+ if (state.ctx === undefined) return
572
+ Promise.all([api.getHosts(), api.getWorkspaces()]).then(
573
+ ([hosts, payload]) => {
574
+ // Apply the Host's effective timeouts FIRST: `refreshGit()` right below
575
+ // computes its deadline from them, and a stale default would cut a large
576
+ // repository off at the old 45 s before the raised value could apply.
577
+ const timeouts = configureTimeouts(payload.timeouts)
578
+ // The stored sub-directory is keyed by the RESOLVED binding (see
579
+ // activeBinding): it was written under that id, so reading it under the
580
+ // raw `state.workspaceId` ('' on a fresh profile) lost it every reload.
581
+ const key = activeBinding().key
582
+ patch({
583
+ hosts,
584
+ workspaces: payload.workspaces,
585
+ routing: { mode: payload.mode, aliasPaths: payload.aliasPaths, tombstones: payload.tombstones },
586
+ workspaceId: key,
587
+ repoSubdir: readRepoSubdir(key),
588
+ gitDeadlineMs: timeouts.gitMs,
589
+ ready: true,
590
+ error: undefined,
591
+ })
592
+ void refreshGit()
593
+ },
594
+ (error: unknown) => {
595
+ patch({ ready: true, error: error instanceof RemoteApiError ? error.message : String(error) })
596
+ },
597
+ )
598
+ }
599
+
600
+ /** Switch the sidebar panel (persisted across reloads). */
601
+ export function setView(view: RailView): void {
602
+ if (state.view === view) return
603
+ patch({ view })
604
+ try {
605
+ localStorage.setItem(VIEW_KEY, view)
606
+ } catch {
607
+ // Private mode: ignore.
608
+ }
609
+ }
610
+
611
+ /** One-shot request from the explorer: "search this folder". */
612
+ export function requestSearch(request: { mode: 'name' | 'content'; query?: string; scope: string }): void {
613
+ searchSeq += 1
614
+ patch({
615
+ searchRequest: {
616
+ id: searchSeq,
617
+ mode: request.mode,
618
+ query: request.query ?? '',
619
+ scope: request.scope,
620
+ },
621
+ })
622
+ setView('search')
623
+ }
624
+
625
+ /**
626
+ * Select the binding every view works against — the USER's choice.
627
+ *
628
+ * This is the entry point the snapshot hands to the views (`ExplorerValue.
629
+ * selectWorkspace`), so every dropdown and the workspace wizard land here. It
630
+ * records the pick for the session on screen, which is what stops a follow that
631
+ * has not resolved yet from undoing it.
632
+ */
633
+ export function selectWorkspace(id: string): void {
634
+ applyWorkspaceSelection(id, true)
635
+ }
636
+
637
+ /**
638
+ * Select a binding on the PLUGIN's own initiative, claiming nothing.
639
+ *
640
+ * Two callers: opening a file that belongs to another binding (the change
641
+ * panel, the click interceptor) and the follow itself. Neither is a statement
642
+ * about where the user wants to browse — clicking a changed file is "show me
643
+ * this result", not "I moved my workspace" — so a pending follow stays free to
644
+ * put the selection back on the session's binding afterwards.
645
+ */
646
+ function selectWorkspaceAuto(id: string): void {
647
+ applyWorkspaceSelection(id, false)
648
+ }
649
+
650
+ /** Move the selection; `byUser` decides whether it is remembered as a choice. */
651
+ function applyWorkspaceSelection(id: string, byUser: boolean): void {
652
+ if (state.workspaceId === id) return
653
+ if (byUser) noteManualPick()
654
+ // Local stage results are repository-relative: another binding's paths must
655
+ // never be re-applied to this one. The expanded untracked directories are
656
+ // repository-relative too.
657
+ stageOverrides = new Map()
658
+ stageOverrideTarget = ''
659
+ patch({
660
+ workspaceId: id,
661
+ tabs: [],
662
+ activeTabId: undefined,
663
+ status: undefined,
664
+ head: undefined,
665
+ repoRoot: undefined,
666
+ repoSubdir: readRepoSubdir(id),
667
+ untrackedDirs: new Map(),
668
+ })
669
+ try {
670
+ localStorage.setItem(WS_KEY, id)
671
+ } catch {
672
+ // Private mode: ignore.
673
+ }
674
+ void refreshGit()
675
+ }
676
+
677
+ let searchSeq = 0
678
+
679
+ /**
680
+ * The session whose binding the sidebar already followed (per page load).
681
+ *
682
+ * Module state, not a ref: the two surfaces that can trigger the follow (the
683
+ * sidebar and the change panel) must share it, or each would drag the selector
684
+ * to its own idea of the session's workspace on every repaint.
685
+ */
686
+ let followedSession: string | undefined
687
+
688
+ /**
689
+ * The binding resolved for `followedSession` (module state, same reason).
690
+ *
691
+ * Kept beside `followedSession` because the two are only meaningful together:
692
+ * `workspaceMatchesSession()` trusts this id only while the session it was
693
+ * resolved FOR is still the one being followed.
694
+ */
695
+ let followedBindingId: string | undefined
696
+
697
+ /**
698
+ * The registered session whose selection the USER took over by hand.
699
+ *
700
+ * "Follow once, then the user's choice stands" cannot be decided from
701
+ * `followedSession` alone: a session whose workspace never resolved (a probe
702
+ * that failed, a host that answers nothing) has no follow to remember, and the
703
+ * retry keeps asking — so a resolution landing later would yank a selection the
704
+ * user made in the meantime.
705
+ *
706
+ * Recorded ONLY for an explicit user switch (the dropdowns, the workspace
707
+ * wizard). A selection the plugin moved on its own — clicking a changed file
708
+ * that lives in another binding, opening it from the reference interceptor — is
709
+ * "show me this result", not a browsing decision, and must not cancel a follow
710
+ * that is still resolving. Cleared when the session changes (a new seat
711
+ * re-follows).
712
+ */
713
+ let manualPickSession: string | undefined
714
+
715
+ /**
716
+ * Remember an explicit user choice of workspace.
717
+ *
718
+ * Called from the user entry point (`selectWorkspace`) only; the `let` above
719
+ * lives with the follow narrative rather than next to the selector, so this
720
+ * stays a hoisted call.
721
+ */
722
+ function noteManualPick(): void {
723
+ if (followSessionId !== undefined) manualPickSession = followSessionId
724
+ }
725
+
726
+ /**
727
+ * The session the surfaces are showing — the subject of every follow.
728
+ *
729
+ * Module state, and deliberately not a React ref: this is what makes the
730
+ * session→workspace resolution work with NO UI mounted at all. The change panel
731
+ * registers the session it renders (see `ChangesDock`), so a conversation whose
732
+ * sidebar was never opened still resolves its own workspace — and a first scan
733
+ * can never be handed another workspace's repository directory.
734
+ */
735
+ let followSessionId: string | undefined
736
+
737
+ /**
738
+ * Register the session every follow call is about.
739
+ *
740
+ * Called by the surfaces that know which session is on screen. Clearing it is a
741
+ * real state — "no session on screen" — and every consumer then answers "not
742
+ * this session" instead of guessing.
743
+ */
744
+ export function setFollowSession(sessionId: string | undefined): void {
745
+ const next = sessionId === undefined || sessionId === '' ? undefined : sessionId
746
+ if (followSessionId === next) return
747
+ followSessionId = next
748
+ // A new seat re-follows: the previous session's manual pick was about that
749
+ // session's workspace, not this one's.
750
+ manualPickSession = undefined
751
+ }
752
+
753
+ /** One resolution in flight, so the same session is never probed twice at once. */
754
+ let followFlight: { sessionId: string; promise: Promise<string | undefined> } | undefined
755
+
756
+ /**
757
+ * Point the workspace selector (and with it the tree, the editor's dropdown and
758
+ * the git panel) at the binding the registered session works in.
759
+ *
760
+ * The user's complaint this exists for: opening a session in one remote
761
+ * workspace showed the sidebar on another — the selector kept whatever
762
+ * localStorage held (or the FIRST binding, on a fresh profile), and a click in
763
+ * the change panel matched its binding by alias alone, which is ambiguous as
764
+ * soon as one host carries two workspaces (P710 and Z700 share `ssh_xiray_new`).
765
+ *
766
+ * Resolution order, strongest evidence first (all of it inside
767
+ * `planWorkspaceFollow`, so the policy has exactly one home):
768
+ * 1. `workspacePath` — the host's own workspace membership for the session,
769
+ * which IS the alias dir of exactly one binding;
770
+ * 2. `alias` + `remoteRoot` — what the change panel holds once it has a
771
+ * summary (`bindingForRemoteFile`), which disambiguates by REMOTE path;
772
+ * 3. the session's own cwd, asked of the host (`api.sessionCwd`) — the only
773
+ * source available BEFORE the panel's first successful scan, which is
774
+ * precisely the state that used to send a foreign repository directory.
775
+ *
776
+ * Idempotent and re-entrant by construction:
777
+ * - once the session has been followed, later calls answer the recorded
778
+ * binding without touching the network, so a manual pick stands;
779
+ * - a pick the USER made before anything resolved stands just the same (see
780
+ * `manualPickSession`), so the retry can never undo an explicit choice;
781
+ * - while a resolution is in flight, concurrent callers share that one
782
+ * promise (the sidebar and the panel both trigger this);
783
+ * - an answer that arrives after the session changed is discarded.
784
+ *
785
+ * Called with no evidence at all it does nothing rather than guessing: an
786
+ * unknown session must never move the selection, and must never fall back to
787
+ * "the first binding".
788
+ *
789
+ * @returns the binding id that was selected, or `undefined` when nothing moved.
790
+ */
791
+ export function followSessionWorkspace(options: {
792
+ /** The session the caller's evidence belongs to; defaults to the registered one. */
793
+ sessionId?: string | undefined
794
+ /** The session's host workspace path (the local alias dir), when known. */
795
+ workspacePath?: string | undefined
796
+ /** Host alias the session is routed to, when known. */
797
+ alias?: string | undefined
798
+ /** Absolute remote root of the session's workspace, when known. */
799
+ remoteRoot?: string | undefined
800
+ } = {}): Promise<string | undefined> {
801
+ const asked = options.sessionId
802
+ if (asked !== undefined && asked !== '') {
803
+ if (followSessionId === undefined) setFollowSession(asked)
804
+ // Evidence for one session must never steer another: the surface that owns
805
+ // the session on screen follows it, and this one is stale.
806
+ else if (followSessionId !== asked) return Promise.resolve(undefined)
807
+ }
808
+ const sessionId = followSessionId
809
+ if (sessionId === undefined) return Promise.resolve(undefined)
810
+ // Already followed: the user's manual pick stands, and no round trip is spent
811
+ // re-deciding what was already decided. A manual pick made BEFORE anything
812
+ // resolved (see `manualPickSession`) is the same promise, kept even though
813
+ // there is no follow to remember.
814
+ if (followedSession === sessionId) return Promise.resolve(followedBindingId)
815
+ if (manualPickSession === sessionId) return Promise.resolve(undefined)
816
+ const inFlight = followFlight
817
+ if (inFlight !== undefined && inFlight.sessionId === sessionId) return inFlight.promise
818
+ const promise = resolveSessionWorkspace(sessionId, options).finally(() => {
819
+ if (followFlight?.promise === promise) followFlight = undefined
820
+ })
821
+ followFlight = { sessionId, promise }
822
+ return promise
823
+ }
824
+
825
+ /**
826
+ * Resolve one session's binding and apply it (the applier behind the API above).
827
+ *
828
+ * The cwd probe is a LAST resort, and only when the caller's own evidence
829
+ * resolved nothing: it is an HTTP round trip per attempt, and the panel retries
830
+ * on a slow timer — evidence that already answers must not cost one. A probe
831
+ * that fails (or answers nothing usable) leaves the selection exactly as it
832
+ * was; the caller retries later, and `workspaceMatchesSession()` keeps saying
833
+ * "not proven" in the meantime, so nothing wrong is ever sent.
834
+ */
835
+ async function resolveSessionWorkspace(
836
+ sessionId: string,
837
+ options: { workspacePath?: string | undefined; alias?: string | undefined; remoteRoot?: string | undefined },
838
+ ): Promise<string | undefined> {
839
+ const plan = (cwd?: string): WorkspaceFollowDecision => planWorkspaceFollow({
840
+ table: remoteOpenBindings(),
841
+ sessionId,
842
+ workspacePath: options.workspacePath,
843
+ alias: options.alias,
844
+ remoteRoot: options.remoteRoot,
845
+ sessionCwd: cwd,
846
+ followedSession,
847
+ })
848
+ let decision = plan()
849
+ if (decision.action === 'keep' && decision.reason === 'unresolved') {
850
+ let cwd: string | undefined
851
+ try {
852
+ const answer = await api.sessionCwd(sessionId)
853
+ cwd = answer.cwd
854
+ } catch {
855
+ // A failed probe resolves nothing: the selection stands, nothing is sent.
856
+ return undefined
857
+ }
858
+ // The session may have changed while the host was answering; its evidence
859
+ // must not be applied to the new one.
860
+ if (followSessionId !== sessionId) return undefined
861
+ if (cwd !== undefined && cwd !== '') decision = plan(cwd)
862
+ }
863
+ if (decision.action === 'keep') return undefined
864
+ // The user may have chosen a workspace WHILE this resolution was in flight:
865
+ // a choice beats a late answer.
866
+ if (followSessionId !== sessionId || manualPickSession === sessionId) return undefined
867
+ followedSession = decision.followedSession
868
+ followedBindingId = decision.bindingId
869
+ if (state.workspaceId !== decision.bindingId) selectWorkspaceAuto(decision.bindingId)
870
+ return decision.bindingId
871
+ }
872
+
873
+ /**
874
+ * Whether the views' CURRENT selection is the binding resolved for the followed
875
+ * session. `false` whenever that cannot be proven (no session, nothing resolved
876
+ * yet, or a manual pick moved the selection elsewhere) — see
877
+ * `selectionIsSessionBinding`, which owns the comparison.
878
+ */
879
+ export function workspaceMatchesSession(): boolean {
880
+ return selectionIsSessionBinding({
881
+ table: remoteOpenBindings(),
882
+ sessionId: followSessionId,
883
+ followedSession,
884
+ followedBindingId,
885
+ selectedId: currentWorkspace()?.id,
886
+ })
887
+ }
888
+
889
+ /**
890
+ * The repository sub-directory to hand the host for one session, or `''` when
891
+ * the selection cannot be proven to be that session's binding.
892
+ *
893
+ * `''` is not "no answer": it tells the host to use the SESSION ROOT (and its
894
+ * own unique-candidate probe), which is the only correct instruction when the
895
+ * caller cannot name the session's repository. Sending the selected binding's
896
+ * directory instead is what produced the reported loop — the host ran `cd` on
897
+ * `session root + another workspace's subdir`, answered `session directory is
898
+ * missing on the host` (exit 90), the panel lost its summary and asked again
899
+ * three seconds later, forever.
900
+ *
901
+ * @param sessionId - the session the caller is about to ask the host about.
902
+ * Omitted, the registered session is used; given, a registration that is
903
+ * about a DIFFERENT session answers `''` — a panel listing one conversation
904
+ * must never describe another one's repository.
905
+ */
906
+ export function sessionRepoSubdir(sessionId?: string | undefined): string {
907
+ if (sessionId !== undefined && sessionId !== '' && sessionId !== followSessionId) return ''
908
+ if (!workspaceMatchesSession()) return ''
909
+ return readRepoSubdir(activeBinding().key)
910
+ }
911
+
912
+ /** The binding id the views are on (the dropdown/tree's answer), plain read. */
913
+ export function selectedWorkspaceId(): string {
914
+ return currentWorkspace()?.id ?? ''
915
+ }
916
+
917
+ /**
918
+ * Point git at a directory inside the workspace. Repositories often live in a
919
+ * sub-directory of the binding (`/srv/app` holding `/srv/app/web/.git`), and
920
+ * git only answers for the directory it is run in, so the choice is persisted
921
+ * per binding and re-resolved on every refresh.
922
+ */
923
+ export async function setRepoSubdir(subdir: string): Promise<void> {
924
+ const { workspace, key } = activeBinding()
925
+ const clean = normalizeRepoSubdir(subdir)
926
+ if (workspace !== undefined) writeRepoSubdir(key, clean)
927
+ patch({ repoSubdir: clean, repoRoot: undefined, gitError: undefined })
928
+ await refreshGit()
929
+ }
930
+
931
+ /** Repositories found under the workspace root (workspace-relative, root first). */
932
+ export async function discoverRepos(): Promise<string[]> {
933
+ const workspace = currentWorkspace()
934
+ if (workspace === undefined) return []
935
+ const found = await findRepos(workspace.alias, workspace.remotePath)
936
+ return found
937
+ .map(dir => repoSubdirOf(workspace.remotePath, dir))
938
+ .filter((dir): dir is string => dir !== undefined)
939
+ }
940
+
941
+ /** Re-read git status for the current binding. */
942
+ export async function refreshGit(): Promise<void> {
943
+ const { workspace, key } = activeBinding()
944
+ if (workspace === undefined) return
945
+ const gen = ++gitGeneration
946
+ // Discard a response that is no longer about the binding on screen (the
947
+ // user switched) or is superseded by a newer refresh. Without this the
948
+ // slower request wins the last write and the panel shows one workspace's
949
+ // repository while the selector and every action use another's.
950
+ const stale = (): boolean => gen !== gitGeneration || activeBinding().key !== key
951
+ // A local stage/unstage that happens while this read is in flight cannot be
952
+ // in its answer: remember where the local timeline stood when it started.
953
+ const stageAt = stageSeq
954
+ patch({ gitBusy: true })
955
+ try {
956
+ const workdir = repoWorkdir(workspace.remotePath, state.repoSubdir)
957
+ const root = await resolveRepoRoot(workspace.alias, workdir)
958
+ if (stale()) return
959
+ if (root === undefined) {
960
+ patch({ repoRoot: undefined, repoMissing: true, status: EMPTY_STATUS, head: undefined, gitBusy: false, gitError: undefined })
961
+ return
962
+ }
963
+ const [detail, head] = await Promise.all([
964
+ withDeadline(loadStatusDetail(workspace.alias, root), gitDeadlineMs(), t('editor.readTimeout')),
965
+ withDeadline(loadHead(workspace.alias, root), gitDeadlineMs(), t('editor.readTimeout')),
966
+ ])
967
+ if (stale()) return
968
+ // A directory INSIDE a repository settles on that repository's root, so the
969
+ // stored choice is what git actually uses (and what the picker shows).
970
+ const settled = repoSubdirOf(workspace.remotePath, root) ?? state.repoSubdir
971
+ if (settled !== state.repoSubdir) writeRepoSubdir(key, settled)
972
+ // Keep the local stage results this read could not have seen, and retire
973
+ // the ones it did see (they are in `detail` now, for better or worse).
974
+ const target = stageTargetKey({ alias: workspace.alias, root })
975
+ const status = stageOverrideTarget === target ? applyStageOverrides(detail, stageOverrides, stageAt) : detail
976
+ if (stageOverrideTarget === target) {
977
+ for (const [path, override] of stageOverrides) {
978
+ if (override.at <= stageAt) stageOverrides.delete(path)
979
+ }
980
+ }
981
+ // An expanded untracked directory survives this refresh only while git still
982
+ // reports the same directory row (see `pruneDirExpansions`).
983
+ patch({
984
+ repoRoot: root,
985
+ repoSubdir: settled,
986
+ repoMissing: false,
987
+ status,
988
+ head,
989
+ gitBusy: false,
990
+ gitError: undefined,
991
+ untrackedDirs: pruneDirExpansions(state.untrackedDirs, status),
992
+ })
993
+ } catch (error) {
994
+ if (stale()) return
995
+ // Clear the repo identity too. Leaving a previous binding's `repoRoot` in
996
+ // place kept its branch, change list and sync buttons painted while the
997
+ // selector said something else — and ScmPanel keys "Loading…" off
998
+ // repoRoot === undefined, so NOT clearing it showed a stale repo while
999
+ // clearing it without an error message showed a spinner forever. The
1000
+ // message is now rendered by the panel (see ScmPanel's empty state).
1001
+ patch({
1002
+ repoRoot: undefined,
1003
+ head: undefined,
1004
+ status: EMPTY_STATUS,
1005
+ gitBusy: false,
1006
+ gitError: error instanceof Error ? error.message : String(error),
1007
+ })
1008
+ }
1009
+ }
1010
+
1011
+ /** Refresh everything a working-tree change can invalidate. */
1012
+ export async function afterWrite(): Promise<void> {
1013
+ await refreshGit()
1014
+ patch({ revision: state.revision + 1 })
1015
+ }
1016
+
1017
+ /** The repository the SCM panel is pointed at, or undefined when none. */
1018
+ export function scmTarget(): { alias: string; root: string } | undefined {
1019
+ const workspace = currentWorkspace()
1020
+ if (workspace === undefined || state.repoRoot === undefined) return undefined
1021
+ return { alias: workspace.alias, root: state.repoRoot }
1022
+ }
1023
+
1024
+ /**
1025
+ * Move paths between the index and the worktree halves of the LOCAL status
1026
+ * model — the optimistic update, its rollback, and the correction the probe
1027
+ * asks for all land here.
1028
+ *
1029
+ * Pure model work: no command is issued, nothing but `status` is republished,
1030
+ * and the explorer's revision is left alone (staging does not change the remote
1031
+ * tree, so re-listing directories over SFTP would be pure waste). The paths are
1032
+ * also remembered so a full status read that started BEFORE this change cannot
1033
+ * undo it (see `applyStageOverrides`).
1034
+ */
1035
+ export function applyLocalStage(paths: readonly string[], staged: boolean): void {
1036
+ if (paths.length === 0) return
1037
+ const target = scmTarget()
1038
+ const key = target === undefined ? '' : stageTargetKey(target)
1039
+ if (key !== stageOverrideTarget) {
1040
+ // Another repository (or none): the previous overrides name paths that
1041
+ // mean something else here, so they must not be applied to it.
1042
+ stageOverrides = new Map()
1043
+ stageOverrideTarget = key
1044
+ }
1045
+ stageSeq += 1
1046
+ for (const path of paths) stageOverrides.set(path, { staged, at: stageSeq })
1047
+ if (state.status === undefined) return
1048
+ // A file inside an expanded untracked directory has no record of its own
1049
+ // (`-u normal` reported only the directory): staging it must still show up
1050
+ // under "staged changes" instead of vanishing from the list.
1051
+ let next = state.status
1052
+ if (staged) for (const path of paths) next = insertStagedEntry(next, path)
1053
+ next = optimisticStageDetail(next, paths, staged)
1054
+ if (next === state.status) return
1055
+ // A staged row is no longer an untracked row: it leaves the expansion, and a
1056
+ // directory row that stopped being untracked takes its whole expansion with
1057
+ // it (see `pruneDirExpansions`).
1058
+ const dirs = staged ? dropExpandedRows(state.untrackedDirs, paths) : state.untrackedDirs
1059
+ patch({ status: next, untrackedDirs: pruneDirExpansions(dirs, next) })
1060
+ }
1061
+
1062
+ /**
1063
+ * Stage / unstage paths with instant local feedback and ONE cheap check.
1064
+ *
1065
+ * The reported problem: `+` on one file in a large repository fired four
1066
+ * repository-wide git commands (the `git add`, a full `git status`, and the
1067
+ * three extras loaders), so the click cost minutes and locked the panel. Here
1068
+ * the row moves immediately in the local model, the real verb goes out at once,
1069
+ * and a single path-limited `git status -- <paths>` (cost independent of the
1070
+ * repository size) confirms it — rolling the row back and telling the user when
1071
+ * the repository disagrees.
1072
+ *
1073
+ * Resolves when the verification is in; rejects when the git verb itself failed
1074
+ * (the caller reports that error).
1075
+ */
1076
+ export async function stageOptimistic(paths: readonly string[], staged: boolean): Promise<void> {
1077
+ const target = scmTarget()
1078
+ if (target === undefined || paths.length === 0) return
1079
+ await stageCoordinator.submit(target, paths, staged)
1080
+ }
1081
+
1082
+ /**
1083
+ * Expand or collapse one untracked DIRECTORY row.
1084
+ *
1085
+ * `-u normal` collapses an untracked directory into a single `dir/` row, so its
1086
+ * contents are invisible. Expanding costs exactly ONE path-limited
1087
+ * `git status -uall -- <dir>/` — never a repository-wide status, never the
1088
+ * extras — and the rows it answers with are shown under the directory row.
1089
+ * Collapsing costs nothing at all.
1090
+ *
1091
+ * The expansions are pruned against every status the panel adopts, so a
1092
+ * directory that gets staged or deleted takes its rows with it (see
1093
+ * `scm/untracked-dirs.ts`).
1094
+ *
1095
+ * @param dirPath - the directory row's repo-relative path (with its `/`).
1096
+ */
1097
+ export async function toggleUntrackedDir(dirPath: string): Promise<void> {
1098
+ if (dirPath === '' || !dirPath.endsWith('/')) return
1099
+ if (state.untrackedDirs.has(dirPath)) {
1100
+ patch({ untrackedDirs: collapseDir(state.untrackedDirs, dirPath) })
1101
+ return
1102
+ }
1103
+ // A double click must not buy two probes: the second click only arrives while
1104
+ // the first one's answer is still in flight.
1105
+ if (dirProbes.has(dirPath)) return
1106
+ const target = scmTarget()
1107
+ if (target === undefined) return
1108
+ dirProbes.add(dirPath)
1109
+ try {
1110
+ const stdout = await probeStatusDir(target.alias, target.root, dirPath)
1111
+ // Read the state AFTER the round trip: another directory may have been
1112
+ // expanded (or this one collapsed) while the probe was in flight.
1113
+ patch({ untrackedDirs: expandDir(state.untrackedDirs, dirPath, stdout) })
1114
+ } finally {
1115
+ dirProbes.delete(dirPath)
1116
+ }
1117
+ }
1118
+
1119
+ /** Directory rows whose expansion probe is in flight (see `toggleUntrackedDir`). */
1120
+ const dirProbes = new Set<string>()
1121
+
1122
+ // -------------------------------------------------------------- tab model
1123
+
1124
+ function computeRel(fullPath: string): string {
1125
+ const workspace = currentWorkspace()
1126
+ const root = workspace?.remotePath ?? ''
1127
+ if (root !== '' && fullPath.startsWith(`${root}/`)) return fullPath.slice(root.length + 1)
1128
+ return repoRelative(state.repoRoot, fullPath) || fullPath
1129
+ }
1130
+
1131
+ function upsertTab(tab: EditorTab): void {
1132
+ const already = state.tabs.some(item => item.id === tab.id)
1133
+ if (already) {
1134
+ patch({ activeTabId: tab.id })
1135
+ revealEditor()
1136
+ return
1137
+ }
1138
+ patch({ tabs: [...state.tabs, tab], activeTabId: tab.id })
1139
+ revealEditor()
1140
+ }
1141
+
1142
+ /**
1143
+ * Pending reveal retry. Module level on purpose: `revealEditor` is called from
1144
+ * store actions, so no component lifecycle could ever cancel it.
1145
+ */
1146
+ let revealRetry: ReturnType<typeof setTimeout> | undefined
1147
+
1148
+ /**
1149
+ * Bring the editor into view. On the 'center' surface the shell renders only
1150
+ * the selected conversation view, so a file opened from the sidebar must also
1151
+ * select that tab — otherwise the click looks like a no-op. When the tab
1152
+ * cannot be found (another host build, or the shell's markup changed) the user
1153
+ * is told where the file opened instead of being left guessing.
1154
+ */
1155
+ function revealEditor(): void {
1156
+ if (state.editorSurface !== 'center') return
1157
+ const label = t('view.editor')
1158
+ // A newer open supersedes the pending retry: arming a second one left the
1159
+ // timers piling up one per opened file (each doing its own full-document tab
1160
+ // scan 80ms later), and an older retry could pop its "opened in …" toast on
1161
+ // top of the newer open.
1162
+ if (revealRetry !== undefined) {
1163
+ clearTimeout(revealRetry)
1164
+ revealRetry = undefined
1165
+ }
1166
+ // The tab only exists once the shell has painted it; one deferred retry
1167
+ // covers the frame in which a freshly registered view is being added.
1168
+ if (revealViewTab(label)) return
1169
+ revealRetry = setTimeout(() => {
1170
+ revealRetry = undefined
1171
+ if (!revealViewTab(label)) toast(`${t('editor.openedIn')}「${label}」`, false)
1172
+ }, 80)
1173
+ }
1174
+
1175
+ function patchTab(id: string, next: Partial<EditorTab>): void {
1176
+ patch({ tabs: state.tabs.map(tab => (tab.id === id ? { ...tab, ...next } : tab)) })
1177
+ }
1178
+
1179
+ /**
1180
+ * Deadline for one git round trip.
1181
+ *
1182
+ * Was a fixed 45 s shared with file reads, which made a cold `git status` on a
1183
+ * large remote repository look like a dead host ("读取超时(远端无响应)(45s)").
1184
+ * Git now follows the configured `gitTimeoutMs` (default 300 s), refreshed from
1185
+ * the Host's workspaces answer — see `timeouts.ts`.
1186
+ */
1187
+ function gitDeadlineMs(): number {
1188
+ return clientDeadlineFor(gitTimeoutMs())
1189
+ }
1190
+
1191
+ /** Deadline for one SFTP file read (editor, diff sides, preview). */
1192
+ function fileReadDeadlineMs(): number {
1193
+ return clientDeadlineFor(fileReadTimeoutMs())
1194
+ }
1195
+
1196
+ /**
1197
+ * Reject a remote call that never answers. The host route has its own caps,
1198
+ * but a stuck TCP/SSH round trip would otherwise leave a tab loading forever
1199
+ * with no way to tell what happened.
1200
+ */
1201
+ function withDeadline<T>(promise: Promise<T>, ms: number, label: string): Promise<T> {
1202
+ return new Promise<T>((resolve, reject) => {
1203
+ const timer = setTimeout(() => reject(new Error(`${label} (${Math.round(ms / 1000)}s)`)), ms)
1204
+ timer.unref?.()
1205
+ promise.then(
1206
+ (value) => {
1207
+ clearTimeout(timer)
1208
+ resolve(value)
1209
+ },
1210
+ (error: unknown) => {
1211
+ clearTimeout(timer)
1212
+ reject(error instanceof Error ? error : new Error(String(error)))
1213
+ },
1214
+ )
1215
+ })
1216
+ }
1217
+
1218
+ /** Open (or focus) a remote file in the editor tab strip. */
1219
+ export function openFile(fullPath: string, options: { line?: number } = {}): void {
1220
+ const workspace = currentWorkspace()
1221
+ if (workspace === undefined || fullPath === '') return
1222
+ const id = `file:${workspace.alias}:${fullPath}`
1223
+ // Re-opening a tab that is already up only re-addresses the reveal line, so
1224
+ // a second search hit in the same file moves the cursor instead of reloading.
1225
+ // The nonce also re-triggers a jump to the SAME line (value alone would not).
1226
+ if (state.tabs.some(tab => tab.id === id)) {
1227
+ patch({
1228
+ activeTabId: id,
1229
+ tabs: state.tabs.map(tab => (tab.id === id
1230
+ ? (options.line === undefined
1231
+ ? tab
1232
+ : { ...tab, line: options.line, lineNonce: (tab.lineNonce ?? 0) + 1 })
1233
+ : tab)),
1234
+ })
1235
+ revealEditor()
1236
+ return
1237
+ }
1238
+ upsertTab({
1239
+ id,
1240
+ kind: 'file',
1241
+ fullPath,
1242
+ rel: computeRel(fullPath),
1243
+ loading: true,
1244
+ error: undefined,
1245
+ text: '',
1246
+ dirty: false,
1247
+ saving: false,
1248
+ binary: false,
1249
+ truncated: false,
1250
+ image: isImageFile(fullPath),
1251
+ markdown: isMarkdownFile(fullPath),
1252
+ html: isHtmlFile(fullPath),
1253
+ preview: isMarkdownFile(fullPath) || isHtmlFile(fullPath),
1254
+ staged: false,
1255
+ untracked: false,
1256
+ ...(options.line === undefined ? {} : { line: options.line, lineNonce: 1 }),
1257
+ diff: undefined,
1258
+ model: undefined,
1259
+ view: 'split',
1260
+ })
1261
+ void (async () => {
1262
+ try {
1263
+ const read = await withDeadline(api.fileOp.read(workspace.alias, fullPath), fileReadDeadlineMs(), t('editor.readTimeout'))
1264
+ patchTab(id, {
1265
+ loading: false,
1266
+ binary: read.binary,
1267
+ truncated: read.truncated,
1268
+ text: read.binary ? '' : (read.text ?? ''),
1269
+ })
1270
+ } catch (error) {
1271
+ patchTab(id, { loading: false, error: error instanceof Error ? error.message : String(error) })
1272
+ }
1273
+ })()
1274
+ }
1275
+
1276
+ /**
1277
+ * Binding table for the click interceptor (`remote-file-open.ts`).
1278
+ *
1279
+ * A plain snapshot read, not a hook: the interceptor runs inside a service
1280
+ * method call (no React render), so it needs the current bindings and alias
1281
+ * directories synchronously. Unknown/empty state answers empty tables.
1282
+ */
1283
+ export function remoteOpenBindings(): {
1284
+ bindings: RemoteWorkspaceBinding[]
1285
+ aliasPaths: Record<string, string>
1286
+ } {
1287
+ return { bindings: state.workspaces, aliasPaths: state.routing.aliasPaths }
1288
+ }
1289
+
1290
+ /**
1291
+ * Open one file that belongs to a SPECIFIC remote binding (the click
1292
+ * interceptor already resolved it), switching the explorer's selected
1293
+ * workspace when the on-screen one differs.
1294
+ *
1295
+ * @returns true when the file was handed to the editor; false when the binding
1296
+ * is unknown (the caller then keeps the native preview).
1297
+ */
1298
+ export function openRemoteFileInBinding(
1299
+ bindingId: string,
1300
+ remotePath: string,
1301
+ options: { line?: number } = {},
1302
+ ): boolean {
1303
+ const binding = state.workspaces.find(item => item.id === bindingId)
1304
+ if (binding === undefined || remotePath === '') return false
1305
+ // AUTO, not a user choice: clicking a result that lives in another binding
1306
+ // must not cancel the session's pending follow (see `selectWorkspaceAuto`).
1307
+ if (state.workspaceId !== binding.id) selectWorkspaceAuto(binding.id)
1308
+ openFile(remotePath, options)
1309
+ return true
1310
+ }
1311
+
1312
+ /** Whether the editor tab is mounted in the centre conversation view. */
1313
+ export function isEditorCentered(): boolean {
1314
+ return state.editorSurface === 'center'
1315
+ }
1316
+
1317
+ /** Open (or focus) a working-tree diff of one repository path. */
1318
+ export function openDiff(rel: string, staged: boolean, untracked = false): void {
1319
+ const workspace = currentWorkspace()
1320
+ if (workspace === undefined || state.repoRoot === undefined || rel === '') {
1321
+ toast(t('git.notARepo'), true)
1322
+ return
1323
+ }
1324
+ const root = state.repoRoot
1325
+ const id = `diff:${workspace.alias}:${root}:${rel}:${staged ? 'staged' : 'worktree'}`
1326
+ upsertTab({
1327
+ id,
1328
+ kind: 'diff',
1329
+ fullPath: `${root.replace(/\/+$/, '')}/${rel}`,
1330
+ rel,
1331
+ loading: true,
1332
+ error: undefined,
1333
+ text: '',
1334
+ dirty: false,
1335
+ saving: false,
1336
+ binary: false,
1337
+ truncated: false,
1338
+ image: isImageFile(rel),
1339
+ markdown: false,
1340
+ html: false,
1341
+ preview: false,
1342
+ staged,
1343
+ untracked,
1344
+ diff: undefined,
1345
+ model: undefined,
1346
+ view: 'split',
1347
+ })
1348
+ void (async () => {
1349
+ try {
1350
+ const payload = await withDeadline(loadDiff(workspace.alias, root, rel, staged, untracked), gitDeadlineMs(), t('editor.readTimeout'))
1351
+ patchTab(id, {
1352
+ loading: false,
1353
+ diff: payload,
1354
+ model: payload.unsupported ? undefined : buildDiffModel(payload),
1355
+ binary: payload.unsupported,
1356
+ })
1357
+ } catch (error) {
1358
+ patchTab(id, { loading: false, error: error instanceof Error ? error.message : String(error) })
1359
+ }
1360
+ })()
1361
+ }
1362
+
1363
+ /** Open (or focus) one file of one commit (`ref^` against `ref`). */
1364
+ export function openCommitFile(hash: string, rel: string): void {
1365
+ const workspace = currentWorkspace()
1366
+ const root = state.repoRoot
1367
+ if (workspace === undefined || root === undefined || rel === '') return
1368
+ const id = `commit:${workspace.alias}:${hash}:${rel}`
1369
+ upsertTab({
1370
+ id,
1371
+ kind: 'diff',
1372
+ fullPath: `${root.replace(/\/+$/, '')}/${rel}`,
1373
+ rel,
1374
+ loading: true,
1375
+ error: undefined,
1376
+ text: '',
1377
+ dirty: false,
1378
+ saving: false,
1379
+ binary: false,
1380
+ truncated: false,
1381
+ image: false,
1382
+ markdown: false,
1383
+ html: false,
1384
+ preview: false,
1385
+ staged: true,
1386
+ untracked: false,
1387
+ ref: hash,
1388
+ diff: undefined,
1389
+ model: undefined,
1390
+ view: 'split',
1391
+ })
1392
+ void (async () => {
1393
+ try {
1394
+ const payload = await withDeadline(loadCommitFileDiff(workspace.alias, root, hash, rel), gitDeadlineMs(), t('editor.readTimeout'))
1395
+ patchTab(id, {
1396
+ loading: false,
1397
+ diff: payload,
1398
+ model: payload.unsupported ? undefined : buildDiffModel(payload),
1399
+ binary: payload.unsupported,
1400
+ })
1401
+ } catch (error) {
1402
+ patchTab(id, { loading: false, error: error instanceof Error ? error.message : String(error) })
1403
+ }
1404
+ })()
1405
+ }
1406
+
1407
+ export function activateTab(id: string): void {
1408
+ patch({ activeTabId: id })
1409
+ }
1410
+
1411
+ export function closeTab(id: string): void {
1412
+ const index = state.tabs.findIndex(tab => tab.id === id)
1413
+ const next = state.tabs.filter(tab => tab.id !== id)
1414
+ const activeTabId = state.activeTabId === id
1415
+ ? (next[Math.min(index, next.length - 1)]?.id ?? next[next.length - 1]?.id)
1416
+ : state.activeTabId
1417
+ patch({ tabs: next, activeTabId })
1418
+ }
1419
+
1420
+ export function updateTab(id: string, next: Partial<EditorTab>): void {
1421
+ patchTab(id, next)
1422
+ }
1423
+
1424
+ /** Write one tab back to the remote host. */
1425
+ export async function saveTab(id: string, content: string): Promise<void> {
1426
+ const workspace = currentWorkspace()
1427
+ const tab = state.tabs.find(item => item.id === id)
1428
+ if (workspace === undefined || tab === undefined || tab.kind !== 'file' || tab.binary || tab.truncated) return
1429
+ // Never fall back to the tab's loaded text. `tab.text` is only ever written
1430
+ // by a load/reload, never by typing, so substituting it here would push the
1431
+ // PRE-EDIT bytes to the remote file and report success — destroying the
1432
+ // user's edit with a confirmation. A caller without a live buffer must
1433
+ // refuse to save instead (see EditorPane.saveActive).
1434
+ if (typeof content !== 'string') {
1435
+ toast(t('editor.needSource'), true)
1436
+ return
1437
+ }
1438
+ const text = content
1439
+ patchTab(id, { saving: true, text })
1440
+ try {
1441
+ await api.fileOp.write(workspace.alias, tab.fullPath, text)
1442
+ patchTab(id, { saving: false, dirty: false })
1443
+ toast(`${t('editor.saved')} · ${tab.rel}`, false)
1444
+ await afterWrite()
1445
+ } catch (error) {
1446
+ patchTab(id, { saving: false })
1447
+ toast(error instanceof Error ? error.message : String(error), true)
1448
+ }
1449
+ }
1450
+
1451
+ /**
1452
+ * Re-read one tab (file content or diff payload).
1453
+ *
1454
+ * A FILE tab's content has to reach the mounted editor through a REMOUNT, which
1455
+ * is why the nonce below exists: `CodeEditor` reads its `initial` prop once, on
1456
+ * mount (`key` = tab identity), so patching `tab.text` alone leaves the live
1457
+ * buffer on the OLD bytes — the screen would show content the remote file no
1458
+ * longer has, and the next Ctrl+S would write those stale bytes back, silently
1459
+ * undoing whatever changed the file (a hover-revert, a discard, another tool).
1460
+ * `EditorPane` folds `reloadNonce` into the editor's `key`; bumping it here is
1461
+ * what makes the re-read visible, and the remount restores scroll + caret from
1462
+ * the per-tab view state (see `.spike/viewstate-check.mjs`).
1463
+ *
1464
+ * ORDER IS LOAD-BEARING: text and `dirty` are patched FIRST, the nonce SECOND.
1465
+ * The remount reads `initial` from the tab, so a nonce bump that raced ahead of
1466
+ * the new text would mount the editor on the old content — exactly the bug this
1467
+ * is fixing, only harder to see.
1468
+ */
1469
+ export async function reloadTab(id: string): Promise<void> {
1470
+ const workspace = currentWorkspace()
1471
+ const tab = state.tabs.find(item => item.id === id)
1472
+ if (workspace === undefined || tab === undefined) return
1473
+ if (tab.kind === 'file') {
1474
+ try {
1475
+ const read = await withDeadline(api.fileOp.read(workspace.alias, tab.fullPath), fileReadDeadlineMs(), t('editor.readTimeout'))
1476
+ patchTab(id, {
1477
+ loading: false,
1478
+ error: undefined,
1479
+ text: read.binary ? '' : (read.text ?? ''),
1480
+ binary: read.binary,
1481
+ truncated: read.truncated,
1482
+ dirty: false,
1483
+ })
1484
+ // Only now: the new text must already be in the tab when the editor remounts.
1485
+ patchTab(id, { reloadNonce: (tab.reloadNonce ?? 0) + 1 })
1486
+ } catch (error) {
1487
+ patchTab(id, { error: error instanceof Error ? error.message : String(error) })
1488
+ }
1489
+ return
1490
+ }
1491
+ const root = state.repoRoot
1492
+ if (root === undefined) return
1493
+ try {
1494
+ const payload = tab.ref !== undefined
1495
+ ? await withDeadline(loadCommitFileDiff(workspace.alias, root, tab.ref, tab.rel), gitDeadlineMs(), t('editor.readTimeout'))
1496
+ : await withDeadline(loadDiff(workspace.alias, root, tab.rel, tab.staged, tab.untracked), gitDeadlineMs(), t('editor.readTimeout'))
1497
+ patchTab(id, {
1498
+ loading: false,
1499
+ error: undefined,
1500
+ diff: payload,
1501
+ binary: payload.unsupported,
1502
+ model: payload.unsupported ? undefined : buildDiffModel(payload),
1503
+ dirty: false,
1504
+ })
1505
+ } catch (error) {
1506
+ patchTab(id, { error: error instanceof Error ? error.message : String(error) })
1507
+ }
1508
+ }
1509
+
1510
+ /** Note where the editor pane is mounted (set by the client entry). */
1511
+ export function setEditorSurface(surface: 'center' | 'sidebar'): void {
1512
+ patch({ editorSurface: surface })
1513
+ }
1514
+
1515
+ /**
1516
+ * 换一份「本会话改了什么」的标记。
1517
+ *
1518
+ * `activeHunk` 归零是刻意的:数据换了一份,上一份里"跳到第 3 处"的下标没有任何
1519
+ * 意义。0 表示"当前停在第 1 处"(打开这个文件的调用方本来就会把 caret 放到
1520
+ * `hunks[0]`),所以紧接着按"下一个"会走到第 2 处,不会把第 1 处跳过。
1521
+ */
1522
+ export function applyChangeSet(tabId: string, set: ChangeSet | null): void {
1523
+ patchTab(tabId, { changeSet: set, activeHunk: 0, changeStale: false })
1524
+ }
1525
+
1526
+ /**
1527
+ * 在改动点之间前后移动(到头绕回),返回目标行号(1-based)。
1528
+ *
1529
+ * `activeHunk` 是 `changeSet.hunks` 的 **0-based 下标**,`<0` / 缺省表示"这批数据
1530
+ * 里还没跳过":第一次向后跳落在第 1 处、向前跳落在最后一处 —— 用户按下快捷键期
1531
+ * 待的是"带我去看改动",而不是"从第 1 处跳到第 2 处、把第 1 处跳过"。
1532
+ *
1533
+ * 跳转目标顺手写进 tab(`revealHunkLine` + 递增的 `revealHunkNonce`),编辑器通过
1534
+ * props 收到后自己选中并滚动;调用方(面板按钮、键盘)只需要调这一个函数。这与
1535
+ * 面板里内联的那套步进算法是同一套下标语义,两边混用也不会错位。
1536
+ */
1537
+ export function gotoChange(tabId: string, dir: 1 | -1): number | undefined {
1538
+ const tab = state.tabs.find(item => item.id === tabId)
1539
+ const hunks = tab?.changeSet?.hunks ?? []
1540
+ if (tab === undefined || hunks.length === 0) return undefined
1541
+ const current = tab.activeHunk ?? -1
1542
+ const next = current < 0 || current >= hunks.length
1543
+ ? (dir > 0 ? 0 : hunks.length - 1)
1544
+ : (current + dir + hunks.length) % hunks.length
1545
+ const line = hunks[next]
1546
+ if (line === undefined) return undefined
1547
+ patchTab(tabId, {
1548
+ activeHunk: next,
1549
+ revealHunkLine: line,
1550
+ revealHunkNonce: (tab.revealHunkNonce ?? 0) + 1,
1551
+ })
1552
+ return line
1553
+ }
1554
+
1555
+ /**
1556
+ * 编辑器报告"文档已经和改动数据脱钩"(用户敲了字)。
1557
+ *
1558
+ * 只打标记、不做别的:标记本身已在编辑器内部被清空(见 `change-marks.ts`),
1559
+ * 重新拉数据是上层的事,仓库在这一层不发起任何请求。
1560
+ */
1561
+ export function markChangeStale(tabId: string): void {
1562
+ const tab = state.tabs.find(item => item.id === tabId)
1563
+ if (tab === undefined || tab.changeStale === true) return
1564
+ patchTab(tabId, { changeStale: true })
1565
+ }
1566
+
1567
+ /**
1568
+ * 撤销「本会话在某个文件里的一处改动」(改动行上那个悬浮按钮点下去的动作)。
1569
+ *
1570
+ * 语义是**把远端工作区里这一块反向应用回去**,不是编辑器 buffer 的 undo:真正的反向
1571
+ * 操作由 `applyHunk(..., 'revert')` 在主机上跑 `git apply -R` 完成,hunk 的上下文对
1572
+ * 不上时它自己会拒绝 —— 那正是冲突护栏。这里绝不自己算文本替换:浏览器一侧没有基线
1573
+ * 内容,重算出来的区间只会把别人的改动一起盖掉。
1574
+ *
1575
+ * 每一步的先后都不是随意的:
1576
+ * 1. 编辑器里有未保存内容时**直接拒绝**:用远端补丁盖掉用户还没保存的编辑是最糟的
1577
+ * 结果(既丢编辑,又让"撤销"变成"覆盖")。
1578
+ * 2. 没有撤销坐标就什么也不做:改动列表没在屏幕上时,"哪台主机、哪个仓库"是未知数。
1579
+ * 3. 没有补丁(二进制文件、非文本 diff、`hunk` 下标对不上)同样什么也不做。
1580
+ * 4. 成功后才作废这批标记、再重读文件:标记描述的是"撤销前那一刻"的行号,撤销后它们
1581
+ * 全部失效;重读让标签页的数字回到磁盘上的事实。
1582
+ * 5. 失败时**不清标记、不重读**:远端一个字节都没动,屏幕上那份标记仍然是事实。
1583
+ */
1584
+ /**
1585
+ * Re-read one file's change set from the host and hand it to the editor.
1586
+ *
1587
+ * The host reads the worktree for every `diff` call, so the marks this installs
1588
+ * describe the file as it is right now — which is what makes them survive a
1589
+ * revert (or any other external write) without the user re-opening the file.
1590
+ */
1591
+ async function refreshChangeSet(tabId: string, context: UndoContext): Promise<void> {
1592
+ const tab = state.tabs.find(item => item.id === tabId)
1593
+ if (tab === undefined || tab.kind !== 'file') return
1594
+ try {
1595
+ const diff = await api.changes.diff(context.sessionId, tab.fullPath)
1596
+ if (diff === null || diff.kind !== 'text' || diff.hunks.length === 0) {
1597
+ applyChangeSet(tabId, null)
1598
+ return
1599
+ }
1600
+ applyChangeSet(tabId, changeSetFromHunks(diff.hunks, `${tab.fullPath}@${Date.now()}`))
1601
+ } catch {
1602
+ // A read that fails leaves no marks rather than wrong ones.
1603
+ applyChangeSet(tabId, null)
1604
+ }
1605
+ }
1606
+
1607
+ export async function undoChange(tabId: string, mark: ChangeMark): Promise<void> {
1608
+ const tab = state.tabs.find(item => item.id === tabId)
1609
+ if (tab === undefined) {
1610
+ info(t('changes.undoMissing'))
1611
+ return
1612
+ }
1613
+ if (tab.dirty === true) {
1614
+ info(t('changes.undoDirty'))
1615
+ return
1616
+ }
1617
+ const context = undoContext()
1618
+ if (context === undefined) {
1619
+ info(t('changes.undoNoContext'))
1620
+ return
1621
+ }
1622
+ const patch = tab.changeSet?.patches?.[mark.hunk ?? -1]
1623
+ if (patch === undefined || patch.trim() === '') {
1624
+ info(t('changes.undoMissing'))
1625
+ return
1626
+ }
1627
+ try {
1628
+ // 'revert' = 远端 `git apply -R`;上下文不匹配它就抛错,这就是冲突护栏。
1629
+ await applyHunk(context.alias, context.root, patch, 'revert')
1630
+ } catch (error) {
1631
+ // 可把 git 的说法附在后面("patch does not apply" 之类),但**不动**标记与文件。
1632
+ const detail = error instanceof Error ? error.message.trim() : String(error).trim()
1633
+ info(detail === '' ? t('changes.undoConflict') : `${t('changes.undoConflict')} · ${detail}`)
1634
+ return
1635
+ }
1636
+ // Refresh the marks instead of dropping them, and put the caret back where the
1637
+ // revert happened.
1638
+ //
1639
+ // Dropping them was the old behaviour and it broke the flow in three ways: the
1640
+ // editor's floating prev/next control disappeared until the file was clicked
1641
+ // again, the remount re-ran the "reveal line" the file was opened with (hunk
1642
+ // one, i.e. the top of the file) so the caret jumped away from the revert, and
1643
+ // the remaining changes lost their marks. The host re-reads the file for this
1644
+ // call, so the set describes the file as it is now.
1645
+ const anchor = mark.from
1646
+ await reloadTab(tabId)
1647
+ await refreshChangeSet(tabId, context)
1648
+ const after = state.tabs.find(item => item.id === tabId)
1649
+ patchTab(tabId, {
1650
+ // The line the tab was opened with no longer exists in this file's history;
1651
+ // left in place it would drag the caret to the top on the next remount.
1652
+ line: undefined,
1653
+ lineNonce: undefined,
1654
+ revealHunkLine: anchor,
1655
+ revealHunkNonce: (after?.revealHunkNonce ?? 0) + 1,
1656
+ })
1657
+ info(t('changes.undone'))
1658
+ // The host still holds the pre-revert comparison, so the panel would keep the
1659
+ // old counts (and re-opening the file would paint the OLD hunks against the
1660
+ // NEW buffer). Force a scan now: the list is the user's evidence that the
1661
+ // revert happened, and it must not lag the editor by a turn.
1662
+ void api.changes.list(context.sessionId, true).catch(() => undefined)
1663
+ }
1664
+
1665
+ /** Mount-time hook: boot the store for a given ctx. */
1666
+ export function useBootstrap(ctx: Context): void {
1667
+ useEffect(() => {
1668
+ bootstrap(ctx)
1669
+ }, [ctx])
1670
+ }