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,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
|
+
}
|