dsh-remote-vs 0.8.9 → 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.
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Handoff for the ONE live editor buffer when its mount is replaced.
3
+ *
4
+ * The plugin mounts exactly one `EditorPane` at a time (centre conversation
5
+ * view, sidebar fallback, or the floating window), and `CodeEditor` reads its
6
+ * `initial` text once on mount. So moving the editor between surfaces is
7
+ * effectively a remount: without a handoff the buffer typed since the last
8
+ * load is gone and the tab's stored text is the pre-edit one.
9
+ *
10
+ * This module is the handoff, and it is module-level for the same reason the
11
+ * store is: `EditorPane` is unmounted before the replacement mounts, so no
12
+ * React state survives the swap.
13
+ *
14
+ * Two things travel, and they are captured SEPARATELY because their costs
15
+ * differ by orders of magnitude:
16
+ * - the buffer text (the store copies it onto the tab before the surface
17
+ * flips, so the replacement mounts with it as `initial`) — materializes the
18
+ * whole document, so it is taken only when the tab may differ from disk;
19
+ * - scroll + caret (kept here and read back by the replacement, which is how
20
+ * a tab switch already restored its position — same storage, now shared by
21
+ * every surface instead of one component's `useRef`) — cheap, taken on
22
+ * every switch.
23
+ */
24
+ import type { CodeEditorHandle, CodeEditorViewState } from './CodeEditor.tsx'
25
+
26
+ interface LiveEditorRegistration {
27
+ tabId: string
28
+ getHandle: () => CodeEditorHandle | null
29
+ }
30
+
31
+ let registration: LiveEditorRegistration | undefined
32
+
33
+ /** Scroll + caret per tab id, surviving an unmount (see the header). */
34
+ const viewStates = new Map<string, CodeEditorViewState>()
35
+
36
+ /**
37
+ * Register the editor that is on screen right now.
38
+ *
39
+ * The caller passes a getter, not a handle: `editorRef.current` is `null`
40
+ * during the first render and is cleared before the parent's cleanup runs, so
41
+ * a snapshot taken later must read through the getter.
42
+ * @returns disposer; only the registration that is still current is cleared.
43
+ */
44
+ export function registerLiveEditor(
45
+ tabId: string,
46
+ getHandle: () => CodeEditorHandle | null,
47
+ ): () => void {
48
+ const entry: LiveEditorRegistration = { tabId, getHandle }
49
+ registration = entry
50
+ return () => {
51
+ if (registration === entry) registration = undefined
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Snapshot the live position (scroll + caret) without touching the document.
57
+ *
58
+ * Cheap, so the store can call it on EVERY switch: a surface flip remounts the
59
+ * very same tab, and the replacement's render happens before the old view's
60
+ * unmount report — the position has to be in the map before that render.
61
+ */
62
+ export function captureLiveEditorPosition(): void {
63
+ const entry = registration
64
+ if (entry === undefined) return
65
+ const handle = entry.getHandle()
66
+ if (handle === null) return
67
+ const viewState = handle.getViewState()
68
+ if (viewState !== undefined) viewStates.set(entry.tabId, viewState)
69
+ }
70
+
71
+ /**
72
+ * Whether the live buffer differs from its disk baseline.
73
+ *
74
+ * The store uses this to skip the O(document) text copy on clean tabs (see
75
+ * `captureLiveBuffer`): the boolean is cheap, `getContent()` materializes the
76
+ * whole file as a string.
77
+ */
78
+ export function isLiveEditorDirty(): boolean {
79
+ return registration?.getHandle()?.isDirty() === true
80
+ }
81
+
82
+ /**
83
+ * Copy the live buffer's text, for a switch that would otherwise lose it.
84
+ *
85
+ * Called from the store BEFORE the tab switch / surface flip: at that moment
86
+ * the editor is still mounted and its CodeMirror view still owns the edits.
87
+ * @returns undefined when no file editor is live (a diff tab, or a view that
88
+ * was never the selected one) — there is nothing to carry over then.
89
+ */
90
+ export function captureLiveEditorText(): { tabId: string; text: string } | undefined {
91
+ const entry = registration
92
+ if (entry === undefined) return undefined
93
+ const handle = entry.getHandle()
94
+ if (handle === null) return undefined
95
+ try {
96
+ return { tabId: entry.tabId, text: handle.getContent() }
97
+ } catch {
98
+ // A destroyed view is one race away from throwing; the switch must never
99
+ // be blocked by the handoff itself.
100
+ return undefined
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Whether one tab's file editor is the one registered right now.
106
+ *
107
+ * The save path asks this before it clears a tab's dirty flag: if an editor IS
108
+ * mounted for that tab, the flag belongs to the live buffer and only the
109
+ * editor may clear it (see `markLiveEditorSaved`).
110
+ */
111
+ export function isLiveEditorFor(tabId: string): boolean {
112
+ return registration?.tabId === tabId
113
+ }
114
+
115
+ /**
116
+ * Advance the registered editor's dirty baseline to the bytes a save wrote.
117
+ *
118
+ * Routed through the MODULE-LEVEL registration rather than a React ref on
119
+ * purpose: the pane can be remounted while the remote write is in flight (a
120
+ * surface flip, a dock, a tab switch), and the ref the save started with is
121
+ * then detached — that is exactly how a tab ended up reported clean while its
122
+ * live buffer was newer than the file. The registration always names the editor
123
+ * mounted NOW, so the correction lands on whichever instance is on screen, and
124
+ * it is skipped (never misapplied) when that editor shows another tab: that tab
125
+ * heals on its next mount from `savedText` (see `CodeEditor`).
126
+ * @returns true when an editor for this tab took the new baseline.
127
+ */
128
+ export function markLiveEditorSaved(tabId: string, savedText: string): boolean {
129
+ const entry = registration
130
+ if (entry === undefined || entry.tabId !== tabId) return false
131
+ entry.getHandle()?.markSaved(savedText, tabId)
132
+ return true
133
+ }
134
+
135
+ /** Remember one tab's scroll + caret (reported by the editor, see EditorPane). */
136
+ export function rememberEditorViewState(tabId: string, state: CodeEditorViewState): void {
137
+ viewStates.set(tabId, state)
138
+ }
139
+
140
+ /** The stored position for one tab, or undefined when it was never seen. */
141
+ export function editorViewStateOf(tabId: string): CodeEditorViewState | undefined {
142
+ return viewStates.get(tabId)
143
+ }
144
+
145
+ /**
146
+ * Drop positions of tabs that no longer exist.
147
+ *
148
+ * The map used to live in a component with the same cleanup: without it a
149
+ * long session accumulated one state object per file ever opened.
150
+ */
151
+ export function pruneEditorViewStates(liveIds: ReadonlySet<string>): void {
152
+ for (const id of [...viewStates.keys()]) {
153
+ if (!liveIds.has(id)) viewStates.delete(id)
154
+ }
155
+ }
@@ -31,7 +31,7 @@ import {
31
31
  type ParsedFileAddress,
32
32
  type RemoteOpenBinding,
33
33
  } from './remote-file-open-target.ts'
34
- import { isEditorCentered, openRemoteFileInBinding, remoteOpenBindings } from './store.tsx'
34
+ import { isEditorTakeoverActive, openRemoteFileInBinding, remoteOpenBindings } from './store.tsx'
35
35
  import { workspacePathOf } from './use-remote-chat-target.ts'
36
36
 
37
37
  interface SidebarRightLike {
@@ -216,7 +216,7 @@ export function installRemoteFileOpen(ctx: Context): () => void {
216
216
  // Cheap, synchronous gate first: only a file address of a session in
217
217
  // one of our bindings is intercepted. Everything else keeps the native
218
218
  // call (and its synchronous throw semantics) untouched.
219
- if (!isEditorCentered()) return original(address, options)
219
+ if (!isEditorTakeoverActive()) return original(address, options)
220
220
  const parsed = parseFileAddress(address)
221
221
  if (parsed === undefined) return original(address, options)
222
222
  return interceptSync(ctx, parsed, address, options) === true ? undefined : original(address, options)
@@ -8,7 +8,7 @@
8
8
  * and every successful write refreshes the shared status so the tree markers
9
9
  * and the change list never disagree.
10
10
  */
11
- import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
11
+ import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'
12
12
  import type { GitBranch, GitRemote, GitStash } from '../../protocol.ts'
13
13
  import { useExplorer } from '../store.tsx'
14
14
  import {
@@ -39,6 +39,7 @@ import {
39
39
  IconSync,
40
40
  IconTrash,
41
41
  IconUndo,
42
+ ContextMenu,
42
43
  FileIcon,
43
44
  MiniSpinner,
44
45
  baseName,
@@ -49,6 +50,7 @@ import {
49
50
  isPreviewOnlyFile,
50
51
  promptDialog,
51
52
  toast,
53
+ useContextMenu,
52
54
  } from '../vsc/kit.tsx'
53
55
 
54
56
  const MSG_KEY = 'dsh-remote-vs.commitMsg'
@@ -77,6 +79,8 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
77
79
  const [newBranch, setNewBranch] = useState('')
78
80
  const [remoteForm, setRemoteForm] = useState({ name: '', url: '' })
79
81
  const [picker, setPicker] = useState(false)
82
+ /** Right-click menu of one change row (the floating-editor entry). */
83
+ const menu = useContextMenu()
80
84
 
81
85
  const fold = (id: string): void => setFolded(prev => ({ ...prev, [id]: prev[id] !== true }))
82
86
 
@@ -236,6 +240,39 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
236
240
  store.openDiff(entry.path, entry.staged, entry.status === '?')
237
241
  }
238
242
 
243
+ /**
244
+ * The same destination as `openChange`, but in the floating editor.
245
+ *
246
+ * Kept in step with `openChange` on purpose: a conflicted path and a
247
+ * preview-only file are opened as FILES (there is no usable diff), everything
248
+ * else as the working-tree diff the row click would show.
249
+ */
250
+ const openChangeInFloat = (entry: ChangeEntry): void => {
251
+ if (entry.path.endsWith('/')) return
252
+ const abs = `${repoRoot?.replace(/\/+$/, '') ?? store.root}/${entry.path}`
253
+ if (entry.status === 'U' || isPreviewOnlyFile(entry.path)) {
254
+ store.openInFloatEditor(abs)
255
+ // Same explanation the row click gives: a conflicted path has no single
256
+ // diff, so the file itself is what opened.
257
+ if (entry.status === 'U') toast(t('scm.conflictOpen'), false)
258
+ return
259
+ }
260
+ store.openDiffInFloatEditor(entry.path, entry.staged, entry.status === '?')
261
+ }
262
+
263
+ /** Right-click on one change row: the floating-editor entry. */
264
+ const openRowMenu = (event: ReactMouseEvent, entry: ChangeEntry): void => {
265
+ event.preventDefault()
266
+ event.stopPropagation()
267
+ menu.open(event.clientX, event.clientY, [
268
+ {
269
+ label: t('files.openInFloat'),
270
+ onSelect: () => openChangeInFloat(entry),
271
+ disabled: !store.floatAvailable || entry.path.endsWith('/'),
272
+ },
273
+ ])
274
+ }
275
+
239
276
  const doCommit = async (): Promise<void> => {
240
277
  if (repoRoot === undefined || message.trim() === '') return
241
278
  // A commit is a real state transition and the user expects to wait for it:
@@ -365,6 +402,7 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
365
402
  style={depth === 0 ? undefined : { paddingLeft: `${22 + depth * 12}px` }}
366
403
  title={expandable ? `${entry.path}\n${t('scm.dirToggle')}` : `${entry.path}\n${t('scm.openHint')}`}
367
404
  onClick={activate}
405
+ onContextMenu={(event) => openRowMenu(event, entry)}
368
406
  onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') activate() }}
369
407
  >
370
408
  {/* The chevron slot is reserved on EVERY row (11px), so a directory row
@@ -852,6 +890,9 @@ export function ScmPanel(props: { onOpenHistory: () => void; visible: boolean })
852
890
  </>
853
891
  )}
854
892
  </div>
893
+ {menu.menu !== undefined && (
894
+ <ContextMenu x={menu.menu.x} y={menu.menu.y} entries={menu.menu.entries} onClose={menu.close} />
895
+ )}
855
896
  </div>
856
897
  )
857
898
  }