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.
Files changed (200) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +385 -0
  3. package/README.md +347 -2
  4. package/cordis.patch.yml +259 -0
  5. package/icon.svg +7 -0
  6. package/lib/client.js +62602 -0
  7. package/lib/index.js +4287 -0
  8. package/lib/p2-bridge-refs.js +10 -0
  9. package/lib/p2-bridge.js +16 -0
  10. package/lib/p2-router-fs.js +252 -0
  11. package/lib/p2-router-refs.js +466 -0
  12. package/lib/p2-router-spill.js +62 -0
  13. package/lib/p2-router-subprocess.js +64 -0
  14. package/lib/p2-shell-bash.js +24 -0
  15. package/lib/ssh-proc-arI5lm4W.js +632 -0
  16. package/lib/types/changes/recorder.d.ts +240 -0
  17. package/lib/types/changes/status.d.ts +201 -0
  18. package/lib/types/changes/text-diff.d.ts +65 -0
  19. package/lib/types/client/CodeEditor.d.ts +125 -0
  20. package/lib/types/client/Dialog.d.ts +14 -0
  21. package/lib/types/client/DirectoryFlow.d.ts +30 -0
  22. package/lib/types/client/EditorPane.d.ts +30 -0
  23. package/lib/types/client/HostsView.d.ts +10 -0
  24. package/lib/types/client/NativeExplorer.d.ts +39 -0
  25. package/lib/types/client/RemoteDirectoryPicker.d.ts +16 -0
  26. package/lib/types/client/RemoteEditorView.d.ts +20 -0
  27. package/lib/types/client/RemoteExplorer.d.ts +18 -0
  28. package/lib/types/client/RemoteRoot.d.ts +16 -0
  29. package/lib/types/client/SearchView.d.ts +14 -0
  30. package/lib/types/client/SettingsHosts.d.ts +15 -0
  31. package/lib/types/client/TerminalView.d.ts +16 -0
  32. package/lib/types/client/WorkspaceWizard.d.ts +16 -0
  33. package/lib/types/client/WorkspacesView.d.ts +14 -0
  34. package/lib/types/client/XTerm.d.ts +15 -0
  35. package/lib/types/client/api.d.ts +162 -0
  36. package/lib/types/client/changes/ChangesDock.d.ts +35 -0
  37. package/lib/types/client/changes/model.d.ts +36 -0
  38. package/lib/types/client/changes/undo-affordance.d.ts +48 -0
  39. package/lib/types/client/changes/undo-context.d.ts +25 -0
  40. package/lib/types/client/composer.d.ts +165 -0
  41. package/lib/types/client/i18n.d.ts +17 -0
  42. package/lib/types/client/index.d.ts +14 -0
  43. package/lib/types/client/remote-file-open-target.d.ts +247 -0
  44. package/lib/types/client/remote-file-open.d.ts +35 -0
  45. package/lib/types/client/reveal.d.ts +15 -0
  46. package/lib/types/client/scm/DiffView.d.ts +24 -0
  47. package/lib/types/client/scm/HistoryPanel.d.ts +12 -0
  48. package/lib/types/client/scm/RepoPicker.d.ts +24 -0
  49. package/lib/types/client/scm/ScmPanel.d.ts +15 -0
  50. package/lib/types/client/scm/actions.d.ts +170 -0
  51. package/lib/types/client/scm/diff-model.d.ts +29 -0
  52. package/lib/types/client/scm/stage-queue.d.ts +99 -0
  53. package/lib/types/client/scm/status-map.d.ts +157 -0
  54. package/lib/types/client/scm/untracked-dirs.d.ts +72 -0
  55. package/lib/types/client/selection-popup.d.ts +61 -0
  56. package/lib/types/client/store.d.ts +437 -0
  57. package/lib/types/client/theme.d.ts +15 -0
  58. package/lib/types/client/ui.d.ts +46 -0
  59. package/lib/types/client/upload-plan.d.ts +78 -0
  60. package/lib/types/client/upload-source.d.ts +46 -0
  61. package/lib/types/client/use-remote-chat-target.d.ts +17 -0
  62. package/lib/types/client/vsc/FindWidget.d.ts +81 -0
  63. package/lib/types/client/vsc/change-marks.d.ts +150 -0
  64. package/lib/types/client/vsc/editor-langs.d.ts +5 -0
  65. package/lib/types/client/vsc/editor-palette.d.ts +37 -0
  66. package/lib/types/client/vsc/editor-theme.d.ts +32 -0
  67. package/lib/types/client/vsc/fallback-highlight.d.ts +26 -0
  68. package/lib/types/client/vsc/kit.d.ts +195 -0
  69. package/lib/types/client/vsc/markdown.d.ts +11 -0
  70. package/lib/types/client/vsc/reveal-line.d.ts +38 -0
  71. package/lib/types/client/vsc/tokenize.d.ts +104 -0
  72. package/lib/types/deploy.d.ts +94 -0
  73. package/lib/types/fs-ops.d.ts +8 -0
  74. package/lib/types/git-format.d.ts +34 -0
  75. package/lib/types/git-ops.d.ts +183 -0
  76. package/lib/types/http.d.ts +14 -0
  77. package/lib/types/importers.d.ts +20 -0
  78. package/lib/types/index.d.ts +87 -0
  79. package/lib/types/keygen.d.ts +19 -0
  80. package/lib/types/loopback.d.ts +7 -0
  81. package/lib/types/p2/bridge-refs.d.ts +21 -0
  82. package/lib/types/p2/bridge.d.ts +24 -0
  83. package/lib/types/p2/file-refs.d.ts +73 -0
  84. package/lib/types/p2/helper-rpc.d.ts +63 -0
  85. package/lib/types/p2/in-flight-gate.d.ts +25 -0
  86. package/lib/types/p2/router-fs.d.ts +92 -0
  87. package/lib/types/p2/router-refs.d.ts +45 -0
  88. package/lib/types/p2/router-spill.d.ts +11 -0
  89. package/lib/types/p2/router-subprocess.d.ts +23 -0
  90. package/lib/types/p2/shell-bash.d.ts +16 -0
  91. package/lib/types/p2/ssh-proc.d.ts +22 -0
  92. package/lib/types/p2/worlds.d.ts +179 -0
  93. package/lib/types/paths.d.ts +12 -0
  94. package/lib/types/pool.d.ts +105 -0
  95. package/lib/types/protocol.d.ts +452 -0
  96. package/lib/types/routes.d.ts +27 -0
  97. package/lib/types/search-ops.d.ts +68 -0
  98. package/lib/types/sftp.d.ts +90 -0
  99. package/lib/types/store.d.ts +66 -0
  100. package/lib/types/terminal.d.ts +51 -0
  101. package/lib/types/timeouts.d.ts +83 -0
  102. package/lib/types/tools.d.ts +41 -0
  103. package/lib/worlds-PUumRIh7.js +18765 -0
  104. package/locale/en.json +6 -0
  105. package/locale/zh.json +6 -0
  106. package/package.json +189 -4
  107. package/src/changes/recorder.ts +902 -0
  108. package/src/changes/status.ts +448 -0
  109. package/src/changes/text-diff.ts +377 -0
  110. package/src/client/CodeEditor.tsx +923 -0
  111. package/src/client/Dialog.tsx +76 -0
  112. package/src/client/DirectoryFlow.tsx +431 -0
  113. package/src/client/EditorPane.tsx +512 -0
  114. package/src/client/HostsView.tsx +312 -0
  115. package/src/client/NativeExplorer.tsx +121 -0
  116. package/src/client/RemoteDirectoryPicker.tsx +157 -0
  117. package/src/client/RemoteEditorView.tsx +54 -0
  118. package/src/client/RemoteExplorer.tsx +1398 -0
  119. package/src/client/RemoteRoot.tsx +389 -0
  120. package/src/client/SearchView.tsx +407 -0
  121. package/src/client/SettingsHosts.tsx +136 -0
  122. package/src/client/TerminalView.tsx +308 -0
  123. package/src/client/WorkspaceWizard.tsx +199 -0
  124. package/src/client/WorkspacesView.tsx +93 -0
  125. package/src/client/XTerm.tsx +308 -0
  126. package/src/client/api.ts +331 -0
  127. package/src/client/changes/ChangesDock.tsx +348 -0
  128. package/src/client/changes/changes.css +230 -0
  129. package/src/client/changes/model.ts +347 -0
  130. package/src/client/changes/undo-affordance.ts +389 -0
  131. package/src/client/changes/undo-context.ts +34 -0
  132. package/src/client/composer.ts +436 -0
  133. package/src/client/css.d.ts +7 -0
  134. package/src/client/i18n.ts +702 -0
  135. package/src/client/index.tsx +286 -0
  136. package/src/client/remote-file-open-target.ts +553 -0
  137. package/src/client/remote-file-open.ts +261 -0
  138. package/src/client/reveal.ts +33 -0
  139. package/src/client/scm/DiffView.tsx +570 -0
  140. package/src/client/scm/HistoryPanel.tsx +353 -0
  141. package/src/client/scm/RepoPicker.tsx +238 -0
  142. package/src/client/scm/ScmPanel.tsx +857 -0
  143. package/src/client/scm/actions.ts +460 -0
  144. package/src/client/scm/diff-model.ts +206 -0
  145. package/src/client/scm/stage-queue.ts +264 -0
  146. package/src/client/scm/status-map.ts +381 -0
  147. package/src/client/scm/untracked-dirs.ts +139 -0
  148. package/src/client/selection-popup.ts +157 -0
  149. package/src/client/store.tsx +1670 -0
  150. package/src/client/theme.ts +54 -0
  151. package/src/client/ui.tsx +136 -0
  152. package/src/client/upload-plan.ts +153 -0
  153. package/src/client/upload-source.ts +145 -0
  154. package/src/client/use-remote-chat-target.ts +153 -0
  155. package/src/client/vsc/FindWidget.tsx +446 -0
  156. package/src/client/vsc/change-marks.ts +586 -0
  157. package/src/client/vsc/editor-langs.ts +137 -0
  158. package/src/client/vsc/editor-palette.ts +107 -0
  159. package/src/client/vsc/editor-theme.ts +93 -0
  160. package/src/client/vsc/fallback-highlight.ts +145 -0
  161. package/src/client/vsc/kit.tsx +777 -0
  162. package/src/client/vsc/markdown.tsx +172 -0
  163. package/src/client/vsc/reveal-line.ts +142 -0
  164. package/src/client/vsc/tokenize.ts +569 -0
  165. package/src/client/vsc/vsc.css +2669 -0
  166. package/src/deploy.ts +481 -0
  167. package/src/fs-ops.ts +57 -0
  168. package/src/git-format.ts +205 -0
  169. package/src/git-ops.ts +693 -0
  170. package/src/http.ts +55 -0
  171. package/src/importers.ts +141 -0
  172. package/src/index.ts +442 -0
  173. package/src/keygen.ts +77 -0
  174. package/src/loopback.ts +47 -0
  175. package/src/p2/bridge-refs.ts +27 -0
  176. package/src/p2/bridge.ts +32 -0
  177. package/src/p2/file-refs.ts +392 -0
  178. package/src/p2/helper-rpc.ts +394 -0
  179. package/src/p2/in-flight-gate.ts +63 -0
  180. package/src/p2/router-fs.ts +327 -0
  181. package/src/p2/router-refs.ts +166 -0
  182. package/src/p2/router-spill.ts +75 -0
  183. package/src/p2/router-subprocess.ts +92 -0
  184. package/src/p2/shell-bash.ts +30 -0
  185. package/src/p2/ssh-proc.ts +802 -0
  186. package/src/p2/worlds.ts +735 -0
  187. package/src/paths.ts +50 -0
  188. package/src/pool.ts +811 -0
  189. package/src/protocol.ts +460 -0
  190. package/src/routes.ts +802 -0
  191. package/src/search-ops.ts +220 -0
  192. package/src/sftp.ts +677 -0
  193. package/src/store.ts +364 -0
  194. package/src/terminal.ts +291 -0
  195. package/src/timeouts.ts +122 -0
  196. package/src/tools.ts +320 -0
  197. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.helper.json +16 -0
  198. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz +4 -0
  199. package/vendor/dsh-remote-helpers/dsh-ssh-helper-0.2.1-alpha.2-linux-x64.tar.gz.sha256 +1 -0
  200. 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
+ }