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.
- package/LICENSE +21 -0
- package/README.en.md +385 -0
- package/README.md +347 -2
- package/cordis.patch.yml +259 -0
- package/icon.svg +7 -0
- package/lib/client.js +62602 -0
- package/lib/index.js +4287 -0
- package/lib/p2-bridge-refs.js +10 -0
- package/lib/p2-bridge.js +16 -0
- package/lib/p2-router-fs.js +252 -0
- package/lib/p2-router-refs.js +466 -0
- package/lib/p2-router-spill.js +62 -0
- package/lib/p2-router-subprocess.js +64 -0
- package/lib/p2-shell-bash.js +24 -0
- package/lib/ssh-proc-arI5lm4W.js +632 -0
- package/lib/types/changes/recorder.d.ts +240 -0
- package/lib/types/changes/status.d.ts +201 -0
- package/lib/types/changes/text-diff.d.ts +65 -0
- package/lib/types/client/CodeEditor.d.ts +125 -0
- package/lib/types/client/Dialog.d.ts +14 -0
- package/lib/types/client/DirectoryFlow.d.ts +30 -0
- package/lib/types/client/EditorPane.d.ts +30 -0
- package/lib/types/client/HostsView.d.ts +10 -0
- package/lib/types/client/NativeExplorer.d.ts +39 -0
- package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
- package/lib/types/client/RemoteEditorView.d.ts +20 -0
- package/lib/types/client/RemoteExplorer.d.ts +18 -0
- package/lib/types/client/RemoteRoot.d.ts +16 -0
- package/lib/types/client/SearchView.d.ts +14 -0
- package/lib/types/client/SettingsHosts.d.ts +15 -0
- package/lib/types/client/TerminalView.d.ts +16 -0
- package/lib/types/client/WorkspaceWizard.d.ts +16 -0
- package/lib/types/client/WorkspacesView.d.ts +14 -0
- package/lib/types/client/XTerm.d.ts +15 -0
- package/lib/types/client/api.d.ts +162 -0
- package/lib/types/client/changes/ChangesDock.d.ts +35 -0
- package/lib/types/client/changes/model.d.ts +36 -0
- package/lib/types/client/changes/undo-affordance.d.ts +48 -0
- package/lib/types/client/changes/undo-context.d.ts +25 -0
- package/lib/types/client/composer.d.ts +165 -0
- package/lib/types/client/i18n.d.ts +17 -0
- package/lib/types/client/index.d.ts +14 -0
- package/lib/types/client/remote-file-open-target.d.ts +247 -0
- package/lib/types/client/remote-file-open.d.ts +35 -0
- package/lib/types/client/reveal.d.ts +15 -0
- package/lib/types/client/scm/DiffView.d.ts +24 -0
- package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
- package/lib/types/client/scm/RepoPicker.d.ts +24 -0
- package/lib/types/client/scm/ScmPanel.d.ts +15 -0
- package/lib/types/client/scm/actions.d.ts +170 -0
- package/lib/types/client/scm/diff-model.d.ts +29 -0
- package/lib/types/client/scm/stage-queue.d.ts +99 -0
- package/lib/types/client/scm/status-map.d.ts +157 -0
- package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
- package/lib/types/client/selection-popup.d.ts +61 -0
- package/lib/types/client/store.d.ts +437 -0
- package/lib/types/client/theme.d.ts +15 -0
- package/lib/types/client/ui.d.ts +46 -0
- package/lib/types/client/upload-plan.d.ts +78 -0
- package/lib/types/client/upload-source.d.ts +46 -0
- package/lib/types/client/use-remote-chat-target.d.ts +17 -0
- package/lib/types/client/vsc/FindWidget.d.ts +81 -0
- package/lib/types/client/vsc/change-marks.d.ts +150 -0
- package/lib/types/client/vsc/editor-langs.d.ts +5 -0
- package/lib/types/client/vsc/editor-palette.d.ts +37 -0
- package/lib/types/client/vsc/editor-theme.d.ts +32 -0
- package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
- package/lib/types/client/vsc/kit.d.ts +195 -0
- package/lib/types/client/vsc/markdown.d.ts +11 -0
- package/lib/types/client/vsc/reveal-line.d.ts +38 -0
- package/lib/types/client/vsc/tokenize.d.ts +104 -0
- package/lib/types/deploy.d.ts +94 -0
- package/lib/types/fs-ops.d.ts +8 -0
- package/lib/types/git-format.d.ts +34 -0
- package/lib/types/git-ops.d.ts +183 -0
- package/lib/types/http.d.ts +14 -0
- package/lib/types/importers.d.ts +20 -0
- package/lib/types/index.d.ts +87 -0
- package/lib/types/keygen.d.ts +19 -0
- package/lib/types/loopback.d.ts +7 -0
- package/lib/types/p2/bridge-refs.d.ts +21 -0
- package/lib/types/p2/bridge.d.ts +24 -0
- package/lib/types/p2/file-refs.d.ts +73 -0
- package/lib/types/p2/helper-rpc.d.ts +63 -0
- package/lib/types/p2/in-flight-gate.d.ts +25 -0
- package/lib/types/p2/router-fs.d.ts +92 -0
- package/lib/types/p2/router-refs.d.ts +45 -0
- package/lib/types/p2/router-spill.d.ts +11 -0
- package/lib/types/p2/router-subprocess.d.ts +23 -0
- package/lib/types/p2/shell-bash.d.ts +16 -0
- package/lib/types/p2/ssh-proc.d.ts +22 -0
- package/lib/types/p2/worlds.d.ts +179 -0
- package/lib/types/paths.d.ts +12 -0
- package/lib/types/pool.d.ts +105 -0
- package/lib/types/protocol.d.ts +452 -0
- package/lib/types/routes.d.ts +27 -0
- package/lib/types/search-ops.d.ts +68 -0
- package/lib/types/sftp.d.ts +90 -0
- package/lib/types/store.d.ts +66 -0
- package/lib/types/terminal.d.ts +51 -0
- package/lib/types/timeouts.d.ts +83 -0
- package/lib/types/tools.d.ts +41 -0
- package/lib/worlds-PUumRIh7.js +18765 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +189 -4
- package/src/changes/recorder.ts +902 -0
- package/src/changes/status.ts +448 -0
- package/src/changes/text-diff.ts +377 -0
- package/src/client/CodeEditor.tsx +923 -0
- package/src/client/Dialog.tsx +76 -0
- package/src/client/DirectoryFlow.tsx +431 -0
- package/src/client/EditorPane.tsx +512 -0
- package/src/client/HostsView.tsx +312 -0
- package/src/client/NativeExplorer.tsx +121 -0
- package/src/client/RemoteDirectoryPicker.tsx +157 -0
- package/src/client/RemoteEditorView.tsx +54 -0
- package/src/client/RemoteExplorer.tsx +1398 -0
- package/src/client/RemoteRoot.tsx +389 -0
- package/src/client/SearchView.tsx +407 -0
- package/src/client/SettingsHosts.tsx +136 -0
- package/src/client/TerminalView.tsx +308 -0
- package/src/client/WorkspaceWizard.tsx +199 -0
- package/src/client/WorkspacesView.tsx +93 -0
- package/src/client/XTerm.tsx +308 -0
- package/src/client/api.ts +331 -0
- package/src/client/changes/ChangesDock.tsx +348 -0
- package/src/client/changes/changes.css +230 -0
- package/src/client/changes/model.ts +347 -0
- package/src/client/changes/undo-affordance.ts +389 -0
- package/src/client/changes/undo-context.ts +34 -0
- package/src/client/composer.ts +436 -0
- package/src/client/css.d.ts +7 -0
- package/src/client/i18n.ts +702 -0
- package/src/client/index.tsx +286 -0
- package/src/client/remote-file-open-target.ts +553 -0
- package/src/client/remote-file-open.ts +261 -0
- package/src/client/reveal.ts +33 -0
- package/src/client/scm/DiffView.tsx +570 -0
- package/src/client/scm/HistoryPanel.tsx +353 -0
- package/src/client/scm/RepoPicker.tsx +238 -0
- package/src/client/scm/ScmPanel.tsx +857 -0
- package/src/client/scm/actions.ts +460 -0
- package/src/client/scm/diff-model.ts +206 -0
- package/src/client/scm/stage-queue.ts +264 -0
- package/src/client/scm/status-map.ts +381 -0
- package/src/client/scm/untracked-dirs.ts +139 -0
- package/src/client/selection-popup.ts +157 -0
- package/src/client/store.tsx +1670 -0
- package/src/client/theme.ts +54 -0
- package/src/client/ui.tsx +136 -0
- package/src/client/upload-plan.ts +153 -0
- package/src/client/upload-source.ts +145 -0
- package/src/client/use-remote-chat-target.ts +153 -0
- package/src/client/vsc/FindWidget.tsx +446 -0
- package/src/client/vsc/change-marks.ts +586 -0
- package/src/client/vsc/editor-langs.ts +137 -0
- package/src/client/vsc/editor-palette.ts +107 -0
- package/src/client/vsc/editor-theme.ts +93 -0
- package/src/client/vsc/fallback-highlight.ts +145 -0
- package/src/client/vsc/kit.tsx +777 -0
- package/src/client/vsc/markdown.tsx +172 -0
- package/src/client/vsc/reveal-line.ts +142 -0
- package/src/client/vsc/tokenize.ts +569 -0
- package/src/client/vsc/vsc.css +2669 -0
- package/src/deploy.ts +481 -0
- package/src/fs-ops.ts +57 -0
- package/src/git-format.ts +205 -0
- package/src/git-ops.ts +693 -0
- package/src/http.ts +55 -0
- package/src/importers.ts +141 -0
- package/src/index.ts +442 -0
- package/src/keygen.ts +77 -0
- package/src/loopback.ts +47 -0
- package/src/p2/bridge-refs.ts +27 -0
- package/src/p2/bridge.ts +32 -0
- package/src/p2/file-refs.ts +392 -0
- package/src/p2/helper-rpc.ts +394 -0
- package/src/p2/in-flight-gate.ts +63 -0
- package/src/p2/router-fs.ts +327 -0
- package/src/p2/router-refs.ts +166 -0
- package/src/p2/router-spill.ts +75 -0
- package/src/p2/router-subprocess.ts +92 -0
- package/src/p2/shell-bash.ts +30 -0
- package/src/p2/ssh-proc.ts +802 -0
- package/src/p2/worlds.ts +735 -0
- package/src/paths.ts +50 -0
- package/src/pool.ts +811 -0
- package/src/protocol.ts +460 -0
- package/src/routes.ts +802 -0
- package/src/search-ops.ts +220 -0
- package/src/sftp.ts +677 -0
- package/src/store.ts +364 -0
- package/src/terminal.ts +291 -0
- package/src/timeouts.ts +122 -0
- package/src/tools.ts +320 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
- package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
- 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
|
+
}
|