dsh-remote-vs 0.8.9 → 0.9.1

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 (35) hide show
  1. package/README.en.md +15 -2
  2. package/README.md +12 -1
  3. package/lib/client.js +1235 -53
  4. package/lib/index.js +1 -1
  5. package/lib/p2-router-fs.js +1 -1
  6. package/lib/types/client/CodeEditor.d.ts +27 -1
  7. package/lib/types/client/FloatingEditorWindow.d.ts +20 -0
  8. package/lib/types/client/FloatingPlaceholder.d.ts +9 -0
  9. package/lib/types/client/RemoteEditorView.d.ts +4 -0
  10. package/lib/types/client/float-editor.d.ts +123 -0
  11. package/lib/types/client/float-tab-trigger.d.ts +24 -0
  12. package/lib/types/client/live-editor.d.ts +94 -0
  13. package/lib/types/client/store.d.ts +95 -6
  14. package/lib/types/workspace-title.d.ts +2 -2
  15. package/lib/{worlds-DnzpX3Hv.js → worlds-a8nqW9mi.js} +2 -2
  16. package/package.json +1 -1
  17. package/src/client/CodeEditor.tsx +95 -8
  18. package/src/client/EditorPane.tsx +45 -24
  19. package/src/client/FloatingEditorWindow.tsx +494 -0
  20. package/src/client/FloatingPlaceholder.tsx +22 -0
  21. package/src/client/RemoteEditorView.tsx +7 -2
  22. package/src/client/RemoteExplorer.tsx +16 -0
  23. package/src/client/RemoteRoot.tsx +15 -6
  24. package/src/client/SearchView.tsx +10 -3
  25. package/src/client/float-editor.ts +271 -0
  26. package/src/client/float-tab-trigger.ts +51 -0
  27. package/src/client/i18n.ts +28 -0
  28. package/src/client/index.tsx +48 -1
  29. package/src/client/live-editor.ts +155 -0
  30. package/src/client/remote-file-open.ts +2 -2
  31. package/src/client/scm/ScmPanel.tsx +42 -1
  32. package/src/client/store.tsx +274 -16
  33. package/src/client/vsc/editor-theme.ts +6 -1
  34. package/src/client/vsc/vsc.css +235 -0
  35. package/src/workspace-title.ts +2 -2
@@ -60,7 +60,18 @@ export interface CodeEditorViewState {
60
60
  export interface CodeEditorHandle {
61
61
  getContent: () => string
62
62
  isDirty: () => boolean
63
- markSaved: () => void
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 !== undefined) {
408
- savedRef.current = view.state.doc.toString()
409
- savedDocRef.current = view.state.doc
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
- savedDocRef.current = view.state.doc
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 editor REPORTS its position (see `CodeEditor`'s `onViewState`) instead
233
- * of this component reading it back through `editorRef`, because the ref is
234
- * already `null` by the time any cleanup here could run: React clears a
235
- * child's ref before running the parent's cleanup. Both earlier attempts
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
- * otherwise the sidebar fallback. With two live editors for the same tab the
244
- * hidden one would report its untouched scrollTop 0 and overwrite the visible
245
- * one's, snapping every switch back to the top — so `RemoteRoot` gates both
246
- * mounts on `editorSurface` and must keep doing so.
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
- viewStates.current.set(tabId, state)
268
+ rememberEditorViewState(tabId, state)
265
269
  }, [])
266
270
 
267
271
  /**
268
- * Closed tabs may not keep their scroll + caret. This map is keyed by tab id
269
- * and was only ever written, and the pane is mounted for the life of the
270
- * page: a session that opened a few hundred files held a state object for
271
- * every one of them, long after the tab was gone.
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
- const live = new Set(tabs.map(tab => tab.id))
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
- {...(viewStates.current.get(activeTab.id) === undefined
512
+ {...(editorViewStateOf(activeTab.id) === undefined
492
513
  ? {}
493
- : { viewState: viewStates.current.get(activeTab.id) })}
514
+ : { viewState: editorViewStateOf(activeTab.id) })}
494
515
  onTokens={setTokenCount}
495
516
  onViewState={rememberViewState}
496
517
  onStepHunk={(dir) => { gotoChange(activeTab.id, dir) }}