dsh-remote-vs 0.9.3 → 0.11.0

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 (44) hide show
  1. package/README.en.md +13 -10
  2. package/README.md +14 -9
  3. package/cordis.patch.yml +10 -5
  4. package/lib/client.js +3319 -1093
  5. package/lib/index.js +211 -9
  6. package/lib/types/client/api.d.ts +12 -0
  7. package/lib/types/client/composer.d.ts +5 -1
  8. package/lib/types/client/exec-output.d.ts +58 -0
  9. package/lib/types/client/i18n.d.ts +1 -1
  10. package/lib/types/client/scm/CommitPane.d.ts +73 -0
  11. package/lib/types/client/scm/ExecPanel.d.ts +34 -0
  12. package/lib/types/client/scm/HistoryPanel.d.ts +12 -4
  13. package/lib/types/client/scm/actions.d.ts +50 -3
  14. package/lib/types/client/scm/commit-model.d.ts +119 -0
  15. package/lib/types/client/store.d.ts +9 -3
  16. package/lib/types/client/vsc/kit.d.ts +16 -0
  17. package/lib/types/git-format.d.ts +5 -0
  18. package/lib/types/git-ops.d.ts +57 -2
  19. package/lib/types/index.d.ts +5 -0
  20. package/lib/types/pool.d.ts +9 -0
  21. package/lib/types/protocol.d.ts +23 -0
  22. package/lib/types/timeouts.d.ts +17 -2
  23. package/package.json +37 -37
  24. package/src/client/SettingsHosts.tsx +2 -1
  25. package/src/client/api.ts +61 -0
  26. package/src/client/composer.ts +11 -2
  27. package/src/client/exec-output.ts +129 -0
  28. package/src/client/i18n.ts +133 -4
  29. package/src/client/scm/CommitPane.tsx +726 -0
  30. package/src/client/scm/ExecPanel.tsx +124 -0
  31. package/src/client/scm/HistoryPanel.tsx +483 -104
  32. package/src/client/scm/ScmPanel.tsx +247 -9
  33. package/src/client/scm/actions.ts +93 -10
  34. package/src/client/scm/commit-model.ts +317 -0
  35. package/src/client/store.tsx +13 -6
  36. package/src/client/vsc/kit.tsx +71 -0
  37. package/src/client/vsc/vsc.css +647 -0
  38. package/src/git-format.ts +7 -3
  39. package/src/git-ops.ts +83 -7
  40. package/src/index.ts +10 -2
  41. package/src/pool.ts +84 -4
  42. package/src/protocol.ts +14 -0
  43. package/src/routes.ts +135 -1
  44. package/src/timeouts.ts +18 -2
@@ -0,0 +1,317 @@
1
+ /**
2
+ * Pure model behind the history panel's commit pane.
3
+ *
4
+ * The pane answers three questions about the selected commit — who/when/what
5
+ * it says (`details`), which files it touched (`content`) and what changed
6
+ * (`diff`). Only the last one needs the patch, so this module turns one
7
+ * `git show --patch` text into the per-file blocks the view renders: file
8
+ * header (path, rename source, status letter, +/− counts), hunk rows carrying
9
+ * BOTH line numbers (a patch already narrates the pairing), and `skip` rows
10
+ * where a hunk jumps ahead.
11
+ *
12
+ * Everything here is pure so the local self-check (the `scripts/` and `.spike/`
13
+ * harnesses are gitignored and not shipped) can pin it, including a replay
14
+ * against a real repository's `git show --stat`: a hand-written expectation
15
+ * would only encode my idea of git's output.
16
+ *
17
+ * No full-file context is available: a commit patch is read-only and git only
18
+ * ships the hunks. That is deliberate — building the two full revisions per
19
+ * file would cost one `git show <rev>:<path>` per side per file just to paint
20
+ * unchanged lines nobody asked for.
21
+ */
22
+ import { parseUnifiedDiff } from '../../git-ops.ts'
23
+ import type { GitCommitFile } from '../../protocol.ts'
24
+
25
+ /** The pane's three views, in strip order. */
26
+ export const HISTORY_TABS = ['details', 'content', 'diff'] as const
27
+
28
+ export type HistoryTab = (typeof HISTORY_TABS)[number]
29
+
30
+ /** Guard for persisted values (`localStorage` may hold anything). */
31
+ export function isHistoryTab(value: unknown): value is HistoryTab {
32
+ return typeof value === 'string' && (HISTORY_TABS as readonly string[]).includes(value)
33
+ }
34
+
35
+ /**
36
+ * Keyboard navigation for the pane's tab strip: arrows cycle (wrapping, so the
37
+ * last tab's → returns to the first), Home/End jump to the ends. `undefined`
38
+ * means "not a navigation key" and the caller leaves the event alone.
39
+ */
40
+ export function stepHistoryTab(current: HistoryTab, key: string): HistoryTab | undefined {
41
+ const index = HISTORY_TABS.indexOf(current)
42
+ if (key === 'Home') return HISTORY_TABS[0]
43
+ if (key === 'End') return HISTORY_TABS[HISTORY_TABS.length - 1]
44
+ if (key === 'ArrowLeft') return HISTORY_TABS[(index - 1 + HISTORY_TABS.length) % HISTORY_TABS.length]
45
+ if (key === 'ArrowRight') return HISTORY_TABS[(index + 1) % HISTORY_TABS.length]
46
+ return undefined
47
+ }
48
+
49
+ /** The state the 差异 tab's lazy read keeps for one commit. */
50
+ export interface PatchLoadState {
51
+ key: string
52
+ text?: string
53
+ truncated?: boolean
54
+ error?: string
55
+ }
56
+
57
+ /**
58
+ * Whether the 差异 tab still has to read the patch for `key`.
59
+ *
60
+ * The subtle case is a SUPERSEDED read: switching the tab or the commit while
61
+ * a read is in flight used to leave the key marker behind with no data, and an
62
+ * equality-only guard (`state.key === key`) then treated that marker as
63
+ * "already loaded" forever — the reported "点击文件后差异一直说没有改动" bug.
64
+ * A marker without text/error therefore means RETRY, never "done".
65
+ */
66
+ export function needsPatchLoad(state: PatchLoadState, key: string, tab: HistoryTab): boolean {
67
+ if (tab !== 'diff' || key === '') return false
68
+ if (state.key !== key) return true
69
+ return state.text === undefined && state.error === undefined
70
+ }
71
+
72
+ /** One rendered patch line. */
73
+ export interface PatchRow {
74
+ kind: 'context' | 'add' | 'del' | 'skip'
75
+ /** Old-revision line number; null on added lines and skip markers. */
76
+ oldLine: number | null
77
+ /** New-revision line number; null on deleted lines and skip markers. */
78
+ newLine: number | null
79
+ /** Line content without the leading ` ` / `+` / `-` marker. */
80
+ text: string
81
+ }
82
+
83
+ /** One file block of a commit patch. */
84
+ export interface CommitPatchFile {
85
+ /** Destination path (the source for deletions). */
86
+ path: string
87
+ /** Source path when the block is a rename/copy (equals `path` otherwise). */
88
+ oldPath: string
89
+ status: string
90
+ binary: boolean
91
+ added: number
92
+ deleted: number
93
+ rows: PatchRow[]
94
+ /** True when the global row cap kept this block's rows (partly) out. */
95
+ truncated: boolean
96
+ }
97
+
98
+ export interface CommitPatchModel {
99
+ files: CommitPatchFile[]
100
+ added: number
101
+ deleted: number
102
+ /** True when the row cap cut the model (later blocks may have no rows). */
103
+ truncated: boolean
104
+ }
105
+
106
+ /**
107
+ * Rendered-row ceiling for one commit patch.
108
+ *
109
+ * A commit that rewrites a generated file can carry hundreds of thousands of
110
+ * lines; painting them all freezes the sidebar on a click. The header counts
111
+ * stay exact (they come from the parsed hunks, not from the rendered rows), so
112
+ * the truncation notice only claims what is actually missing: the paint.
113
+ */
114
+ export const PATCH_ROW_CAP = 6000
115
+
116
+ /** Build the render model for one commit's `git show --patch` text. */
117
+ export function buildCommitPatch(text: string, rowCap = PATCH_ROW_CAP): CommitPatchModel {
118
+ const files: CommitPatchFile[] = []
119
+ let added = 0
120
+ let deleted = 0
121
+ let painted = 0
122
+ let truncated = false
123
+
124
+ for (const file of parseUnifiedDiff(text)) {
125
+ const rows: PatchRow[] = []
126
+ let fileAdded = 0
127
+ let fileDeleted = 0
128
+ let fileTruncated = false
129
+ /** End of the previous hunk, so the next one can name its jump. */
130
+ let oldEnd = 1
131
+ let newEnd = 1
132
+ for (const [index, hunk] of file.hunks.entries()) {
133
+ // The unshown gap before a hunk: git itself prints nothing there, but a
134
+ // reader of a unified patch needs to know the line numbers skipped.
135
+ const gap = hunk.newStart - newEnd
136
+ const oldGap = hunk.oldStart - oldEnd
137
+ if (index === 0 ? hunk.oldStart > 1 || hunk.newStart > 1 : gap > 0 || oldGap > 0) {
138
+ if (painted < rowCap) {
139
+ rows.push({ kind: 'skip', oldLine: null, newLine: null, text: '' })
140
+ painted += 1
141
+ } else {
142
+ fileTruncated = true
143
+ }
144
+ }
145
+ let oldLine = hunk.oldStart
146
+ let newLine = hunk.newStart
147
+ for (const line of hunk.lines) {
148
+ if (line.startsWith('\\')) continue
149
+ const marker = line[0]
150
+ if (marker === '+') {
151
+ fileAdded += 1
152
+ if (painted < rowCap) {
153
+ rows.push({ kind: 'add', oldLine: null, newLine, text: line.slice(1) })
154
+ painted += 1
155
+ } else {
156
+ fileTruncated = true
157
+ }
158
+ newLine += 1
159
+ continue
160
+ }
161
+ if (marker === '-') {
162
+ fileDeleted += 1
163
+ if (painted < rowCap) {
164
+ rows.push({ kind: 'del', oldLine, newLine: null, text: line.slice(1) })
165
+ painted += 1
166
+ } else {
167
+ fileTruncated = true
168
+ }
169
+ oldLine += 1
170
+ continue
171
+ }
172
+ if (painted < rowCap) {
173
+ rows.push({ kind: 'context', oldLine, newLine, text: line.slice(1) })
174
+ painted += 1
175
+ } else {
176
+ fileTruncated = true
177
+ }
178
+ oldLine += 1
179
+ newLine += 1
180
+ }
181
+ oldEnd = oldLine
182
+ newEnd = newLine
183
+ }
184
+ added += fileAdded
185
+ deleted += fileDeleted
186
+ if (fileTruncated) truncated = true
187
+ files.push({
188
+ path: file.path,
189
+ oldPath: file.oldPath,
190
+ status: file.status,
191
+ binary: file.binary,
192
+ added: fileAdded,
193
+ deleted: fileDeleted,
194
+ rows,
195
+ truncated: fileTruncated,
196
+ })
197
+ }
198
+
199
+ return { files, added, deleted, truncated }
200
+ }
201
+
202
+ /** The file block a path selects in the diff view (rename-aware). */
203
+ export function blockForPath(model: CommitPatchModel, path: string): string | undefined {
204
+ if (path === '') return undefined
205
+ for (const file of model.files) {
206
+ if (file.path === path || file.oldPath === path) return file.path
207
+ }
208
+ return undefined
209
+ }
210
+
211
+ /**
212
+ * Split one commit patch into its per-file raw texts, in the SAME order
213
+ * `buildCommitPatch` reports the files.
214
+ *
215
+ * Used by "copy this file's patch": the view still holds the original text, so
216
+ * the exact bytes git produced are what lands on the clipboard — no
217
+ * re-serialization from rows, which would drop headers a future git adds.
218
+ */
219
+ export function splitCommitPatch(text: string): string[] {
220
+ const blocks: string[] = []
221
+ let current: string[] = []
222
+ for (const line of text.split('\n')) {
223
+ if (line.startsWith('diff --git ')) {
224
+ if (current.length > 0) blocks.push(joinBlock(current))
225
+ current = [line]
226
+ continue
227
+ }
228
+ if (current.length > 0) current.push(line)
229
+ }
230
+ if (current.length > 0) blocks.push(joinBlock(current))
231
+ return blocks
232
+ }
233
+
234
+ function joinBlock(lines: string[]): string {
235
+ const text = lines.join('\n')
236
+ return text.endsWith('\n') ? text : `${text}\n`
237
+ }
238
+
239
+ /** One directory of the 内容 tree ('' = repository root). */
240
+ export interface CommitFileGroup {
241
+ dir: string
242
+ files: GitCommitFile[]
243
+ }
244
+
245
+ /**
246
+ * Group one commit's files by directory for the 内容 tree: a commit touching
247
+ * 40 files in 6 folders is a wall of paths as a flat list, and the folder row
248
+ * is also what lets a reader collapse the part they do not care about.
249
+ * Root-level files come first; every level keeps git's own order by path.
250
+ */
251
+ export function groupCommitFiles(files: readonly GitCommitFile[]): CommitFileGroup[] {
252
+ const groups = new Map<string, GitCommitFile[]>()
253
+ for (const file of files) {
254
+ const slash = file.path.lastIndexOf('/')
255
+ const dir = slash < 0 ? '' : file.path.slice(0, slash)
256
+ const list = groups.get(dir)
257
+ if (list === undefined) groups.set(dir, [file])
258
+ else list.push(file)
259
+ }
260
+ return [...groups.entries()]
261
+ .sort(([left], [right]) => {
262
+ if (left === right) return 0
263
+ if (left === '') return -1
264
+ if (right === '') return 1
265
+ return left.localeCompare(right)
266
+ })
267
+ .map(([dir, list]) => ({ dir, files: [...list].sort((a, b) => a.path.localeCompare(b.path)) }))
268
+ }
269
+
270
+ /**
271
+ * The next path in `paths` from `current` (wrapping at both ends), the diff
272
+ * toolbar's prev/next-file math. `current` unknown → the first/last path, so a
273
+ * fresh pane steps into the list instead of doing nothing.
274
+ */
275
+ export function stepPath(paths: readonly string[], current: string | undefined, direction: 1 | -1): string | undefined {
276
+ if (paths.length === 0) return undefined
277
+ const index = current === undefined ? -1 : paths.indexOf(current)
278
+ if (index < 0) return direction === 1 ? paths[0] : paths[paths.length - 1]
279
+ return paths[(index + direction + paths.length) % paths.length]
280
+ }
281
+
282
+ /**
283
+ * Default folding for the 差异 tab: keep the first `rowBudget` rendered rows
284
+ * open, fold every file after that.
285
+ *
286
+ * A 40-file commit painting up to `PATCH_ROW_CAP` rows in a pane that may be
287
+ * 80px tall is a scroll wall where the reader sees none of the topology; the
288
+ * reference opens a curated subset for the same reason. Folding the TAIL (not
289
+ * per-file size) keeps the reading order intact: the files you scrolled to are
290
+ * still in the same place, just collapsed, and one click opens any of them.
291
+ */
292
+ export function autoFoldCommitPatch(model: CommitPatchModel, rowBudget = 1500): string[] {
293
+ const folded: string[] = []
294
+ let painted = 0
295
+ for (const file of model.files) {
296
+ if (painted >= rowBudget) folded.push(file.path)
297
+ else painted += file.rows.length
298
+ }
299
+ return folded
300
+ }
301
+
302
+ /**
303
+ * Ref badge decoration for one `%D` entry (shared by the graph rows and the
304
+ * commit card). Split for the same reason the history panel split them: the
305
+ * CSS modifier and the display text are different jobs.
306
+ */
307
+ export function refBadgeClass(ref: string): string {
308
+ if (ref.startsWith('HEAD ->')) return ' head'
309
+ if (ref.startsWith('tag:')) return ' tag'
310
+ return ''
311
+ }
312
+
313
+ /** Display text of one `%D` entry (`HEAD -> main` → `main`, `tag: v1` → `v1`). */
314
+ export function refBadgeLabel(ref: string): string {
315
+ if (ref.startsWith('HEAD -> ')) return ref.slice('HEAD -> '.length)
316
+ return ref.replace(/^tag: /, '')
317
+ }
@@ -350,7 +350,8 @@ const state: State = {
350
350
  error: undefined,
351
351
  hosts: [],
352
352
  workspaces: [],
353
- routing: { mode: 'off', aliasPaths: {}, tombstones: [] },
353
+ // Pre-bootstrap placeholder: same default as the plugin config (nforce).
354
+ routing: { mode: 'enforce', aliasPaths: {}, tombstones: [] },
354
355
  workspaceId: readWorkspaceId(),
355
356
  status: undefined,
356
357
  head: undefined,
@@ -487,7 +488,7 @@ export interface ExplorerValue {
487
488
  /** 停靠回主界面(底层的中心视图或侧栏表面)。 */
488
489
  dockFloatingEditor: () => void
489
490
  openDiff: (rel: string, staged: boolean, untracked?: boolean) => void
490
- openCommitFile: (hash: string, rel: string) => void
491
+ openCommitFile: (hash: string, rel: string, origPath?: string) => void
491
492
  activateTab: (id: string) => void
492
493
  closeTab: (id: string) => void
493
494
  updateTab: (id: string, patch: Partial<EditorTab>) => void
@@ -1872,13 +1873,19 @@ export function openDiff(rel: string, staged: boolean, untracked = false): void
1872
1873
  })()
1873
1874
  }
1874
1875
 
1875
- /** Open (or focus) one file of one commit (`ref^` against `ref`). */
1876
- export function openCommitFile(hash: string, rel: string): void {
1876
+ /**
1877
+ * Open (or focus) one file of one commit (`ref^` against `ref`).
1878
+ *
1879
+ * `origPath` is the rename source from the commit's file row / patch header:
1880
+ * the two-sided view must diff the OLD path against the NEW one, or git sees
1881
+ * two unrelated paths and paints the rename as a whole-file addition.
1882
+ */
1883
+ export function openCommitFile(hash: string, rel: string, origPath?: string): void {
1877
1884
  captureLiveBuffer()
1878
1885
  const workspace = currentWorkspace()
1879
1886
  const root = state.repoRoot
1880
1887
  if (workspace === undefined || root === undefined || rel === '') return
1881
- const id = `commit:${workspace.alias}:${hash}:${rel}`
1888
+ const id = `commit:${workspace.alias}:${hash}:${rel}${origPath === undefined ? '' : `:<-${origPath}`}`
1882
1889
  upsertTab({
1883
1890
  id,
1884
1891
  kind: 'diff',
@@ -1904,7 +1911,7 @@ export function openCommitFile(hash: string, rel: string): void {
1904
1911
  })
1905
1912
  void (async () => {
1906
1913
  try {
1907
- const payload = await withDeadline(loadCommitFileDiff(workspace.alias, root, hash, rel), gitDeadlineMs(), t('editor.readTimeout'))
1914
+ const payload = await withDeadline(loadCommitFileDiff(workspace.alias, root, hash, rel, origPath), gitDeadlineMs(), t('editor.readTimeout'))
1908
1915
  patchTab(id, {
1909
1916
  loading: false,
1910
1917
  diff: payload,
@@ -51,6 +51,42 @@ export function IconChevronRight(props: IconProps): ReactNode {
51
51
  return svg(props.size ?? 14, <path d="M6 3.5 L10.5 8 L6 12.5" />, props.className)
52
52
  }
53
53
 
54
+ /** Mirror of {@link IconChevronRight}, for the commit pane's "previous file". */
55
+ export function IconChevronLeft(props: IconProps): ReactNode {
56
+ return svg(props.size ?? 14, <path d="M10 3.5 L5.5 8 L10 12.5" />, props.className)
57
+ }
58
+
59
+ /** Expand: two arrows pushing the corners apart (the pane's full-height toggle). */
60
+ export function IconExpand(props: IconProps): ReactNode {
61
+ return svg(props.size ?? 13, (
62
+ <>
63
+ <path d="M9.4 2.6h4v4M13.4 2.6 9 7" />
64
+ <path d="M6.6 13.4h-4v-4M2.6 13.4 7 9" />
65
+ </>
66
+ ), props.className)
67
+ }
68
+
69
+ /** Restore: the same two arrows pulling the corners back in. */
70
+ export function IconCollapse(props: IconProps): ReactNode {
71
+ return svg(props.size ?? 13, (
72
+ <>
73
+ <path d="M13.4 6.6h-4v-4M9.4 6.6 13.4 2.6" />
74
+ <path d="M2.6 9.4h4v4M6.6 9.4 2.6 13.4" />
75
+ </>
76
+ ), props.className)
77
+ }
78
+
79
+ /** Wrap long lines: a line turning back on itself instead of running off. */
80
+ export function IconWrap(props: IconProps): ReactNode {
81
+ return svg(props.size ?? 13, (
82
+ <>
83
+ <path d="M2.6 4.4h8.6a2.6 2.6 0 0 1 0 5.2H6.4" />
84
+ <path d="M8.2 7.4 6.2 9.6l2 2.2" />
85
+ <path d="M2.6 11.6h1.8" />
86
+ </>
87
+ ), props.className)
88
+ }
89
+
54
90
  export function IconChevronDown(props: IconProps): ReactNode {
55
91
  return svg(props.size ?? 14, <path d="M3.5 6 L8 10.5 L12.5 6" />, props.className)
56
92
  }
@@ -90,6 +126,41 @@ export function IconClose(props: IconProps): ReactNode {
90
126
  return svg(props.size ?? 12, <path d="M4 4l8 8M12 4l-8 8" />, props.className)
91
127
  }
92
128
 
129
+ /** Stop: a filled square — the universal "kill this run" mark. */
130
+ export function IconStop(props: IconProps): ReactNode {
131
+ return svg(props.size ?? 12, <rect x="4.2" y="4.2" width="7.6" height="7.6" rx="1.2" fill="currentColor" stroke="none" />, props.className)
132
+ }
133
+
134
+ /** Copy glyph: two offset sheets, the front one the clipboard's payload. */
135
+ export function IconCopy(props: IconProps): ReactNode {
136
+ return svg(props.size ?? 14, (
137
+ <>
138
+ <rect x="5.4" y="5.4" width="7.8" height="7.8" rx="1.2" />
139
+ <path d="M10.6 5.4V3.6a1.2 1.2 0 0 0-1.2-1.2H3.6a1.2 1.2 0 0 0-1.2 1.2v5.8a1.2 1.2 0 0 0 1.2 1.2h1.8" />
140
+ </>
141
+ ), props.className)
142
+ }
143
+
144
+ /** Author glyph for the commit pane's metadata rows. */
145
+ export function IconUser(props: IconProps): ReactNode {
146
+ return svg(props.size ?? 13, (
147
+ <>
148
+ <circle cx="8" cy="5.4" r="2.6" />
149
+ <path d="M2.8 13.4c.5-2.4 2.6-3.8 5.2-3.8s4.7 1.4 5.2 3.8" />
150
+ </>
151
+ ), props.className)
152
+ }
153
+
154
+ /** Date glyph, the pair of {@link IconUser} in the commit pane's metadata. */
155
+ export function IconClock(props: IconProps): ReactNode {
156
+ return svg(props.size ?? 13, (
157
+ <>
158
+ <circle cx="8" cy="8" r="5.6" />
159
+ <path d="M8 4.8V8l2.2 1.6" />
160
+ </>
161
+ ), props.className)
162
+ }
163
+
93
164
  export function IconSearch(props: IconProps): ReactNode {
94
165
  return svg(props.size ?? 16, <path d="M10.6 10.6 14 14M11.8 7.2a4.6 4.6 0 1 1-9.2 0 4.6 4.6 0 0 1 9.2 0Z" />, props.className)
95
166
  }