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,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Untracked DIRECTORY rows, expanded in place.
|
|
3
|
+
*
|
|
4
|
+
* `git status -u normal` — the panel's performance mode — reports an untracked
|
|
5
|
+
* directory as ONE row (`dir/`), so the files inside it are invisible. Clicking
|
|
6
|
+
* that row runs a pathspec-limited `git status -uall -- dir/` (cost bounded by
|
|
7
|
+
* the directory, never the repository) and the rows it answers with are shown
|
|
8
|
+
* under the directory row. This module is the pure state half — expand,
|
|
9
|
+
* collapse, prune and the row projection; the probe itself lives in
|
|
10
|
+
* `scm/actions.ts` and the command in `git-ops.ts`.
|
|
11
|
+
*
|
|
12
|
+
* Rows keep their FULL repo-relative paths (`dir/sub/b.txt`), exactly like every
|
|
13
|
+
* other row in the panel: nesting is then a matter of indentation, and a row can
|
|
14
|
+
* never be ambiguous when two directories hold the same file name.
|
|
15
|
+
*/
|
|
16
|
+
import type { GitPathStatus, GitStatusDetail } from '../../protocol.ts'
|
|
17
|
+
import { parseStatusDetail } from '../../git-ops.ts'
|
|
18
|
+
import { isConflictStatus, type ChangeEntry } from './status-map.ts'
|
|
19
|
+
|
|
20
|
+
/** Expanded directory rows: directory path (`dir/`) → the rows under it. */
|
|
21
|
+
export type DirExpansions = ReadonlyMap<string, readonly ChangeEntry[]>
|
|
22
|
+
|
|
23
|
+
/** True when a status record is an untracked DIRECTORY row (`?? dir/`). */
|
|
24
|
+
export function isUntrackedDirRow(entry: GitPathStatus): boolean {
|
|
25
|
+
return entry.x === '?' && entry.y === '?' && entry.path.endsWith('/')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* One probe record as the row the panel shows.
|
|
30
|
+
*
|
|
31
|
+
* Same precedence as `bucketStatus` (an untracked record is a `?` row, a
|
|
32
|
+
* conflict is `U`, an index half wins over the worktree half) but ONE row per
|
|
33
|
+
* path, in the order git answered — the expansion is a list under a parent row,
|
|
34
|
+
* not the two-section view.
|
|
35
|
+
*/
|
|
36
|
+
export function childRows(detail: GitStatusDetail): ChangeEntry[] {
|
|
37
|
+
const rows: ChangeEntry[] = []
|
|
38
|
+
for (const entry of detail.entries) {
|
|
39
|
+
const { path, x, y, origPath } = entry
|
|
40
|
+
const base = origPath === undefined ? {} : { oldPath: origPath }
|
|
41
|
+
if (x === '?' && y === '?') {
|
|
42
|
+
rows.push({ path, status: '?', staged: false, ...base })
|
|
43
|
+
continue
|
|
44
|
+
}
|
|
45
|
+
if (isConflictStatus(x, y)) {
|
|
46
|
+
rows.push({ path, status: 'U', staged: false, ...base })
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (x !== ' ' && x !== '?') {
|
|
50
|
+
rows.push({ path, status: x, staged: true, ...base })
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
rows.push({ path, status: y, staged: false, ...base })
|
|
54
|
+
}
|
|
55
|
+
return rows
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Expand one directory row from a probe answer.
|
|
60
|
+
*
|
|
61
|
+
* @param current - the expansions in force.
|
|
62
|
+
* @param dirPath - the directory row that was clicked (`dir/`).
|
|
63
|
+
* @param stdout - the raw `-z` stdout of `statusDirCommand` for that directory.
|
|
64
|
+
* @returns a new map (the same one when the path is unusable).
|
|
65
|
+
*/
|
|
66
|
+
export function expandDir(current: DirExpansions, dirPath: string, stdout: string): DirExpansions {
|
|
67
|
+
if (dirPath === '') return current
|
|
68
|
+
const next = new Map(current)
|
|
69
|
+
next.set(dirPath, childRows(parseStatusDetail(stdout)))
|
|
70
|
+
return next
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Collapse one directory row.
|
|
75
|
+
*
|
|
76
|
+
* @returns a new map, or the same one when nothing was expanded for that path.
|
|
77
|
+
*/
|
|
78
|
+
export function collapseDir(current: DirExpansions, dirPath: string): DirExpansions {
|
|
79
|
+
if (!current.has(dirPath)) return current
|
|
80
|
+
const next = new Map(current)
|
|
81
|
+
next.delete(dirPath)
|
|
82
|
+
return next
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Drop rows that are no longer untracked from the expansions.
|
|
87
|
+
*
|
|
88
|
+
* A path that was just STAGED stops being an untracked row: it must leave the
|
|
89
|
+
* expansion (it now lives under "staged changes"), and a directory that was
|
|
90
|
+
* staged itself takes its whole expansion with it. Without this the same file
|
|
91
|
+
* would be listed twice — once as `??` under its directory and once as `A `.
|
|
92
|
+
*
|
|
93
|
+
* @param current - the expansions in force.
|
|
94
|
+
* @param paths - repo-relative paths that were just staged.
|
|
95
|
+
* @returns a new map, or the same one when nothing matched.
|
|
96
|
+
*/
|
|
97
|
+
export function dropExpandedRows(current: DirExpansions, paths: readonly string[]): DirExpansions {
|
|
98
|
+
if (paths.length === 0 || current.size === 0) return current
|
|
99
|
+
const gone = new Set(paths)
|
|
100
|
+
let next: Map<string, readonly ChangeEntry[]> | undefined
|
|
101
|
+
for (const [dirPath, rows] of current) {
|
|
102
|
+
if (gone.has(dirPath)) {
|
|
103
|
+
next = next ?? new Map(current)
|
|
104
|
+
next.delete(dirPath)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
const kept = rows.filter(row => !gone.has(row.path))
|
|
108
|
+
if (kept.length === rows.length) continue
|
|
109
|
+
next = next ?? new Map(current)
|
|
110
|
+
// Nothing left under it: collapse rather than show an empty expansion.
|
|
111
|
+
if (kept.length === 0) next.delete(dirPath)
|
|
112
|
+
else next.set(dirPath, kept)
|
|
113
|
+
}
|
|
114
|
+
return next ?? current
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Drop the expansions whose directory row is gone from the status.
|
|
119
|
+
*
|
|
120
|
+
* This is what keeps an expansion honest across a full refresh: it survives
|
|
121
|
+
* while git still reports the same untracked directory row, and disappears the
|
|
122
|
+
* moment that row does — staged, deleted, or simply no longer untracked. It is
|
|
123
|
+
* applied to EVERY status the panel adopts (a refresh, or a local stage), so a
|
|
124
|
+
* stale expansion can never outlive the row it hangs from.
|
|
125
|
+
*
|
|
126
|
+
* @param current - the expansions in force.
|
|
127
|
+
* @param detail - the status the panel is about to show.
|
|
128
|
+
* @returns a new map, or the same one when every expansion still applies.
|
|
129
|
+
*/
|
|
130
|
+
export function pruneDirExpansions(current: DirExpansions, detail: GitStatusDetail): DirExpansions {
|
|
131
|
+
let next: Map<string, readonly ChangeEntry[]> | undefined
|
|
132
|
+
for (const dirPath of current.keys()) {
|
|
133
|
+
const alive = detail.entries.some(entry => entry.path === dirPath && isUntrackedDirRow(entry))
|
|
134
|
+
if (alive) continue
|
|
135
|
+
next = next ?? new Map(current)
|
|
136
|
+
next.delete(dirPath)
|
|
137
|
+
}
|
|
138
|
+
return next ?? current
|
|
139
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The floating "add selection to conversation" popup for the remote code
|
|
3
|
+
* viewer: a viewport-anchored button portaled to `document.body`, kept alive
|
|
4
|
+
* across the selection gesture and committed on click. Vendored
|
|
5
|
+
* behavior-identical from dsh-better-sidebar 0.25.0 (`selection-popup.ts`);
|
|
6
|
+
* the only adaptation is this header (the remote viewer is CodeMirror-only,
|
|
7
|
+
* so there is no preview/edit mode flip to hide on — the caller still hides
|
|
8
|
+
* on surface scrolls, selection collapse and gate flips).
|
|
9
|
+
*
|
|
10
|
+
* Dismissal contract (fixes upstream issue #425): the popup must never
|
|
11
|
+
* outlive its editor surface. The sidebar keeps every tab MOUNTED —
|
|
12
|
+
* switching tabs only flips the pane cell to `display:none` and collapsing
|
|
13
|
+
* the panel translates it off-screen — while the portaled `position:fixed`
|
|
14
|
+
* button stays pinned to its viewport anchor, ignoring both. The caller
|
|
15
|
+
* already hides on surface scrolls, selection collapse and mode flips; this
|
|
16
|
+
* hook adds the global dismissal that covers everything else:
|
|
17
|
+
*
|
|
18
|
+
* - any `mousedown` outside the button (tab bar, composer, another pane,
|
|
19
|
+
* the collapse toggle, …) closes it;
|
|
20
|
+
* - `Escape` closes it;
|
|
21
|
+
* - the document going hidden (`visibilitychange`) or the window losing
|
|
22
|
+
* focus closes it;
|
|
23
|
+
* - an `IntersectionObserver` on the editor surface closes it as soon as the
|
|
24
|
+
* surface leaves the viewport — the tab-switch (`display:none`) and
|
|
25
|
+
* panel-collapse (translated off-screen) paths have no DOM events of their
|
|
26
|
+
* own, so the geometry signal is the only reliable one.
|
|
27
|
+
*
|
|
28
|
+
* The button's own `mousedown` is never treated as an outside click: the
|
|
29
|
+
* caller preventDefaults it to keep the selection/caret alive until the
|
|
30
|
+
* click commits (the hook's capture-phase listener runs first and must not
|
|
31
|
+
* hide for it).
|
|
32
|
+
*/
|
|
33
|
+
import { useEffect, useRef, useState, type RefObject } from 'react'
|
|
34
|
+
|
|
35
|
+
/** The floating "add to conversation" action: payload + viewport anchor. */
|
|
36
|
+
export interface SelectionPopup {
|
|
37
|
+
insert: string
|
|
38
|
+
left: number
|
|
39
|
+
top: number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SelectionPopupOptions {
|
|
43
|
+
/** Commit the payload into the composer draft (button click). */
|
|
44
|
+
onCommit(insert: string): void
|
|
45
|
+
/**
|
|
46
|
+
* The DOM surface that must stay on screen for the popup to live: the
|
|
47
|
+
* CodeMirror host. Called lazily (refs are null until the content loads).
|
|
48
|
+
*/
|
|
49
|
+
getSurface(): HTMLElement | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SelectionPopupControls {
|
|
53
|
+
/** The current popup (null = hidden). */
|
|
54
|
+
popup: SelectionPopup | null
|
|
55
|
+
/** Attach to the portaled button element. */
|
|
56
|
+
buttonRef: RefObject<HTMLButtonElement>
|
|
57
|
+
/** Anchor the popup above a selection (viewport-clamped). */
|
|
58
|
+
show(insert: string, left: number, top: number): void
|
|
59
|
+
/** Hide the popup (idempotent). */
|
|
60
|
+
hide(): void
|
|
61
|
+
/** The button's click: commit the stored payload, then hide. */
|
|
62
|
+
commit(): void
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function useSelectionPopup(options: SelectionPopupOptions): SelectionPopupControls {
|
|
66
|
+
// Latest-callback refs: the dismissal listeners live for the mount's
|
|
67
|
+
// lifetime, so they must not capture stale closures across renders.
|
|
68
|
+
const onCommitRef = useRef(options.onCommit)
|
|
69
|
+
const getSurfaceRef = useRef(options.getSurface)
|
|
70
|
+
onCommitRef.current = options.onCommit
|
|
71
|
+
getSurfaceRef.current = options.getSurface
|
|
72
|
+
|
|
73
|
+
const [popup, setPopup] = useState<SelectionPopup | null>(null)
|
|
74
|
+
/** Live mirror for click/event-time reads (no re-render race). */
|
|
75
|
+
const popupRef = useRef<SelectionPopup | null>(null)
|
|
76
|
+
/** The portaled button itself (for the outside-click guard). */
|
|
77
|
+
const buttonRef = useRef<HTMLButtonElement>(null)
|
|
78
|
+
/** The surface visibility observer (created lazily on open). */
|
|
79
|
+
const observerRef = useRef<IntersectionObserver | null>(null)
|
|
80
|
+
|
|
81
|
+
const show = (insert: string, left: number, top: number): void => {
|
|
82
|
+
const next: SelectionPopup = {
|
|
83
|
+
insert,
|
|
84
|
+
left: Math.min(Math.max(left, 80), window.innerWidth - 80),
|
|
85
|
+
top,
|
|
86
|
+
}
|
|
87
|
+
popupRef.current = next
|
|
88
|
+
setPopup(next)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const hide = (): void => {
|
|
92
|
+
popupRef.current = null
|
|
93
|
+
setPopup(null)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const commit = (): void => {
|
|
97
|
+
const current = popupRef.current
|
|
98
|
+
if (current === null) return
|
|
99
|
+
onCommitRef.current(current.insert)
|
|
100
|
+
hide()
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Global dismissal listeners, registered once; every handler reads the
|
|
104
|
+
// live popup ref so it no-ops while no popup is open.
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const onMouseDown = (event: MouseEvent): void => {
|
|
107
|
+
if (popupRef.current === null) return
|
|
108
|
+
const button = buttonRef.current
|
|
109
|
+
if (button !== null && (button === event.target || button.contains(event.target as Node))) return
|
|
110
|
+
hide()
|
|
111
|
+
}
|
|
112
|
+
const onKeyDown = (event: KeyboardEvent): void => {
|
|
113
|
+
if (event.key === 'Escape' && popupRef.current !== null) hide()
|
|
114
|
+
}
|
|
115
|
+
const onVisibilityChange = (): void => {
|
|
116
|
+
if (document.hidden && popupRef.current !== null) hide()
|
|
117
|
+
}
|
|
118
|
+
const onWindowBlur = (): void => {
|
|
119
|
+
if (popupRef.current !== null) hide()
|
|
120
|
+
}
|
|
121
|
+
document.addEventListener('mousedown', onMouseDown, true)
|
|
122
|
+
document.addEventListener('keydown', onKeyDown, true)
|
|
123
|
+
document.addEventListener('visibilitychange', onVisibilityChange)
|
|
124
|
+
window.addEventListener('blur', onWindowBlur)
|
|
125
|
+
return () => {
|
|
126
|
+
document.removeEventListener('mousedown', onMouseDown, true)
|
|
127
|
+
document.removeEventListener('keydown', onKeyDown, true)
|
|
128
|
+
document.removeEventListener('visibilitychange', onVisibilityChange)
|
|
129
|
+
window.removeEventListener('blur', onWindowBlur)
|
|
130
|
+
observerRef.current?.disconnect()
|
|
131
|
+
observerRef.current = null
|
|
132
|
+
}
|
|
133
|
+
}, [])
|
|
134
|
+
|
|
135
|
+
// Re-target the visibility observer whenever a popup opens: the surface
|
|
136
|
+
// refs may have been null at mount (content loads async), so the surface
|
|
137
|
+
// is only trustworthy at open time. The boolean flip is the only thing
|
|
138
|
+
// that must re-run this; an anchor move while open keeps the same surface.
|
|
139
|
+
const popupOpen = popup !== null
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (!popupOpen) return
|
|
142
|
+
observerRef.current?.disconnect()
|
|
143
|
+
observerRef.current = null
|
|
144
|
+
if (typeof IntersectionObserver === 'undefined') return
|
|
145
|
+
const surface = getSurfaceRef.current()
|
|
146
|
+
if (surface === null) return
|
|
147
|
+
const observer = new IntersectionObserver((entries) => {
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (!entry.isIntersecting) hide()
|
|
150
|
+
}
|
|
151
|
+
}, { threshold: 0 })
|
|
152
|
+
observerRef.current = observer
|
|
153
|
+
observer.observe(surface)
|
|
154
|
+
}, [popupOpen])
|
|
155
|
+
|
|
156
|
+
return { popup, buttonRef, show, hide, commit }
|
|
157
|
+
}
|