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.
- package/README.en.md +15 -2
- package/README.md +12 -1
- package/lib/client.js +1235 -53
- 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/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/store.d.ts +95 -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 +16 -0
- package/src/client/RemoteRoot.tsx +15 -6
- package/src/client/SearchView.tsx +10 -3
- package/src/client/float-editor.ts +271 -0
- package/src/client/float-tab-trigger.ts +51 -0
- package/src/client/i18n.ts +28 -0
- package/src/client/index.tsx +48 -1
- package/src/client/live-editor.ts +155 -0
- package/src/client/remote-file-open.ts +2 -2
- package/src/client/scm/ScmPanel.tsx +42 -1
- package/src/client/store.tsx +274 -16
- package/src/client/vsc/editor-theme.ts +6 -1
- package/src/client/vsc/vsc.css +235 -0
|
@@ -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 {
|
|
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 (!
|
|
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
|
}
|