dsh-remote-vs 0.8.8 → 0.9.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.
- package/README.en.md +15 -2
- package/README.md +12 -1
- package/lib/client.js +1507 -59
- package/lib/types/client/CodeEditor.d.ts +27 -1
- package/lib/types/client/FloatingEditorWindow.d.ts +20 -0
- package/lib/types/client/FloatingPlaceholder.d.ts +9 -0
- package/lib/types/client/RemoteEditorView.d.ts +4 -0
- package/lib/types/client/TerminalView.d.ts +3 -0
- package/lib/types/client/XTerm.d.ts +6 -0
- package/lib/types/client/float-editor.d.ts +123 -0
- package/lib/types/client/float-tab-trigger.d.ts +24 -0
- package/lib/types/client/live-editor.d.ts +94 -0
- package/lib/types/client/remote-file-open-target.d.ts +0 -22
- package/lib/types/client/shell.d.ts +29 -0
- package/lib/types/client/store.d.ts +160 -6
- package/package.json +1 -1
- package/src/client/CodeEditor.tsx +95 -8
- package/src/client/EditorPane.tsx +45 -24
- package/src/client/FloatingEditorWindow.tsx +494 -0
- package/src/client/FloatingPlaceholder.tsx +22 -0
- package/src/client/RemoteEditorView.tsx +7 -2
- package/src/client/RemoteExplorer.tsx +61 -0
- package/src/client/RemoteRoot.tsx +16 -7
- package/src/client/SearchView.tsx +49 -2
- package/src/client/TerminalView.tsx +53 -2
- package/src/client/XTerm.tsx +15 -0
- package/src/client/float-editor.ts +271 -0
- package/src/client/float-tab-trigger.ts +51 -0
- package/src/client/i18n.ts +32 -0
- package/src/client/index.tsx +48 -1
- package/src/client/live-editor.ts +155 -0
- package/src/client/remote-file-open-target.ts +1 -5
- package/src/client/remote-file-open.ts +2 -2
- package/src/client/scm/ScmPanel.tsx +42 -1
- package/src/client/shell.ts +35 -0
- package/src/client/store.tsx +367 -16
- package/src/client/vsc/editor-theme.ts +6 -1
- package/src/client/vsc/vsc.css +235 -0
|
@@ -60,7 +60,18 @@ export interface CodeEditorViewState {
|
|
|
60
60
|
export interface CodeEditorHandle {
|
|
61
61
|
getContent: () => string
|
|
62
62
|
isDirty: () => boolean
|
|
63
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Advance the dirty baseline to the bytes a save just wrote to the remote
|
|
65
|
+
* file, and re-derive the dirty flag from the LIVE buffer against them.
|
|
66
|
+
*
|
|
67
|
+
* Takes the saved text explicitly because the user may have typed while the
|
|
68
|
+
* remote write was in flight: the buffer is then NEWER than the file, and
|
|
69
|
+
* declaring it saved would be the same silent-loss bug in the other
|
|
70
|
+
* direction. `tabId` guards against the pane having switched to another tab
|
|
71
|
+
* while the save awaited — marking that editor saved would corrupt ITS
|
|
72
|
+
* baseline.
|
|
73
|
+
*/
|
|
74
|
+
markSaved: (savedText: string, tabId: string) => void
|
|
64
75
|
/** Snapshot scroll + selection, for restoring after a remount. */
|
|
65
76
|
getViewState: () => CodeEditorViewState | undefined
|
|
66
77
|
/** Scroll back and put the caret where it was. */
|
|
@@ -190,6 +201,21 @@ export const CodeEditor = forwardRef<CodeEditorHandle, {
|
|
|
190
201
|
* where a file was when its tab is re-selected.
|
|
191
202
|
*/
|
|
192
203
|
viewState?: CodeEditorViewState
|
|
204
|
+
/**
|
|
205
|
+
* The text as it was last LOADED from / SAVED to the remote file — the dirty
|
|
206
|
+
* baseline.
|
|
207
|
+
*
|
|
208
|
+
* Separate from `initial` because a tab switch or a surface flip remounts the
|
|
209
|
+
* editor with the LIVE buffer as `initial` (that is how unsaved edits survive
|
|
210
|
+
* the remount), while the file on disk is still the older `savedText`. Using
|
|
211
|
+
* `initial` as the baseline would declare the unsaved text clean: the dirty
|
|
212
|
+
* dot and status would lie, the close guard would stop prompting and Save
|
|
213
|
+
* would disable — a silent way to lose the very edits the handoff preserved.
|
|
214
|
+
*
|
|
215
|
+
* Absent (diff panes, older callers): the mount's own document is the
|
|
216
|
+
* baseline, which is the historical behaviour.
|
|
217
|
+
*/
|
|
218
|
+
savedText?: string
|
|
193
219
|
onSave: () => void
|
|
194
220
|
onDirty: (dirty: boolean) => void
|
|
195
221
|
}>(function CodeEditor(props, ref) {
|
|
@@ -402,14 +428,23 @@ export const CodeEditor = forwardRef<CodeEditorHandle, {
|
|
|
402
428
|
useImperativeHandle(ref, () => ({
|
|
403
429
|
getContent: () => viewRef.current?.state.doc.toString() ?? savedRef.current,
|
|
404
430
|
isDirty: () => dirtyRef.current,
|
|
405
|
-
markSaved: () => {
|
|
431
|
+
markSaved: (savedText, tabId) => {
|
|
406
432
|
const view = viewRef.current
|
|
407
|
-
if (view
|
|
408
|
-
|
|
409
|
-
|
|
433
|
+
if (view === undefined) return
|
|
434
|
+
// The pane may have switched tabs while the save was in flight; this
|
|
435
|
+
// editor is then a different file and must not adopt the other tab's
|
|
436
|
+
// baseline.
|
|
437
|
+
if (propsRef.current.tabId !== tabId) return
|
|
438
|
+
// The written bytes themselves: no document copy needed (this string came
|
|
439
|
+
// from the caller's own `getContent`, or from the tab's baseline).
|
|
440
|
+
savedRef.current = savedText
|
|
441
|
+
const saved = EditorState.create({ doc: savedText }).doc
|
|
442
|
+
savedDocRef.current = saved
|
|
443
|
+
const dirty = view.state.doc !== saved && !view.state.doc.eq(saved)
|
|
444
|
+
if (dirty !== dirtyRef.current) {
|
|
445
|
+
dirtyRef.current = dirty
|
|
446
|
+
propsRef.current.onDirty(dirty)
|
|
410
447
|
}
|
|
411
|
-
dirtyRef.current = false
|
|
412
|
-
propsRef.current.onDirty(false)
|
|
413
448
|
},
|
|
414
449
|
getViewState: () => {
|
|
415
450
|
const view = viewRef.current
|
|
@@ -459,6 +494,34 @@ export const CodeEditor = forwardRef<CodeEditorHandle, {
|
|
|
459
494
|
viewRef.current?.requestMeasure()
|
|
460
495
|
}, [props.visible])
|
|
461
496
|
|
|
497
|
+
/**
|
|
498
|
+
* A save that landed AFTER this editor mounted.
|
|
499
|
+
*
|
|
500
|
+
* The save path moves the tab's `baseline` (the bytes now on disk) and asks
|
|
501
|
+
* the live editor to re-derive its dirty flag. That works while the same
|
|
502
|
+
* instance is mounted, but the pane can be remounted while the remote write
|
|
503
|
+
* is in flight (a surface flip, a dock, a tab switch): the replacement
|
|
504
|
+
* derives against the baseline it saw at MOUNT, and the corrected value only
|
|
505
|
+
* arrives later as a prop. Without this effect the tab keeps the `dirty:false`
|
|
506
|
+
* the save optimistically wrote, while the buffer holds newer text — dot off,
|
|
507
|
+
* Save disabled, close guard silent. Re-derive, never touch the document.
|
|
508
|
+
*/
|
|
509
|
+
useEffect(() => {
|
|
510
|
+
const view = viewRef.current
|
|
511
|
+
const savedText = props.savedText
|
|
512
|
+
if (view === undefined || savedText === undefined) return
|
|
513
|
+
const baseline = EditorState.create({ doc: savedText }).doc
|
|
514
|
+
const previous = savedDocRef.current
|
|
515
|
+
if (previous !== undefined && previous.eq(baseline)) return
|
|
516
|
+
savedDocRef.current = baseline
|
|
517
|
+
savedRef.current = savedText
|
|
518
|
+
const dirty = view.state.doc !== baseline && !view.state.doc.eq(baseline)
|
|
519
|
+
if (dirty !== dirtyRef.current) {
|
|
520
|
+
dirtyRef.current = dirty
|
|
521
|
+
propsRef.current.onDirty(dirty)
|
|
522
|
+
}
|
|
523
|
+
}, [props.savedText])
|
|
524
|
+
|
|
462
525
|
// The chat gate may flip while a popup is open (session switched away):
|
|
463
526
|
// never commit into a session the file no longer belongs to.
|
|
464
527
|
const chatGate = props.chatEnabled === true && props.sessionId !== undefined && props.remoteRoot !== undefined
|
|
@@ -670,7 +733,31 @@ export const CodeEditor = forwardRef<CodeEditorHandle, {
|
|
|
670
733
|
viewRef.current = view
|
|
671
734
|
// Baseline for the dirty check, kept as a `Text` so comparing on a
|
|
672
735
|
// keystroke does not have to materialize the document as a string.
|
|
673
|
-
|
|
736
|
+
//
|
|
737
|
+
// `savedText` (the last loaded/saved bytes) wins over the mounted document:
|
|
738
|
+
// a remount can carry the UNSAVED buffer as `initial` (see the prop's doc),
|
|
739
|
+
// and that text must never become the "clean" reference.
|
|
740
|
+
const savedText = propsRef.current.savedText
|
|
741
|
+
const baseline = savedText !== undefined
|
|
742
|
+
? EditorState.create({ doc: savedText }).doc
|
|
743
|
+
: view.state.doc
|
|
744
|
+
savedDocRef.current = baseline
|
|
745
|
+
if (savedText !== undefined) {
|
|
746
|
+
savedRef.current = savedText
|
|
747
|
+
// Report the dirty state AT MOUNT: the buffer can already be newer than
|
|
748
|
+
// the disk bytes (a handoff remount). Waiting for the first keystroke
|
|
749
|
+
// left exactly such a file shown as clean — dot off, Save disabled,
|
|
750
|
+
// close guard silent — which is the failure this baseline exists to
|
|
751
|
+
// prevent.
|
|
752
|
+
const dirty = view.state.doc !== baseline && !view.state.doc.eq(baseline)
|
|
753
|
+
// Only a CHANGE is worth a store write: this runs on every mount, and a
|
|
754
|
+
// clean tab reporting `false` would cost every subscriber a re-render for
|
|
755
|
+
// nothing.
|
|
756
|
+
if (dirty !== dirtyRef.current) {
|
|
757
|
+
dirtyRef.current = dirty
|
|
758
|
+
propsRef.current.onDirty(dirty)
|
|
759
|
+
}
|
|
760
|
+
}
|
|
674
761
|
setView(view)
|
|
675
762
|
/**
|
|
676
763
|
* Come back to where this file was left.
|
|
@@ -11,6 +11,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro
|
|
|
11
11
|
import { api } from './api.ts'
|
|
12
12
|
import { CodeEditor, type CodeEditorHandle, type CodeEditorViewState } from './CodeEditor.tsx'
|
|
13
13
|
import { sameLocalDir } from './composer.ts'
|
|
14
|
+
import { editorViewStateOf, pruneEditorViewStates, registerLiveEditor, rememberEditorViewState } from './live-editor.ts'
|
|
14
15
|
import { useRemoteChatTarget } from './use-remote-chat-target.ts'
|
|
15
16
|
import { useExplorer, gotoChange, markChangeStale, undoChange, type EditorTab } from './store.tsx'
|
|
16
17
|
import { DiffView } from './scm/DiffView.tsx'
|
|
@@ -229,23 +230,26 @@ export function EditorPane(props: { visible: boolean }): ReactNode {
|
|
|
229
230
|
* buffer rather than the previous file's undo stack, which is what we want,
|
|
230
231
|
* but it also reset the scroll and the caret.
|
|
231
232
|
*
|
|
232
|
-
* The
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
* failed on exactly that — a `useEffect` keyed on the active tab captured the
|
|
237
|
-
* INCOMING tab's editor (always scrollTop 0), and a `useLayoutEffect` cleanup
|
|
238
|
-
* found `editorRef.current === null`. `.spike/tabswitch-check.mjs` reproduces
|
|
239
|
-
* both failures and pins the working wiring.
|
|
233
|
+
* The map lives in `live-editor.ts`, NOT in a `useRef` here, because the pane
|
|
234
|
+
* is unmounted when the editor floats out (and again when it docks back): a
|
|
235
|
+
* per-component map would lose the position on the one surface swap that
|
|
236
|
+
* most needs it. It survives there for the same reason the store does.
|
|
240
237
|
*
|
|
241
238
|
* Keyed by tab id alone, which is safe because only ONE `EditorPane` is ever
|
|
242
239
|
* mounted: the centre surface when the host has a `conversation.view` slot,
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
240
|
+
* the floating window while the editor floats, otherwise the sidebar
|
|
241
|
+
* fallback. With two live editors for the same tab the hidden one would
|
|
242
|
+
* report its untouched scrollTop 0 and overwrite the visible one's, snapping
|
|
243
|
+
* every switch back to the top — so `RemoteRoot`, `RemoteEditorView` and the
|
|
244
|
+
* floating window all gate their mounts on `editorSurface` and must keep
|
|
245
|
+
* doing so.
|
|
246
|
+
*
|
|
247
|
+
* The editor REPORTS its position (see `CodeEditor`'s `onViewState`) instead
|
|
248
|
+
* of this component reading it back through `editorRef`, because the ref is
|
|
249
|
+
* already `null` by the time any cleanup here could run: React clears a
|
|
250
|
+
* child's ref before running the parent's cleanup. `.spike/tabswitch-check.mjs`
|
|
251
|
+
* reproduces the failed variants and pins the working wiring.
|
|
247
252
|
*/
|
|
248
|
-
const viewStates = useRef(new Map<string, CodeEditorViewState>())
|
|
249
253
|
/**
|
|
250
254
|
* Store a reading under the tab that produced it.
|
|
251
255
|
*
|
|
@@ -261,20 +265,17 @@ export function EditorPane(props: { visible: boolean }): ReactNode {
|
|
|
261
265
|
* accept a report for any tab.
|
|
262
266
|
*/
|
|
263
267
|
const rememberViewState = useCallback((tabId: string, state: CodeEditorViewState): void => {
|
|
264
|
-
|
|
268
|
+
rememberEditorViewState(tabId, state)
|
|
265
269
|
}, [])
|
|
266
270
|
|
|
267
271
|
/**
|
|
268
|
-
* Closed tabs may not keep their scroll + caret.
|
|
269
|
-
* and
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
+
* Closed tabs may not keep their scroll + caret. The map is keyed by tab id
|
|
273
|
+
* and is only ever written: a session that opened a few hundred files would
|
|
274
|
+
* otherwise hold a state object for every one of them, long after the tab is
|
|
275
|
+
* gone.
|
|
272
276
|
*/
|
|
273
277
|
useEffect(() => {
|
|
274
|
-
|
|
275
|
-
for (const id of [...viewStates.current.keys()]) {
|
|
276
|
-
if (!live.has(id)) viewStates.current.delete(id)
|
|
277
|
-
}
|
|
278
|
+
pruneEditorViewStates(new Set(tabs.map(tab => tab.id)))
|
|
278
279
|
}, [tabs])
|
|
279
280
|
|
|
280
281
|
/**
|
|
@@ -295,6 +296,20 @@ export function EditorPane(props: { visible: boolean }): ReactNode {
|
|
|
295
296
|
&& !activeTab.loading
|
|
296
297
|
&& activeTab.error === undefined
|
|
297
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Advertise the live editor so the store can snapshot it before a surface
|
|
301
|
+
* flip (see `captureLiveEditor`): the floating window is a different mount of
|
|
302
|
+
* the SAME pane, so switching to it unmounts this component.
|
|
303
|
+
*
|
|
304
|
+
* The getter is `() => editorRef.current`, not the handle itself: the handle
|
|
305
|
+
* only exists after `CodeEditor` mounts, and it is cleared before the
|
|
306
|
+
* cleanup that would otherwise run here.
|
|
307
|
+
*/
|
|
308
|
+
useEffect(() => {
|
|
309
|
+
if (!editorMounted || activeTab === undefined) return
|
|
310
|
+
return registerLiveEditor(activeTab.id, () => editorRef.current)
|
|
311
|
+
}, [editorMounted, activeTab?.id])
|
|
312
|
+
|
|
298
313
|
const saveActive = async (): Promise<void> => {
|
|
299
314
|
const tab = activeTab
|
|
300
315
|
if (tab === undefined) return
|
|
@@ -304,6 +319,11 @@ export function EditorPane(props: { visible: boolean }): ReactNode {
|
|
|
304
319
|
toast(t('editor.needSource'), true)
|
|
305
320
|
return
|
|
306
321
|
}
|
|
322
|
+
// The store advances the disk baseline and re-derives the dirty flag on the
|
|
323
|
+
// MOUNTED editor through the module-level registration (see
|
|
324
|
+
// `markLiveEditorSaved`), so this works even when the pane is remounted
|
|
325
|
+
// while the write is in flight. Nothing is left to do here beyond handing
|
|
326
|
+
// it the live buffer.
|
|
307
327
|
await store.saveTab(tab.id, text)
|
|
308
328
|
}
|
|
309
329
|
|
|
@@ -474,6 +494,7 @@ export function EditorPane(props: { visible: boolean }): ReactNode {
|
|
|
474
494
|
key={editorKey(activeTab)}
|
|
475
495
|
ref={editorRef}
|
|
476
496
|
initial={activeTab.text}
|
|
497
|
+
savedText={activeTab.baseline}
|
|
477
498
|
path={activeTab.fullPath}
|
|
478
499
|
tabId={activeTab.id}
|
|
479
500
|
readOnly={activeTab.truncated}
|
|
@@ -488,9 +509,9 @@ export function EditorPane(props: { visible: boolean }): ReactNode {
|
|
|
488
509
|
{...(activeTab.changeSet === undefined ? {} : { changeSet: activeTab.changeSet })}
|
|
489
510
|
{...(activeTab.revealHunkLine === undefined ? {} : { revealHunkLine: activeTab.revealHunkLine })}
|
|
490
511
|
{...(activeTab.revealHunkNonce === undefined ? {} : { revealHunkNonce: activeTab.revealHunkNonce })}
|
|
491
|
-
{...(
|
|
512
|
+
{...(editorViewStateOf(activeTab.id) === undefined
|
|
492
513
|
? {}
|
|
493
|
-
: { viewState:
|
|
514
|
+
: { viewState: editorViewStateOf(activeTab.id) })}
|
|
494
515
|
onTokens={setTokenCount}
|
|
495
516
|
onViewState={rememberViewState}
|
|
496
517
|
onStepHunk={(dir) => { gotoChange(activeTab.id, dir) }}
|