dsh-remote-vs 0.0.0-stage → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +385 -0
  3. package/README.md +347 -2
  4. package/cordis.patch.yml +259 -0
  5. package/icon.svg +7 -0
  6. package/lib/client.js +62602 -0
  7. package/lib/index.js +4287 -0
  8. package/lib/p2-bridge-refs.js +10 -0
  9. package/lib/p2-bridge.js +16 -0
  10. package/lib/p2-router-fs.js +252 -0
  11. package/lib/p2-router-refs.js +466 -0
  12. package/lib/p2-router-spill.js +62 -0
  13. package/lib/p2-router-subprocess.js +64 -0
  14. package/lib/p2-shell-bash.js +24 -0
  15. package/lib/ssh-proc-arI5lm4W.js +632 -0
  16. package/lib/types/changes/recorder.d.ts +240 -0
  17. package/lib/types/changes/status.d.ts +201 -0
  18. package/lib/types/changes/text-diff.d.ts +65 -0
  19. package/lib/types/client/CodeEditor.d.ts +125 -0
  20. package/lib/types/client/Dialog.d.ts +14 -0
  21. package/lib/types/client/DirectoryFlow.d.ts +30 -0
  22. package/lib/types/client/EditorPane.d.ts +30 -0
  23. package/lib/types/client/HostsView.d.ts +10 -0
  24. package/lib/types/client/NativeExplorer.d.ts +39 -0
  25. package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
  26. package/lib/types/client/RemoteEditorView.d.ts +20 -0
  27. package/lib/types/client/RemoteExplorer.d.ts +18 -0
  28. package/lib/types/client/RemoteRoot.d.ts +16 -0
  29. package/lib/types/client/SearchView.d.ts +14 -0
  30. package/lib/types/client/SettingsHosts.d.ts +15 -0
  31. package/lib/types/client/TerminalView.d.ts +16 -0
  32. package/lib/types/client/WorkspaceWizard.d.ts +16 -0
  33. package/lib/types/client/WorkspacesView.d.ts +14 -0
  34. package/lib/types/client/XTerm.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +162 -0
  36. package/lib/types/client/changes/ChangesDock.d.ts +35 -0
  37. package/lib/types/client/changes/model.d.ts +36 -0
  38. package/lib/types/client/changes/undo-affordance.d.ts +48 -0
  39. package/lib/types/client/changes/undo-context.d.ts +25 -0
  40. package/lib/types/client/composer.d.ts +165 -0
  41. package/lib/types/client/i18n.d.ts +17 -0
  42. package/lib/types/client/index.d.ts +14 -0
  43. package/lib/types/client/remote-file-open-target.d.ts +247 -0
  44. package/lib/types/client/remote-file-open.d.ts +35 -0
  45. package/lib/types/client/reveal.d.ts +15 -0
  46. package/lib/types/client/scm/DiffView.d.ts +24 -0
  47. package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
  48. package/lib/types/client/scm/RepoPicker.d.ts +24 -0
  49. package/lib/types/client/scm/ScmPanel.d.ts +15 -0
  50. package/lib/types/client/scm/actions.d.ts +170 -0
  51. package/lib/types/client/scm/diff-model.d.ts +29 -0
  52. package/lib/types/client/scm/stage-queue.d.ts +99 -0
  53. package/lib/types/client/scm/status-map.d.ts +157 -0
  54. package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
  55. package/lib/types/client/selection-popup.d.ts +61 -0
  56. package/lib/types/client/store.d.ts +437 -0
  57. package/lib/types/client/theme.d.ts +15 -0
  58. package/lib/types/client/ui.d.ts +46 -0
  59. package/lib/types/client/upload-plan.d.ts +78 -0
  60. package/lib/types/client/upload-source.d.ts +46 -0
  61. package/lib/types/client/use-remote-chat-target.d.ts +17 -0
  62. package/lib/types/client/vsc/FindWidget.d.ts +81 -0
  63. package/lib/types/client/vsc/change-marks.d.ts +150 -0
  64. package/lib/types/client/vsc/editor-langs.d.ts +5 -0
  65. package/lib/types/client/vsc/editor-palette.d.ts +37 -0
  66. package/lib/types/client/vsc/editor-theme.d.ts +32 -0
  67. package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
  68. package/lib/types/client/vsc/kit.d.ts +195 -0
  69. package/lib/types/client/vsc/markdown.d.ts +11 -0
  70. package/lib/types/client/vsc/reveal-line.d.ts +38 -0
  71. package/lib/types/client/vsc/tokenize.d.ts +104 -0
  72. package/lib/types/deploy.d.ts +94 -0
  73. package/lib/types/fs-ops.d.ts +8 -0
  74. package/lib/types/git-format.d.ts +34 -0
  75. package/lib/types/git-ops.d.ts +183 -0
  76. package/lib/types/http.d.ts +14 -0
  77. package/lib/types/importers.d.ts +20 -0
  78. package/lib/types/index.d.ts +87 -0
  79. package/lib/types/keygen.d.ts +19 -0
  80. package/lib/types/loopback.d.ts +7 -0
  81. package/lib/types/p2/bridge-refs.d.ts +21 -0
  82. package/lib/types/p2/bridge.d.ts +24 -0
  83. package/lib/types/p2/file-refs.d.ts +73 -0
  84. package/lib/types/p2/helper-rpc.d.ts +63 -0
  85. package/lib/types/p2/in-flight-gate.d.ts +25 -0
  86. package/lib/types/p2/router-fs.d.ts +92 -0
  87. package/lib/types/p2/router-refs.d.ts +45 -0
  88. package/lib/types/p2/router-spill.d.ts +11 -0
  89. package/lib/types/p2/router-subprocess.d.ts +23 -0
  90. package/lib/types/p2/shell-bash.d.ts +16 -0
  91. package/lib/types/p2/ssh-proc.d.ts +22 -0
  92. package/lib/types/p2/worlds.d.ts +179 -0
  93. package/lib/types/paths.d.ts +12 -0
  94. package/lib/types/pool.d.ts +105 -0
  95. package/lib/types/protocol.d.ts +452 -0
  96. package/lib/types/routes.d.ts +27 -0
  97. package/lib/types/search-ops.d.ts +68 -0
  98. package/lib/types/sftp.d.ts +90 -0
  99. package/lib/types/store.d.ts +66 -0
  100. package/lib/types/terminal.d.ts +51 -0
  101. package/lib/types/timeouts.d.ts +83 -0
  102. package/lib/types/tools.d.ts +41 -0
  103. package/lib/worlds-PUumRIh7.js +18765 -0
  104. package/locale/en.json +6 -0
  105. package/locale/zh.json +6 -0
  106. package/package.json +189 -4
  107. package/src/changes/recorder.ts +902 -0
  108. package/src/changes/status.ts +448 -0
  109. package/src/changes/text-diff.ts +377 -0
  110. package/src/client/CodeEditor.tsx +923 -0
  111. package/src/client/Dialog.tsx +76 -0
  112. package/src/client/DirectoryFlow.tsx +431 -0
  113. package/src/client/EditorPane.tsx +512 -0
  114. package/src/client/HostsView.tsx +312 -0
  115. package/src/client/NativeExplorer.tsx +121 -0
  116. package/src/client/RemoteDirectoryPicker.tsx +157 -0
  117. package/src/client/RemoteEditorView.tsx +54 -0
  118. package/src/client/RemoteExplorer.tsx +1398 -0
  119. package/src/client/RemoteRoot.tsx +389 -0
  120. package/src/client/SearchView.tsx +407 -0
  121. package/src/client/SettingsHosts.tsx +136 -0
  122. package/src/client/TerminalView.tsx +308 -0
  123. package/src/client/WorkspaceWizard.tsx +199 -0
  124. package/src/client/WorkspacesView.tsx +93 -0
  125. package/src/client/XTerm.tsx +308 -0
  126. package/src/client/api.ts +331 -0
  127. package/src/client/changes/ChangesDock.tsx +348 -0
  128. package/src/client/changes/changes.css +230 -0
  129. package/src/client/changes/model.ts +347 -0
  130. package/src/client/changes/undo-affordance.ts +389 -0
  131. package/src/client/changes/undo-context.ts +34 -0
  132. package/src/client/composer.ts +436 -0
  133. package/src/client/css.d.ts +7 -0
  134. package/src/client/i18n.ts +702 -0
  135. package/src/client/index.tsx +286 -0
  136. package/src/client/remote-file-open-target.ts +553 -0
  137. package/src/client/remote-file-open.ts +261 -0
  138. package/src/client/reveal.ts +33 -0
  139. package/src/client/scm/DiffView.tsx +570 -0
  140. package/src/client/scm/HistoryPanel.tsx +353 -0
  141. package/src/client/scm/RepoPicker.tsx +238 -0
  142. package/src/client/scm/ScmPanel.tsx +857 -0
  143. package/src/client/scm/actions.ts +460 -0
  144. package/src/client/scm/diff-model.ts +206 -0
  145. package/src/client/scm/stage-queue.ts +264 -0
  146. package/src/client/scm/status-map.ts +381 -0
  147. package/src/client/scm/untracked-dirs.ts +139 -0
  148. package/src/client/selection-popup.ts +157 -0
  149. package/src/client/store.tsx +1670 -0
  150. package/src/client/theme.ts +54 -0
  151. package/src/client/ui.tsx +136 -0
  152. package/src/client/upload-plan.ts +153 -0
  153. package/src/client/upload-source.ts +145 -0
  154. package/src/client/use-remote-chat-target.ts +153 -0
  155. package/src/client/vsc/FindWidget.tsx +446 -0
  156. package/src/client/vsc/change-marks.ts +586 -0
  157. package/src/client/vsc/editor-langs.ts +137 -0
  158. package/src/client/vsc/editor-palette.ts +107 -0
  159. package/src/client/vsc/editor-theme.ts +93 -0
  160. package/src/client/vsc/fallback-highlight.ts +145 -0
  161. package/src/client/vsc/kit.tsx +777 -0
  162. package/src/client/vsc/markdown.tsx +172 -0
  163. package/src/client/vsc/reveal-line.ts +142 -0
  164. package/src/client/vsc/tokenize.ts +569 -0
  165. package/src/client/vsc/vsc.css +2669 -0
  166. package/src/deploy.ts +481 -0
  167. package/src/fs-ops.ts +57 -0
  168. package/src/git-format.ts +205 -0
  169. package/src/git-ops.ts +693 -0
  170. package/src/http.ts +55 -0
  171. package/src/importers.ts +141 -0
  172. package/src/index.ts +442 -0
  173. package/src/keygen.ts +77 -0
  174. package/src/loopback.ts +47 -0
  175. package/src/p2/bridge-refs.ts +27 -0
  176. package/src/p2/bridge.ts +32 -0
  177. package/src/p2/file-refs.ts +392 -0
  178. package/src/p2/helper-rpc.ts +394 -0
  179. package/src/p2/in-flight-gate.ts +63 -0
  180. package/src/p2/router-fs.ts +327 -0
  181. package/src/p2/router-refs.ts +166 -0
  182. package/src/p2/router-spill.ts +75 -0
  183. package/src/p2/router-subprocess.ts +92 -0
  184. package/src/p2/shell-bash.ts +30 -0
  185. package/src/p2/ssh-proc.ts +802 -0
  186. package/src/p2/worlds.ts +735 -0
  187. package/src/paths.ts +50 -0
  188. package/src/pool.ts +811 -0
  189. package/src/protocol.ts +460 -0
  190. package/src/routes.ts +802 -0
  191. package/src/search-ops.ts +220 -0
  192. package/src/sftp.ts +677 -0
  193. package/src/store.ts +364 -0
  194. package/src/terminal.ts +291 -0
  195. package/src/timeouts.ts +122 -0
  196. package/src/tools.ts +320 -0
  197. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
  198. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
  199. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
  200. package/vendor/dsh-remote-helpers/glibc-ceiling.patch +31 -0
@@ -0,0 +1,570 @@
1
+ /**
2
+ * Side-by-side diff: full file on both sides, real line numbers, added /
3
+ * removed tinting and per-hunk stage / revert.
4
+ *
5
+ * The pairing comes from git (see `scm/diff-model`), so the two panes are
6
+ * always aligned row for row. Hunk buttons are only rendered where a patch
7
+ * can legally be applied: a worktree diff can be staged or reverted, an
8
+ * already-staged diff can only be un-staged. 变更后 switches to a real
9
+ * CodeMirror buffer so the file can be edited in place.
10
+ *
11
+ * The row count is the WHOLE file, so the split mode is windowed (see
12
+ * `useRowWindow`): before that, a 3000-line file put 9000 line containers and
13
+ * ~2N tokenized lines in the DOM at once, and every store publish rebuilt all
14
+ * of them. Note which element scrolls: the two panes only own their HORIZONTAL
15
+ * scroll (`.rvs-diff-col`), while vertical scrolling belongs to `.rvs-diff-grid`
16
+ * outside them — so ONE window serves all three columns and no column can
17
+ * drift out of lockstep with another.
18
+ */
19
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
20
+ import type { EditorTab } from '../store.tsx'
21
+ import { useExplorer } from '../store.tsx'
22
+ import { applyHunk, GitOpError } from './actions.ts'
23
+ import { diffCounts } from './diff-model.ts'
24
+ import type { DiffRow } from '../../protocol.ts'
25
+ import { api } from '../api.ts'
26
+ import { CodeEditor, type CodeEditorHandle } from '../CodeEditor.tsx'
27
+ import { sameLocalDir } from '../composer.ts'
28
+ import { useRemoteChatTarget } from '../use-remote-chat-target.ts'
29
+ import { langFromPath, tokenizeDocument, type Token } from '../vsc/tokenize.ts'
30
+ import {
31
+ IconButton,
32
+ IconRefresh,
33
+ IconSave,
34
+ MiniSpinner,
35
+ Segmented,
36
+ baseName,
37
+ toast,
38
+ } from '../vsc/kit.tsx'
39
+
40
+ type ViewMode = 'split' | 'before' | 'after'
41
+
42
+ /**
43
+ * Height of one diff row, in px. Must track `.rvs-dline`'s declared height
44
+ * (`vsc.css`), because the window below converts scroll offsets to row indices
45
+ * with it: if the CSS box is not exactly this tall the rendered slice drifts
46
+ * from the spacer that stands in for the rest of the file.
47
+ */
48
+ const ROW_HEIGHT = 18
49
+
50
+ /** Rows rendered above and below the viewport, so a wheel step is already ready. */
51
+ const OVERSCAN_ROWS = 8
52
+
53
+ /** Rows assumed to fit before the grid has been measured (one frame at most). */
54
+ const FALLBACK_VISIBLE_ROWS = 30
55
+
56
+ /** The half-open row range the diff columns paint, plus what they leave blank. */
57
+ interface RowWindow {
58
+ /** First rendered row index into `model.rows`. */
59
+ from: number
60
+ /** One past the last rendered row index. */
61
+ to: number
62
+ /** Height of the rows above `from` / below `to`, in px. */
63
+ before: number
64
+ after: number
65
+ }
66
+
67
+ /** What one render pass gets from `useRowWindow`. */
68
+ interface RowWindowing {
69
+ /** Goes on the element that owns the vertical scroll (`.rvs-diff-grid`). */
70
+ attachGrid: (node: HTMLDivElement | null) => void
71
+ onScroll: (event: React.UIEvent<HTMLDivElement>) => void
72
+ /** The rows to paint, already sliced and clamped. */
73
+ rows: DiffRow[]
74
+ /** The range that slice came from, for effects that must re-measure with it. */
75
+ range: RowWindow
76
+ /** Height of the rows above / below the slice, in px. */
77
+ before: number
78
+ after: number
79
+ }
80
+
81
+ /**
82
+ * The rows to render for one scroll position.
83
+ *
84
+ * Memoised against the row LIST, not just the numbers: a reload hands over a
85
+ * new `model.rows` while `scrollTop` looks unchanged, and a window remembered
86
+ * from the previous file would render the new file from the old offset. Handing
87
+ * back the settled object in every other case is what lets a scroll event that
88
+ * stays inside one row cost nothing.
89
+ */
90
+ function computeRowWindow(
91
+ rows: DiffRow[],
92
+ scrollTop: number,
93
+ height: number,
94
+ last: React.MutableRefObject<{ rows: DiffRow[]; window: RowWindow }>,
95
+ ): RowWindow {
96
+ const visibleRows = Math.ceil(height / ROW_HEIGHT)
97
+ /**
98
+ * The offset is clamped to the last row, not merely guarded against
99
+ * negatives: a reload can replace a 3000-line model with a 40-line one while
100
+ * the browser still holds the old offset (it re-clamps the scroller only once
101
+ * the new spacer height is in the DOM). An unclamped slice would ask for rows
102
+ * that no longer exist, `from` would exceed the model, and the two spacers
103
+ * would then add up to less than the file — the bottom of the new file would
104
+ * be unreachable. Clamping keeps `before + slice + after` exactly equal to
105
+ * `rows.length * ROW_HEIGHT`, which is what the scroller's range was before
106
+ * this window existed.
107
+ */
108
+ const scrolledRows = Math.max(0, Math.min(Math.floor(scrollTop / ROW_HEIGHT), Math.max(0, rows.length - 1)))
109
+ const from = Math.max(0, scrolledRows - OVERSCAN_ROWS)
110
+ const to = Math.min(rows.length, Math.max(from + 1, scrolledRows + visibleRows + OVERSCAN_ROWS))
111
+ const settled = last.current
112
+ if (settled.rows === rows && settled.window.from === from && settled.window.to === to) return settled.window
113
+ const window = { from, to, before: from * ROW_HEIGHT, after: Math.max(0, (rows.length - to) * ROW_HEIGHT) }
114
+ last.current = { rows, window }
115
+ return window
116
+ }
117
+
118
+ /**
119
+ * Fixed-height windowing for the side-by-side columns.
120
+ *
121
+ * `.rvs-diff-grid` is the only vertical scroller in the diff view, and
122
+ * `.rvs-dline` is a hard 18px, so the visible slice is arithmetic rather than
123
+ * measurement: offset → first row, viewport height → row count. What is NOT
124
+ * rendered is covered by a spacer of the right height, so the scroll range
125
+ * covers the whole file and the scrollbar behaves exactly as it did when every
126
+ * row was mounted.
127
+ *
128
+ * Scroll geometry is read through refs instead of React state: a `scrollTop`
129
+ * per wheel event would re-render on every pixel, while only crossing a row
130
+ * boundary changes anything on screen. The state here is just a revision
131
+ * counter, and the arithmetic above is what decides whether it moves.
132
+ *
133
+ * Nothing is pinned outside the window: an off-screen hunk button is reached by
134
+ * scrolling to its row, the same gesture as before.
135
+ */
136
+ function useRowWindow(rows: DiffRow[]): RowWindowing {
137
+ const scrollTopRef = useRef(0)
138
+ const heightRef = useRef(FALLBACK_VISIBLE_ROWS * ROW_HEIGHT)
139
+ const lastWindowRef = useRef<{ rows: DiffRow[]; window: RowWindow }>({
140
+ rows: [],
141
+ window: { from: -1, to: -1, before: 0, after: 0 },
142
+ })
143
+ const observerRef = useRef<ResizeObserver | null>(null)
144
+ const [, bumpWindow] = useState(0)
145
+
146
+ /**
147
+ * A CALLBACK ref, so the grid is measured whenever it actually mounts.
148
+ *
149
+ * A layout effect cannot do this job: a diff opens on its loading branch,
150
+ * where there is no grid to point at, and the grid only appears once the
151
+ * model arrives — at which point no dependency has changed and the effect
152
+ * would never run. The height would stay at the fallback and the bottom of a
153
+ * tall viewport would sit blank until the first scroll. React calls this on
154
+ * mount and with `null` on the way out, which is also the observer teardown.
155
+ *
156
+ * Measuring here keeps the guarantee that motivated the layout effect: the
157
+ * first painted frame already carries the right slice, so opening or resizing
158
+ * the split does not flash the opening rows and then jump. A 0-height reading
159
+ * is the grid being hidden, not a collapsed viewport, so the last real height
160
+ * is kept. The pane can also change size without the window following (a
161
+ * sidebar split, a window resize, the editor header appearing), which is what
162
+ * the observer below is for.
163
+ */
164
+ const attachGrid = useCallback((node: HTMLDivElement | null): void => {
165
+ observerRef.current?.disconnect()
166
+ observerRef.current = null
167
+ if (node === null || typeof ResizeObserver === 'undefined') return
168
+ scrollTopRef.current = node.scrollTop
169
+ if (node.clientHeight > 0) heightRef.current = node.clientHeight
170
+ const observer = new ResizeObserver((entries) => {
171
+ const box = entries[0]?.borderBoxSize[0]
172
+ const measured = box === undefined ? node.clientHeight : box.blockSize
173
+ if (measured > 0 && measured !== heightRef.current) {
174
+ heightRef.current = measured
175
+ bumpWindow(step => step + 1)
176
+ }
177
+ })
178
+ observer.observe(node)
179
+ observerRef.current = observer
180
+ }, [])
181
+
182
+ const range = computeRowWindow(rows, scrollTopRef.current, heightRef.current, lastWindowRef)
183
+ /**
184
+ * Recomputed on every scroll event and handed to React as the same object
185
+ * whenever the slice did not move (`computeRowWindow`), so a wheel storm that
186
+ * stays inside one row re-renders nothing. The measured `clientHeight` comes
187
+ * along because it tracks a pane resize the window itself cannot see.
188
+ */
189
+ const onScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
190
+ const grid = event.currentTarget
191
+ scrollTopRef.current = grid.scrollTop
192
+ if (grid.clientHeight > 0) heightRef.current = grid.clientHeight
193
+ // Only a slice change is worth a render. `computeRowWindow` hands back the
194
+ // very object the last render used while the wheel stays inside the same
195
+ // rows, so comparing against the window that was current BEFORE the call is
196
+ // the whole test — without it every scroll event re-rendered the diff,
197
+ // which is the cost the windowing exists to remove.
198
+ const previous = lastWindowRef.current.window
199
+ if (computeRowWindow(rows, grid.scrollTop, heightRef.current, lastWindowRef) !== previous) {
200
+ bumpWindow(step => step + 1)
201
+ }
202
+ }, [rows])
203
+
204
+ return {
205
+ attachGrid,
206
+ onScroll,
207
+ rows: rows.slice(range.from, range.to),
208
+ range,
209
+ before: range.before,
210
+ after: range.after,
211
+ }
212
+ }
213
+
214
+ /** Render one tokenized line. */
215
+ function Line(props: { tokens: Token[]; empty?: boolean }): ReactNode {
216
+ if (props.empty === true) return <span className="rvs-row-empty">{'\u00a0'}</span>
217
+ return (
218
+ <>
219
+ {props.tokens.map((token, index) => (
220
+ <span key={index} className={token.kind === 'plain' ? undefined : `rvs-t-${token.kind}`}>{token.text}</span>
221
+ ))}
222
+ </>
223
+ )
224
+ }
225
+
226
+ /**
227
+ * The top horizontal scrollbar: a scroll container whose only content is a
228
+ * spacer as wide as the widest pane. Dragging it drives both panes, so the
229
+ * user never has to scroll to the bottom of a file to reach a long line's
230
+ * horizontal bar.
231
+ */
232
+ function HScrollBar(props: {
233
+ barRef: React.RefObject<HTMLDivElement>
234
+ width: number
235
+ onScroll: (left: number) => void
236
+ }): ReactNode {
237
+ return (
238
+ <div
239
+ className="rvs-hbar"
240
+ ref={props.barRef}
241
+ onScroll={(e) => props.onScroll(e.currentTarget.scrollLeft)}
242
+ >
243
+ <div className="rvs-hbar-inner" style={{ width: props.width }} />
244
+ </div>
245
+ )
246
+ }
247
+
248
+ /** i18n key per hunk action (a map, not a nested ternary). */
249
+ const HUNK_TOAST_KEY: Record<'stage' | 'unstage' | 'revert', string> = {
250
+ stage: 'scm.hunkStaged',
251
+ unstage: 'scm.hunkUnstaged',
252
+ revert: 'scm.hunkReverted',
253
+ }
254
+
255
+ export function DiffView(props: { tab: EditorTab; visible: boolean }): ReactNode {
256
+ const store = useExplorer()
257
+ const { t, lang } = store
258
+ const tab = props.tab
259
+ const [busy, setBusy] = useState(false)
260
+ const [hunkBusy, setHunkBusy] = useState<number | undefined>(undefined)
261
+ const [mode, setMode] = useState<ViewMode>(tab.view === 'before' || tab.view === 'after' ? tab.view : 'split')
262
+ const [editBuffer, setEditBuffer] = useState<string | undefined>(undefined)
263
+ const [, forceRender] = useState(0)
264
+ const editorRef = useRef<CodeEditorHandle>(null)
265
+ const leftRef = useRef<HTMLDivElement>(null)
266
+ const rightRef = useRef<HTMLDivElement>(null)
267
+ const hbarRef = useRef<HTMLDivElement>(null)
268
+ const [barWidth, setBarWidth] = useState(0)
269
+ const rowsInView = useRowWindow(tab.model?.rows ?? [])
270
+
271
+ /**
272
+ * Measure the widest pane and size the top bar's spacer to it. Runs after
273
+ * every model change, on container resizes (a pane that is empty until
274
+ * its fonts settle would otherwise leave the bar too short) and whenever
275
+ * the window slides, since the widest line on screen changes with it.
276
+ */
277
+ useLayoutEffect(() => {
278
+ if (mode !== 'split') return
279
+ const measure = (): void => {
280
+ const widest = Math.max(leftRef.current?.scrollWidth ?? 0, rightRef.current?.scrollWidth ?? 0)
281
+ setBarWidth(widest)
282
+ }
283
+ measure()
284
+ const timer = window.setTimeout(measure, 80)
285
+ window.addEventListener('resize', measure)
286
+ return () => {
287
+ window.clearTimeout(timer)
288
+ window.removeEventListener('resize', measure)
289
+ }
290
+ }, [mode, tab.id, tab.diff, rowsInView.range])
291
+
292
+ /** Bar → both panes. */
293
+ const syncFromBar = useCallback((left: number): void => {
294
+ for (const pane of [leftRef.current, rightRef.current]) {
295
+ if (pane !== null && pane.scrollLeft !== left) pane.scrollLeft = left
296
+ }
297
+ }, [])
298
+
299
+ /** Pane (trackpad / shift-wheel) → the bar and the sibling pane. */
300
+ const syncFromPane = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
301
+ const left = event.currentTarget.scrollLeft
302
+ const bar = hbarRef.current
303
+ if (bar !== null && bar.scrollLeft !== left) bar.scrollLeft = left
304
+ for (const pane of [leftRef.current, rightRef.current]) {
305
+ if (pane !== null && pane !== event.currentTarget && pane.scrollLeft !== left) pane.scrollLeft = left
306
+ }
307
+ }, [])
308
+
309
+ // Selection→conversation stays available in the editable pane, exactly like
310
+ // the file editor: it needs the on-screen session AND the same binding.
311
+ const chat = useRemoteChatTarget(store.ctx)
312
+ const viewedAliasPath = store.workspace !== undefined ? store.aliasPaths[store.workspace.id] : undefined
313
+ const chatEnabled = chat.sessionId !== undefined
314
+ && chat.sessionWorkspacePath !== undefined
315
+ && viewedAliasPath !== undefined
316
+ && sameLocalDir(chat.sessionWorkspacePath, viewedAliasPath)
317
+
318
+ // Seed the editable buffer the first time the "变更后" pane is shown.
319
+ useEffect(() => {
320
+ if (mode !== 'after' || tab.staged || tab.text !== '' || tab.loading) return
321
+ let cancelled = false
322
+ void (async () => {
323
+ try {
324
+ const read = await api.fileOp.read(store.alias, tab.fullPath)
325
+ if (cancelled) return
326
+ setEditBuffer(read.binary ? '' : (read.text ?? ''))
327
+ } catch {
328
+ if (!cancelled) setEditBuffer('')
329
+ }
330
+ })()
331
+ return () => { cancelled = true }
332
+ }, [mode, tab.staged, tab.text, tab.loading, tab.fullPath, store.alias])
333
+
334
+ const sideLang = useMemo(() => langFromPath(tab.rel), [tab.rel])
335
+ const model = tab.model
336
+ const counts = useMemo(() => (model === undefined ? { added: 0, removed: 0 } : diffCounts(model)), [model])
337
+ const beforeLines = useMemo(
338
+ () => (model === undefined ? [] : tokenizeDocument(tab.diff?.oldText ?? '', sideLang)),
339
+ [model, tab.diff?.oldText, sideLang],
340
+ )
341
+ const afterLines = useMemo(
342
+ () => (model === undefined ? [] : tokenizeDocument(tab.diff?.newText ?? '', sideLang)),
343
+ [model, tab.diff?.newText, sideLang],
344
+ )
345
+
346
+ const runHunk = async (index: number, kind: 'stage' | 'unstage' | 'revert'): Promise<void> => {
347
+ const hunk = model?.hunks.find(item => item.index === index)
348
+ if (hunk === undefined || store.repoRoot === undefined) return
349
+ setHunkBusy(index)
350
+ try {
351
+ await applyHunk(store.alias, store.repoRoot, hunk.patch, kind)
352
+ await store.afterWrite()
353
+ await store.reloadTab(tab.id)
354
+ toast(t(HUNK_TOAST_KEY[kind]))
355
+ } catch (error) {
356
+ toast(error instanceof GitOpError || error instanceof Error ? error.message : String(error), true)
357
+ } finally {
358
+ setHunkBusy(undefined)
359
+ }
360
+ }
361
+
362
+ const reload = async (): Promise<void> => {
363
+ setBusy(true)
364
+ await store.reloadTab(tab.id)
365
+ setBusy(false)
366
+ }
367
+
368
+ const saveAfter = async (): Promise<void> => {
369
+ // The editable pane is a CodeMirror buffer: read it imperatively so the
370
+ // write carries the live text, then persist through the same route the
371
+ // file editor uses and reload the diff from the new working tree.
372
+ const text = editorRef.current?.getContent() ?? editBuffer
373
+ if (text === undefined) return
374
+ setEditBuffer(text)
375
+ store.updateTab(tab.id, { text })
376
+ await store.saveTab(tab.id, text)
377
+ await store.reloadTab(tab.id)
378
+ }
379
+
380
+ if (tab.error !== undefined) {
381
+ return (
382
+ <div className="rvs-diff">
383
+ <div className="rvs-banner error">{tab.error}</div>
384
+ <div style={{ padding: 8 }}>
385
+ <button type="button" className="rvs-chip" onClick={() => { void reload() }}>{t('scm.retry')}</button>
386
+ </div>
387
+ </div>
388
+ )
389
+ }
390
+ if (tab.loading) {
391
+ return <div className="rvs-empty"><MiniSpinner /> {t('common.loading')}</div>
392
+ }
393
+ if (tab.binary) {
394
+ return <div className="rvs-empty">{t('scm.binaryDiff')}</div>
395
+ }
396
+ if (model === undefined || model.empty) {
397
+ return (
398
+ <div className="rvs-diff">
399
+ <div className="rvs-empty">{t('scm.noDiff')}</div>
400
+ </div>
401
+ )
402
+ }
403
+
404
+ const editable = mode === 'after' && !tab.staged
405
+
406
+ return (
407
+ <div className="rvs-diff">
408
+ <div className="rvs-diff-toolbar">
409
+ <Segmented<ViewMode>
410
+ value={mode}
411
+ onChange={(next) => { setMode(next); store.updateTab(tab.id, { view: next }) }}
412
+ options={[
413
+ { id: 'split', label: t('scm.sideBySide') },
414
+ { id: 'before', label: t('scm.before') },
415
+ { id: 'after', label: editable ? t('scm.afterEditable') : t('scm.after') },
416
+ ]}
417
+ />
418
+ <span style={{ color: 'var(--rvs-git-a)' }}>+{counts.added}</span>
419
+ <span style={{ color: 'var(--rvs-git-d)' }}>-{counts.removed}</span>
420
+ {tab.staged && <span className="rvs-pill">{t('scm.stagedReadonly')}</span>}
421
+ <span className="rvs-einfo-spacer" />
422
+ {busy && <MiniSpinner />}
423
+ <IconButton title={t('scm.reloadDiff')} onClick={() => { void reload() }} disabled={busy}>
424
+ <IconRefresh size={13} />
425
+ </IconButton>
426
+ {editable && (
427
+ <IconButton title={`${t('editor.save')} (Ctrl+S)`} onClick={() => { void saveAfter() }}>
428
+ <IconSave size={13} />
429
+ </IconButton>
430
+ )}
431
+ </div>
432
+ {mode === 'split' && (
433
+ <>
434
+ <HScrollBar barRef={hbarRef} width={barWidth} onScroll={syncFromBar} />
435
+ {/* The grid owns the vertical scroll for all three columns, so the
436
+ window below is shared and the panes cannot drift apart. */}
437
+ <div className="rvs-diff-grid" ref={rowsInView.attachGrid} onScroll={rowsInView.onScroll}>
438
+ <div className="rvs-diff-cols">
439
+ {/* Left pane: fixed 50% (see .rvs-diff-col) with its own hidden
440
+ horizontal scroll, driven from the bar above. */}
441
+ <div className="rvs-diff-col" ref={leftRef} onScroll={syncFromPane}>
442
+ {/* The rows scrolled past. Height, not transform: the spacer has
443
+ to leave a real scroll range behind or the file's own rows
444
+ below the window would be unreachable. */}
445
+ <div className="rvs-drows-blank" style={{ height: rowsInView.before }} />
446
+ {rowsInView.rows.map(row => (
447
+ <div key={row.id} className={`rvs-dline${row.oldDel ? ' rvs-row-del' : ''}`}>
448
+ <span className="rvs-dline-num">{row.oldNum ?? ''}</span>
449
+ <span className="rvs-dline-text">
450
+ {row.old === ''
451
+ ? <span className="rvs-row-empty">{'\u00a0'}</span>
452
+ : <Line tokens={beforeLines[(row.oldNum ?? 1) - 1] ?? [{ text: row.old, kind: 'plain' }]} />}
453
+ </span>
454
+ </div>
455
+ ))}
456
+ <div className="rvs-drows-blank" style={{ height: rowsInView.after }} />
457
+ </div>
458
+ <div className="rvs-gutter-col">
459
+ <div className="rvs-drows-blank" style={{ height: rowsInView.before }} />
460
+ {rowsInView.rows.map(row => {
461
+ // `hunk.index` IS the position in `model.hunks`
462
+ // (`diff-model.ts` builds it with `.entries()`), so the row's
463
+ // index is the lookup — the linear `find` was one scan per row.
464
+ const hunk = row.hunk >= 0 ? model.hunks[row.hunk] : undefined
465
+ const startOfHunk = hunk !== undefined && hunk.startRow === row.id
466
+ return (
467
+ <div key={row.id} className="rvs-gutter-line">
468
+ {startOfHunk && hunk !== undefined && (
469
+ <span className="rvs-hunk-btns">
470
+ {hunkBusy === hunk.index
471
+ ? <MiniSpinner />
472
+ : (
473
+ <>
474
+ <button
475
+ type="button"
476
+ className="rvs-hunk-btn stage"
477
+ title={tab.staged ? t('scm.unstageHunk') : t('scm.stageHunk')}
478
+ onClick={() => { void runHunk(hunk.index, tab.staged ? 'unstage' : 'stage') }}
479
+ >
480
+ {tab.staged ? '↧' : '⤒'}
481
+ </button>
482
+ {!tab.staged && (
483
+ <button
484
+ type="button"
485
+ className="rvs-hunk-btn revert"
486
+ title={t('scm.revertHunk')}
487
+ onClick={() => { void runHunk(hunk.index, 'revert') }}
488
+ >
489
+ ↩
490
+ </button>
491
+ )}
492
+ </>
493
+ )}
494
+ </span>
495
+ )}
496
+ {!startOfHunk && (row.oldDel || row.newAdd) && (
497
+ <span className="rvs-gutter-dot" style={{ background: row.oldDel ? 'var(--rvs-git-d)' : 'var(--rvs-git-a)' }} />
498
+ )}
499
+ </div>
500
+ )
501
+ })}
502
+ <div className="rvs-drows-blank" style={{ height: rowsInView.after }} />
503
+ </div>
504
+ <div className="rvs-diff-col" ref={rightRef} onScroll={syncFromPane}>
505
+ <div className="rvs-drows-blank" style={{ height: rowsInView.before }} />
506
+ {rowsInView.rows.map(row => (
507
+ <div key={row.id} className={`rvs-dline${row.newAdd ? ' rvs-row-add' : ''}`}>
508
+ <span className="rvs-dline-num">{row.newNum ?? ''}</span>
509
+ <span className="rvs-dline-text">
510
+ {row.inNew === false || row.new === ''
511
+ ? <span className="rvs-row-empty">{'\u00a0'}</span>
512
+ : <Line tokens={afterLines[(row.newNum ?? 1) - 1] ?? [{ text: row.new, kind: 'plain' }]} />}
513
+ </span>
514
+ </div>
515
+ ))}
516
+ <div className="rvs-drows-blank" style={{ height: rowsInView.after }} />
517
+ </div>
518
+ </div>
519
+ </div>
520
+ </>
521
+ )}
522
+ {mode === 'before' && (
523
+ <div className="rvs-code rvs-scroll">
524
+ {beforeLines.length === 0
525
+ ? <div className="rvs-empty">{t('scm.emptySide')}</div>
526
+ : beforeLines.map((tokens, index) => (
527
+ <div key={index} className="rvs-code-line">
528
+ <span className="rvs-num">{index + 1}</span>
529
+ <span className="rvs-code-text"><Line tokens={tokens} /></span>
530
+ </div>
531
+ ))}
532
+ </div>
533
+ )}
534
+ {mode === 'after' && (
535
+ tab.staged
536
+ ? (
537
+ <div className="rvs-code rvs-scroll">
538
+ {afterLines.map((tokens, index) => (
539
+ <div key={index} className="rvs-code-line">
540
+ <span className="rvs-num">{index + 1}</span>
541
+ <span className="rvs-code-text"><Line tokens={tokens} /></span>
542
+ </div>
543
+ ))}
544
+ </div>
545
+ )
546
+ : (
547
+ <div className="rvs-editor-host">
548
+ <CodeEditor
549
+ key={`${tab.id}:edit`}
550
+ ref={editorRef}
551
+ initial={editBuffer ?? tab.diff?.newText ?? ''}
552
+ path={tab.fullPath}
553
+ visible={props.visible}
554
+ ctx={store.ctx}
555
+ sessionId={chat.sessionId}
556
+ remoteRoot={store.repoRoot ?? store.root}
557
+ chatEnabled={chatEnabled}
558
+ addLabel={t('files.addToConversation')}
559
+ onSave={() => { void saveAfter() }}
560
+ onDirty={(dirty) => {
561
+ store.updateTab(tab.id, { dirty })
562
+ forceRender(n => n + 1)
563
+ }}
564
+ />
565
+ </div>
566
+ )
567
+ )}
568
+ </div>
569
+ )
570
+ }