@plannotator/ui 0.22.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.
Files changed (305) hide show
  1. package/README.md +60 -0
  2. package/assets/diff-options.png +0 -0
  3. package/assets/icon-codex.png +0 -0
  4. package/assets/look-flat.png +0 -0
  5. package/assets/look-grid.png +0 -0
  6. package/assets/review-sections.png +0 -0
  7. package/assets/review-tree.png +0 -0
  8. package/assets/workspaces.webp +0 -0
  9. package/components/AISettingsTab.tsx +145 -0
  10. package/components/ActionMenu.tsx +101 -0
  11. package/components/AgentControls.tsx +216 -0
  12. package/components/AgentsTab.tsx +1294 -0
  13. package/components/AnnotationPanel.tsx +731 -0
  14. package/components/AnnotationSidebar.tsx +86 -0
  15. package/components/AnnotationToolbar.tsx +323 -0
  16. package/components/AnnotationToolstrip.tsx +355 -0
  17. package/components/ApproveDropdown.tsx +170 -0
  18. package/components/AttachmentsButton.tsx +410 -0
  19. package/components/BlockRenderer.tsx +163 -0
  20. package/components/BorderTrail.tsx +38 -0
  21. package/components/CodeFilePicker.tsx +64 -0
  22. package/components/CodeFilePopout.tsx +602 -0
  23. package/components/CodePathValidationContext.tsx +14 -0
  24. package/components/CommentPopover.tsx +512 -0
  25. package/components/CompletionOverlay.tsx +99 -0
  26. package/components/ConfirmDialog.tsx +128 -0
  27. package/components/DocBadges.tsx +231 -0
  28. package/components/EditorAnnotationCard.tsx +65 -0
  29. package/components/ExportModal.tsx +565 -0
  30. package/components/FloatingQuickLabelPicker.tsx +144 -0
  31. package/components/GitHubIcon.tsx +16 -0
  32. package/components/GitLabIcon.tsx +30 -0
  33. package/components/GraphvizBlock.tsx +511 -0
  34. package/components/ImageAnnotator/Canvas.tsx +118 -0
  35. package/components/ImageAnnotator/Toolbar.tsx +218 -0
  36. package/components/ImageAnnotator/index.tsx +272 -0
  37. package/components/ImageAnnotator/types.ts +39 -0
  38. package/components/ImageAnnotator/utils.ts +147 -0
  39. package/components/ImageThumbnail.tsx +126 -0
  40. package/components/ImportModal.tsx +146 -0
  41. package/components/InlineMarkdown.tsx +1094 -0
  42. package/components/KeyboardShortcuts.tsx +208 -0
  43. package/components/Landing.tsx +496 -0
  44. package/components/ListItemBody.tsx +62 -0
  45. package/components/ListMarker.tsx +73 -0
  46. package/components/LookAndFeelAnnouncementDialog.tsx +230 -0
  47. package/components/MarkdownEditor.tsx +44 -0
  48. package/components/MenuVersionSection.tsx +91 -0
  49. package/components/MermaidBlock.tsx +579 -0
  50. package/components/ModeToggle.tsx +68 -0
  51. package/components/OpenInAppButton.tsx +299 -0
  52. package/components/OverlayScrollArea.tsx +82 -0
  53. package/components/PermissionModeSetup.tsx +92 -0
  54. package/components/PinpointOverlay.tsx +99 -0
  55. package/components/PlanAIAnnouncementDialog.tsx +187 -0
  56. package/components/PlanHeaderMenu.tsx +299 -0
  57. package/components/PopoutDialog.tsx +91 -0
  58. package/components/Popover.tsx +27 -0
  59. package/components/ProviderIcons.tsx +51 -0
  60. package/components/PullRequestIcon.tsx +12 -0
  61. package/components/QuickLabelDropdown.tsx +63 -0
  62. package/components/RenderedMarkdown.tsx +54 -0
  63. package/components/RepoIcon.tsx +12 -0
  64. package/components/ResizeHandle.tsx +86 -0
  65. package/components/ReviewAgentsIcon.tsx +18 -0
  66. package/components/SearchableSelect.tsx +157 -0
  67. package/components/Settings.tsx +2220 -0
  68. package/components/SparklesIcon.tsx +45 -0
  69. package/components/StickyHeaderLane.tsx +260 -0
  70. package/components/TableOfContents.tsx +157 -0
  71. package/components/TaterSpritePullup.tsx +34 -0
  72. package/components/TaterSpriteRunning.tsx +54 -0
  73. package/components/TaterSpriteSitting.tsx +37 -0
  74. package/components/TextShimmer.tsx +57 -0
  75. package/components/ThemeProvider.tsx +162 -0
  76. package/components/ThemeTab.tsx +139 -0
  77. package/components/ToolbarButtons.tsx +116 -0
  78. package/components/Tooltip.tsx +45 -0
  79. package/components/Viewer.tsx +977 -0
  80. package/components/ai/AIProviderBar.tsx +95 -0
  81. package/components/ai/DocumentAIChatPanel.tsx +313 -0
  82. package/components/blocks/AlertBlock.tsx +58 -0
  83. package/components/blocks/Callout.tsx +60 -0
  84. package/components/blocks/CodeBlock.tsx +75 -0
  85. package/components/blocks/HtmlBlock.tsx +122 -0
  86. package/components/blocks/MathBlock.tsx +36 -0
  87. package/components/blocks/TableBlock.tsx +146 -0
  88. package/components/blocks/TablePopout.tsx +279 -0
  89. package/components/blocks/TableToolbar.tsx +153 -0
  90. package/components/blocks/proseBody.tsx +102 -0
  91. package/components/core/button.tsx +44 -0
  92. package/components/core/textarea.tsx +26 -0
  93. package/components/diagramLanguages.ts +14 -0
  94. package/components/goal-setup/GoalSetupSurface.tsx +1354 -0
  95. package/components/html-viewer/HtmlViewer.tsx +386 -0
  96. package/components/html-viewer/bridge-script.ts +505 -0
  97. package/components/html-viewer/index.ts +1 -0
  98. package/components/html-viewer/useHtmlAnnotation.ts +400 -0
  99. package/components/icons/AgentIcons.tsx +66 -0
  100. package/components/icons/AppIcon.tsx +56 -0
  101. package/components/icons/MessagesIcon.tsx +11 -0
  102. package/components/icons/ObsidianIcons.tsx +208 -0
  103. package/components/icons/app/android-studio.svg +369 -0
  104. package/components/icons/app/antigravity.svg +97 -0
  105. package/components/icons/app/cursor.svg +16 -0
  106. package/components/icons/app/file-explorer.svg +20 -0
  107. package/components/icons/app/finder.png +0 -0
  108. package/components/icons/app/ghostty.svg +13 -0
  109. package/components/icons/app/iterm2.svg +13 -0
  110. package/components/icons/app/powershell.svg +14 -0
  111. package/components/icons/app/sublime-text.svg +17 -0
  112. package/components/icons/app/terminal.png +0 -0
  113. package/components/icons/app/textmate.png +0 -0
  114. package/components/icons/app/vscode.svg +39 -0
  115. package/components/icons/app/warp.png +0 -0
  116. package/components/icons/app/xcode.png +0 -0
  117. package/components/icons/app/zed-dark.svg +15 -0
  118. package/components/icons/app/zed.svg +15 -0
  119. package/components/icons/themeIcons.tsx +47 -0
  120. package/components/mermaidSvg.ts +33 -0
  121. package/components/plan-diff/PlanCleanDiffView.tsx +884 -0
  122. package/components/plan-diff/PlanDiffBadge.tsx +48 -0
  123. package/components/plan-diff/PlanDiffModeSwitcher.tsx +103 -0
  124. package/components/plan-diff/PlanDiffViewer.tsx +214 -0
  125. package/components/plan-diff/PlanRawDiffView.tsx +102 -0
  126. package/components/plan-diff/VSCodeIcon.tsx +132 -0
  127. package/components/settings/HooksTab.tsx +208 -0
  128. package/components/sidebar/ArchiveBrowser.tsx +98 -0
  129. package/components/sidebar/CountBadge.tsx +12 -0
  130. package/components/sidebar/FileBrowser.tsx +498 -0
  131. package/components/sidebar/MessagesBrowser.tsx +109 -0
  132. package/components/sidebar/SidebarContainer.tsx +353 -0
  133. package/components/sidebar/SidebarTabs.tsx +155 -0
  134. package/components/sidebar/VersionBrowser.tsx +141 -0
  135. package/components/types.d.ts +7 -0
  136. package/components/ui/badge.tsx +44 -0
  137. package/components/ui/button.tsx +83 -0
  138. package/components/ui/card.tsx +58 -0
  139. package/components/ui/dialog.tsx +106 -0
  140. package/components/ui/dropdown-menu.tsx +240 -0
  141. package/components/ui/state-pill.tsx +45 -0
  142. package/components/ui/tabs.tsx +46 -0
  143. package/components/ui/textarea.tsx +25 -0
  144. package/config/configStore.ts +214 -0
  145. package/config/index.ts +4 -0
  146. package/config/reviewView.ts +42 -0
  147. package/config/settings.ts +310 -0
  148. package/config/useConfig.ts +20 -0
  149. package/configure.ts +69 -0
  150. package/globals.d.ts +14 -0
  151. package/hooks/pfm/useCodeFilePopout.ts +111 -0
  152. package/hooks/useAIChat.ts +532 -0
  153. package/hooks/useAIProviderConfig.ts +115 -0
  154. package/hooks/useActiveSection.ts +78 -0
  155. package/hooks/useAgentJobs.ts +306 -0
  156. package/hooks/useAgentSettings.ts +579 -0
  157. package/hooks/useAgents.ts +90 -0
  158. package/hooks/useAnnotationDraft.ts +523 -0
  159. package/hooks/useAnnotationHighlighter.ts +1170 -0
  160. package/hooks/useArchive.ts +171 -0
  161. package/hooks/useAutoClose.ts +104 -0
  162. package/hooks/useCodeAnnotationDraft.ts +194 -0
  163. package/hooks/useCodeFilePopout.ts +1 -0
  164. package/hooks/useDismissOnOutsideAndEscape.ts +50 -0
  165. package/hooks/useDraggable.ts +108 -0
  166. package/hooks/useEditorAnnotations.ts +64 -0
  167. package/hooks/useExternalAnnotationHighlights.ts +105 -0
  168. package/hooks/useExternalAnnotations.ts +268 -0
  169. package/hooks/useFileBrowser.ts +402 -0
  170. package/hooks/useInputMethodSwitch.ts +89 -0
  171. package/hooks/useIsMobile.ts +17 -0
  172. package/hooks/useLinkedDoc.ts +494 -0
  173. package/hooks/useOverlayViewport.ts +36 -0
  174. package/hooks/usePinpoint.ts +182 -0
  175. package/hooks/usePlanDiff.ts +176 -0
  176. package/hooks/usePrintMode.ts +27 -0
  177. package/hooks/useResizablePanel.ts +173 -0
  178. package/hooks/useScrollViewport.ts +38 -0
  179. package/hooks/useSharing.ts +441 -0
  180. package/hooks/useSidebar.ts +55 -0
  181. package/hooks/useUpdateCheck.ts +132 -0
  182. package/hooks/useValidatedCodePaths.ts +94 -0
  183. package/icons/GitUser.tsx +17 -0
  184. package/lib/utils.ts +11 -0
  185. package/package.json +117 -0
  186. package/plannotator.webp +0 -0
  187. package/print.css +427 -0
  188. package/shortcuts/code-review/ai.shortcuts.ts +25 -0
  189. package/shortcuts/code-review/allFilesDiff.shortcuts.ts +46 -0
  190. package/shortcuts/code-review/annotationToolbar.shortcuts.ts +30 -0
  191. package/shortcuts/code-review/fileTree.shortcuts.ts +35 -0
  192. package/shortcuts/code-review/prComments.shortcuts.ts +18 -0
  193. package/shortcuts/code-review/suggestionModal.shortcuts.ts +25 -0
  194. package/shortcuts/code-review/tourDialog.shortcuts.ts +18 -0
  195. package/shortcuts/core.ts +399 -0
  196. package/shortcuts/index.ts +21 -0
  197. package/shortcuts/plan-review/annotationPanel.shortcuts.ts +25 -0
  198. package/shortcuts/plan-review/annotationToolbar.shortcuts.ts +39 -0
  199. package/shortcuts/plan-review/commentPopover.shortcuts.ts +21 -0
  200. package/shortcuts/plan-review/goalSetup.shortcuts.ts +10 -0
  201. package/shortcuts/plan-review/imageAnnotator.shortcuts.ts +42 -0
  202. package/shortcuts/plan-review/inputMethod.shortcuts.ts +30 -0
  203. package/shortcuts/plan-review/sidebar.shortcuts.ts +34 -0
  204. package/shortcuts/plan-review/viewer.shortcuts.ts +24 -0
  205. package/shortcuts/runtime.ts +259 -0
  206. package/sprite_package_additional/index.html +34 -0
  207. package/sprite_package_additional/sprite.png +0 -0
  208. package/sprite_package_new/index.html +34 -0
  209. package/sprite_package_new/sprite.png +0 -0
  210. package/sprite_package_pulluphang/index.html +34 -0
  211. package/sprite_package_pulluphang/sprite.png +0 -0
  212. package/styles.css +1 -0
  213. package/theme.css +893 -0
  214. package/themes/adwaita.css +62 -0
  215. package/themes/andromeeda.css +66 -0
  216. package/themes/aurora-x.css +66 -0
  217. package/themes/ayu-dark.css +66 -0
  218. package/themes/caffeine.css +113 -0
  219. package/themes/catppuccin.css +62 -0
  220. package/themes/claude-plus.css +60 -0
  221. package/themes/cursor-hc.css +34 -0
  222. package/themes/cursor-midnight.css +34 -0
  223. package/themes/cursor.css +62 -0
  224. package/themes/dark-plus.css +66 -0
  225. package/themes/doom-64.css +109 -0
  226. package/themes/dracula.css +33 -0
  227. package/themes/everforest-hard.css +62 -0
  228. package/themes/everforest-soft.css +62 -0
  229. package/themes/everforest.css +62 -0
  230. package/themes/github.css +66 -0
  231. package/themes/gruvbox.css +62 -0
  232. package/themes/houston.css +66 -0
  233. package/themes/kanagawa-dragon.css +34 -0
  234. package/themes/kanagawa-lotus.css +34 -0
  235. package/themes/kanagawa-wave.css +34 -0
  236. package/themes/laserwave.css +66 -0
  237. package/themes/material.css +62 -0
  238. package/themes/min.css +66 -0
  239. package/themes/monokai-pro.css +34 -0
  240. package/themes/neutral.css +59 -0
  241. package/themes/night-owl.css +66 -0
  242. package/themes/nord.css +66 -0
  243. package/themes/one-dark-pro.css +66 -0
  244. package/themes/one-light.css +66 -0
  245. package/themes/paulmillr.css +34 -0
  246. package/themes/plannotator.css +60 -0
  247. package/themes/plastic.css +66 -0
  248. package/themes/poimandres.css +66 -0
  249. package/themes/quantum-rose.css +109 -0
  250. package/themes/red.css +66 -0
  251. package/themes/rose-pine.css +62 -0
  252. package/themes/simple.css +124 -0
  253. package/themes/slack.css +66 -0
  254. package/themes/snazzy-light.css +66 -0
  255. package/themes/soft-pop.css +60 -0
  256. package/themes/solar-dusk.css +109 -0
  257. package/themes/solarized.css +66 -0
  258. package/themes/synthwave-84.css +34 -0
  259. package/themes/terminal.css +62 -0
  260. package/themes/tinacious.css +57 -0
  261. package/themes/tokyo-night.css +62 -0
  262. package/themes/vesper.css +62 -0
  263. package/themes/vitesse-black.css +66 -0
  264. package/themes/vitesse.css +62 -0
  265. package/types.ts +264 -0
  266. package/utils/agentSwitch.ts +71 -0
  267. package/utils/aiChatFormat.ts +33 -0
  268. package/utils/aiPrompt.ts +29 -0
  269. package/utils/aiProvider.ts +217 -0
  270. package/utils/anchors.ts +11 -0
  271. package/utils/annotateAgentTerminal.ts +29 -0
  272. package/utils/annotationHelpers.ts +101 -0
  273. package/utils/bear.ts +59 -0
  274. package/utils/blockTargeting.ts +240 -0
  275. package/utils/callback.ts +99 -0
  276. package/utils/commentContent.ts +8 -0
  277. package/utils/defaultNotesApp.ts +20 -0
  278. package/utils/diffFonts.ts +33 -0
  279. package/utils/editorMode.ts +31 -0
  280. package/utils/fileBrowser.ts +40 -0
  281. package/utils/generateId.ts +7 -0
  282. package/utils/generateIdentity.ts +29 -0
  283. package/utils/identity.ts +112 -0
  284. package/utils/inlineTransforms.ts +41 -0
  285. package/utils/inputMethod.ts +17 -0
  286. package/utils/lookAndFeelAnnouncement.ts +18 -0
  287. package/utils/obsidian.ts +201 -0
  288. package/utils/octarine.ts +53 -0
  289. package/utils/parser.ts +991 -0
  290. package/utils/permissionMode.ts +77 -0
  291. package/utils/planAIAnnouncement.ts +17 -0
  292. package/utils/planAgentInstructions.ts +137 -0
  293. package/utils/planDiffEngine.ts +590 -0
  294. package/utils/planSave.ts +49 -0
  295. package/utils/platform.ts +11 -0
  296. package/utils/quickLabels.ts +77 -0
  297. package/utils/reviewAgentInstructions.ts +182 -0
  298. package/utils/sanitizeHtml.ts +45 -0
  299. package/utils/sharing.ts +350 -0
  300. package/utils/slugify.ts +37 -0
  301. package/utils/storage.ts +141 -0
  302. package/utils/themeRegistry.ts +567 -0
  303. package/utils/uiPreferences.ts +34 -0
  304. package/utils/upload.ts +56 -0
  305. package/utils/wideMode.ts +48 -0
@@ -0,0 +1,402 @@
1
+ /**
2
+ * File Browser Hook
3
+ *
4
+ * Manages multiple directory file trees for the sidebar Files tab.
5
+ * Each directory gets its own tree, loading, and error state.
6
+ * Vault directories are supported via the isVault flag — they fetch
7
+ * from the Obsidian vault endpoint instead of the generic files endpoint.
8
+ */
9
+
10
+ import { useState, useCallback, useEffect, useMemo, useRef } from "react";
11
+ import type { VaultNode } from "../types";
12
+ import type { WorkspaceStatusPayload } from "@plannotator/core/workspace-status-types";
13
+
14
+ export interface DirState {
15
+ path: string;
16
+ name: string;
17
+ tree: VaultNode[];
18
+ isLoading: boolean;
19
+ error: string | null;
20
+ workspaceStatus?: WorkspaceStatusPayload;
21
+ /** True after the first successful snapshot; live watching waits for this so watcher setup cannot block initial load. */
22
+ hasLoadedTree?: boolean;
23
+ /** When true, fetches via /api/reference/obsidian/files and opens docs via /api/reference/obsidian/doc */
24
+ isVault?: boolean;
25
+ }
26
+
27
+ export interface UseFileBrowserReturn {
28
+ dirs: DirState[];
29
+ expandedFolders: Set<string>;
30
+ toggleFolder: (key: string) => void;
31
+ collapsedDirs: Set<string>;
32
+ toggleCollapse: (dirPath: string) => void;
33
+ fetchTree: (dirPath: string, options?: { quiet?: boolean }) => void;
34
+ fetchAll: (directories: string[]) => void;
35
+ addVaultDir: (vaultPath: string) => void;
36
+ clearVaultDirs: () => void;
37
+ activeFile: string | null;
38
+ activeDirPath: string | null;
39
+ setActiveFile: (path: string | null) => void;
40
+ }
41
+
42
+ function isPermanentFileBrowserFetchError(status: number): boolean {
43
+ return status >= 400 && status < 500;
44
+ }
45
+
46
+ function normalizeRoot(path: string): string {
47
+ return path.replace(/\\/g, "/").replace(/\/+$/, "");
48
+ }
49
+
50
+ function remapWorkspaceStatusForDir(
51
+ status: WorkspaceStatusPayload | undefined,
52
+ dirPath: string
53
+ ): WorkspaceStatusPayload | undefined {
54
+ if (!status?.rootPath) return status;
55
+ const fromRoot = normalizeRoot(status.rootPath);
56
+ const toRoot = normalizeRoot(dirPath);
57
+ if (!fromRoot) return status;
58
+
59
+ const files: WorkspaceStatusPayload["files"] = {};
60
+ for (const [path, change] of Object.entries(status.files)) {
61
+ const normalizedPath = normalizeRoot(path);
62
+ const nextPath = normalizedPath === fromRoot
63
+ ? toRoot
64
+ : normalizedPath.startsWith(`${fromRoot}/`)
65
+ ? `${toRoot}${normalizedPath.slice(fromRoot.length)}`
66
+ : normalizedPath;
67
+ const normalizedOldPath = change.oldPath ? normalizeRoot(change.oldPath) : undefined;
68
+ const nextOldPath = normalizedOldPath && normalizedOldPath.startsWith(`${fromRoot}/`)
69
+ ? `${toRoot}${normalizedOldPath.slice(fromRoot.length)}`
70
+ : normalizedOldPath;
71
+ files[nextPath] = {
72
+ ...change,
73
+ path: nextPath,
74
+ oldPath: nextOldPath,
75
+ };
76
+ }
77
+
78
+ return {
79
+ ...status,
80
+ rootPath: dirPath,
81
+ files,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * File-tree backend. Defaults to Plannotator's HTTP endpoints (generic files,
87
+ * Obsidian vault, and the SSE live-watch stream) so Plannotator is unchanged. A
88
+ * host (e.g. Workspaces) calls setFileTreeBackend once at startup to source the
89
+ * tree from its own transport instead.
90
+ */
91
+ export interface FileTreeBackend {
92
+ /** Load a directory tree. Resolves to the same shape the /api/reference/files endpoint returns: a Response whose JSON is { tree, workspaceStatus?, error? }. */
93
+ loadTree(dirPath: string): Promise<Response>;
94
+ /** Load an Obsidian vault tree. Resolves to a Response whose JSON is { tree, error? }. */
95
+ loadVaultTree(vaultPath: string): Promise<Response>;
96
+ /**
97
+ * Begin live-watching the given directory paths. `onChange(path)` is invoked
98
+ * (already debounced/deduped) whenever a watched tree should be re-fetched.
99
+ * Returns a cleanup function. Returning undefined means no watcher started.
100
+ */
101
+ watchTrees(paths: string[], onChange: (path: string) => void): (() => void) | undefined;
102
+ }
103
+
104
+ const defaultFileTreeBackend: FileTreeBackend = {
105
+ loadTree(dirPath) {
106
+ return fetch(`/api/reference/files?dirPath=${encodeURIComponent(dirPath)}`);
107
+ },
108
+ loadVaultTree(vaultPath) {
109
+ return fetch(`/api/reference/obsidian/files?vaultPath=${encodeURIComponent(vaultPath)}`);
110
+ },
111
+ watchTrees(paths, onChange) {
112
+ if (typeof EventSource === "undefined") return undefined;
113
+
114
+ const timers = new Map<string, ReturnType<typeof setTimeout>>();
115
+ const readyPaths = new Set<string>();
116
+ const params = new URLSearchParams();
117
+ for (const path of paths) params.append("dirPath", path);
118
+ const source = new EventSource(`/api/reference/files/stream?${params.toString()}`);
119
+ const scheduleFetch = (path: string) => {
120
+ const existing = timers.get(path);
121
+ if (existing) clearTimeout(existing);
122
+ timers.set(path, setTimeout(() => {
123
+ timers.delete(path);
124
+ onChange(path);
125
+ }, 120));
126
+ };
127
+ const scheduleEventFetch = (dirPath: unknown) => {
128
+ if (typeof dirPath === "string" && paths.includes(dirPath)) {
129
+ scheduleFetch(dirPath);
130
+ return;
131
+ }
132
+ for (const path of paths) scheduleFetch(path);
133
+ };
134
+ const hasSeenReady = (dirPath: unknown): boolean => {
135
+ if (typeof dirPath === "string" && paths.includes(dirPath)) {
136
+ if (readyPaths.has(dirPath)) return true;
137
+ readyPaths.add(dirPath);
138
+ return false;
139
+ }
140
+
141
+ const hadAll = paths.every((path) => readyPaths.has(path));
142
+ for (const path of paths) readyPaths.add(path);
143
+ return hadAll;
144
+ };
145
+ source.onmessage = (event) => {
146
+ try {
147
+ const data = JSON.parse(event.data) as { type?: string; dirPath?: string };
148
+ if (data.type === "ready") {
149
+ if (hasSeenReady(data.dirPath)) scheduleEventFetch(data.dirPath);
150
+ return;
151
+ }
152
+ if (data.type !== "changed") return;
153
+ scheduleEventFetch(data.dirPath);
154
+ } catch {
155
+ return;
156
+ }
157
+ };
158
+
159
+ return () => {
160
+ for (const timer of timers.values()) clearTimeout(timer);
161
+ source.close();
162
+ };
163
+ },
164
+ };
165
+
166
+ // Active backend. Defaults to Plannotator's HTTP endpoints so Plannotator is
167
+ // unchanged. A host calls setFileTreeBackend once at startup to override.
168
+ let fileTreeBackend: FileTreeBackend = defaultFileTreeBackend;
169
+
170
+ /** Override the file-tree backend. Call once at app startup. */
171
+ export function setFileTreeBackend(b: FileTreeBackend): void {
172
+ fileTreeBackend = b;
173
+ }
174
+
175
+ /** Reset to the default (HTTP endpoint) backend. Mainly for tests. */
176
+ export function resetFileTreeBackend(): void {
177
+ fileTreeBackend = defaultFileTreeBackend;
178
+ }
179
+
180
+ export function useFileBrowser(): UseFileBrowserReturn {
181
+ const [dirs, setDirs] = useState<DirState[]>([]);
182
+ const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
183
+ const [activeFile, setActiveFile] = useState<string | null>(null);
184
+ const [collapsedDirs, setCollapsedDirs] = useState<Set<string>>(new Set());
185
+
186
+ const toggleCollapse = useCallback((dirPath: string) => {
187
+ setCollapsedDirs((prev) => {
188
+ const next = new Set(prev);
189
+ if (next.has(dirPath)) next.delete(dirPath);
190
+ else next.add(dirPath);
191
+ return next;
192
+ });
193
+ }, []);
194
+
195
+ const fetchTree = useCallback(async (dirPath: string, options: { quiet?: boolean } = {}) => {
196
+ const name = dirPath.split("/").pop() || dirPath;
197
+
198
+ setDirs((prev) => {
199
+ const exists = prev.find((d) => d.path === dirPath);
200
+ if (exists) {
201
+ return prev.map((d) =>
202
+ d.path === dirPath
203
+ ? { ...d, isLoading: options.quiet ? d.isLoading : true, error: options.quiet ? d.error : null }
204
+ : d
205
+ );
206
+ }
207
+ return [...prev, { path: dirPath, name, tree: [], isLoading: true, error: null, hasLoadedTree: false }];
208
+ });
209
+
210
+ try {
211
+ const res = await fileTreeBackend.loadTree(dirPath);
212
+ const data = await res.json();
213
+
214
+ if (!res.ok || data.error) {
215
+ const error = data.error || "Failed to load";
216
+ const shouldSurfaceError = !options.quiet || isPermanentFileBrowserFetchError(res.status);
217
+ setDirs((prev) =>
218
+ prev.map((d) =>
219
+ d.path === dirPath
220
+ ? shouldSurfaceError
221
+ ? {
222
+ ...d,
223
+ tree: options.quiet ? [] : d.tree,
224
+ workspaceStatus: options.quiet ? undefined : d.workspaceStatus,
225
+ isLoading: false,
226
+ hasLoadedTree: false,
227
+ error,
228
+ }
229
+ : { ...d, isLoading: false, error: d.error }
230
+ : d
231
+ )
232
+ );
233
+ return;
234
+ }
235
+
236
+ const workspaceStatus = remapWorkspaceStatusForDir(data.workspaceStatus, dirPath);
237
+ setDirs((prev) =>
238
+ prev.map((d) =>
239
+ d.path === dirPath
240
+ ? {
241
+ ...d,
242
+ tree: data.tree,
243
+ workspaceStatus,
244
+ isLoading: false,
245
+ hasLoadedTree: true,
246
+ error: null,
247
+ }
248
+ : d
249
+ )
250
+ );
251
+
252
+ if (!options.quiet) {
253
+ const rootFolders = (data.tree as VaultNode[])
254
+ .filter((n) => n.type === "folder")
255
+ .map((n) => `${dirPath}:${n.path}`);
256
+ setExpandedFolders((prev) => {
257
+ const next = new Set(prev);
258
+ rootFolders.forEach((f) => next.add(f));
259
+ return next;
260
+ });
261
+ }
262
+ } catch {
263
+ setDirs((prev) =>
264
+ prev.map((d) =>
265
+ d.path === dirPath
266
+ ? { ...d, isLoading: false, error: options.quiet ? d.error : "Failed to connect to server" }
267
+ : d
268
+ )
269
+ );
270
+ }
271
+ }, []);
272
+
273
+ const fetchTreeRef = useRef(fetchTree);
274
+ useEffect(() => {
275
+ fetchTreeRef.current = fetchTree;
276
+ }, [fetchTree]);
277
+
278
+ const fetchAll = useCallback(
279
+ (directories: string[]) => {
280
+ setDirs((prev) => {
281
+ // Preserve any vault dirs that were already loaded
282
+ const vaultDirs = prev.filter((d) => d.isVault);
283
+ const regularDirs = directories.map((path) => ({
284
+ path,
285
+ name: path.split("/").pop() || path,
286
+ tree: [],
287
+ // Keep initial roots loading until their first snapshot resolves. If
288
+ // the SSE watcher starts before /api/reference/files finishes,
289
+ // chokidar setup on large repo roots can block the server long enough
290
+ // that the visible tree sits on "Loading..." for seconds.
291
+ isLoading: true,
292
+ error: null,
293
+ hasLoadedTree: false,
294
+ }));
295
+ return [...regularDirs, ...vaultDirs];
296
+ });
297
+ directories.forEach((d) => fetchTree(d));
298
+ },
299
+ [fetchTree]
300
+ );
301
+
302
+ const clearVaultDirs = useCallback(() => {
303
+ setDirs((prev) => prev.filter((d) => !d.isVault));
304
+ }, []);
305
+
306
+ const addVaultDir = useCallback(async (vaultPath: string) => {
307
+ const name = vaultPath.split("/").pop() || vaultPath;
308
+
309
+ // Atomically replace any existing vault dirs (handles vault path change without accumulating stale entries)
310
+ setDirs((prev) => {
311
+ const nonVaultDirs = prev.filter((d) => !d.isVault);
312
+ return [...nonVaultDirs, { path: vaultPath, name, tree: [], isLoading: true, error: null, isVault: true }];
313
+ });
314
+
315
+ try {
316
+ const res = await fileTreeBackend.loadVaultTree(vaultPath);
317
+ const data = await res.json();
318
+
319
+ if (!res.ok || data.error) {
320
+ setDirs((prev) =>
321
+ prev.map((d) =>
322
+ d.path === vaultPath ? { ...d, isLoading: false, error: data.error || "Failed to load" } : d
323
+ )
324
+ );
325
+ return;
326
+ }
327
+
328
+ setDirs((prev) =>
329
+ prev.map((d) =>
330
+ d.path === vaultPath ? { ...d, tree: data.tree, isLoading: false, isVault: true } : d
331
+ )
332
+ );
333
+
334
+ const rootFolders = (data.tree as VaultNode[])
335
+ .filter((n) => n.type === "folder")
336
+ .map((n) => `${vaultPath}:${n.path}`);
337
+ setExpandedFolders((prev) => {
338
+ const next = new Set(prev);
339
+ rootFolders.forEach((f) => next.add(f));
340
+ return next;
341
+ });
342
+ } catch {
343
+ setDirs((prev) =>
344
+ prev.map((d) =>
345
+ d.path === vaultPath ? { ...d, isLoading: false, error: "Failed to connect to server" } : d
346
+ )
347
+ );
348
+ }
349
+ }, []);
350
+
351
+ const toggleFolder = useCallback((key: string) => {
352
+ setExpandedFolders((prev) => {
353
+ const next = new Set(prev);
354
+ if (next.has(key)) {
355
+ next.delete(key);
356
+ } else {
357
+ next.add(key);
358
+ }
359
+ return next;
360
+ });
361
+ }, []);
362
+
363
+ const watchDirsKey = useMemo(
364
+ () => {
365
+ const regularDirs = dirs.filter((dir) => !dir.isVault);
366
+ const initialLoadPending = regularDirs.some((dir) => dir.isLoading && !dir.hasLoadedTree);
367
+ if (initialLoadPending) return "";
368
+
369
+ return regularDirs
370
+ // Subscribe only after the initial snapshot is visible. Live updates are
371
+ // for future freshness; they must not compete with first paint.
372
+ .filter((dir) => !dir.error && dir.hasLoadedTree)
373
+ .map((dir) => dir.path)
374
+ .sort()
375
+ .join("\n");
376
+ },
377
+ [dirs]
378
+ );
379
+
380
+ useEffect(() => {
381
+ if (!watchDirsKey) return;
382
+ const paths = watchDirsKey.split("\n").filter(Boolean);
383
+ return fileTreeBackend.watchTrees(paths, (path) => {
384
+ fetchTreeRef.current(path, { quiet: true });
385
+ });
386
+ }, [watchDirsKey]);
387
+
388
+ return {
389
+ dirs,
390
+ expandedFolders,
391
+ toggleFolder,
392
+ collapsedDirs,
393
+ toggleCollapse,
394
+ fetchTree,
395
+ fetchAll,
396
+ addVaultDir,
397
+ clearVaultDirs,
398
+ activeFile,
399
+ activeDirPath: activeFile ? (dirs.find((d) => activeFile.startsWith(d.path + "/"))?.path ?? null) : null,
400
+ setActiveFile,
401
+ };
402
+ }
@@ -0,0 +1,89 @@
1
+ import { useEffect, useRef, useCallback } from 'react';
2
+ import type { InputMethod } from '../types';
3
+
4
+ const DOUBLE_TAP_WINDOW = 300; // ms — max gap between taps for double-tap
5
+ const HOLD_THRESHOLD = 300; // ms — hold longer than this = temporary switch
6
+
7
+ /**
8
+ * Alt/Option key to switch between input methods:
9
+ * - Hold Alt: temporarily switch, reverts on release
10
+ * - Double-tap Alt: permanently toggle
11
+ */
12
+ export function useInputMethodSwitch(
13
+ inputMethod: InputMethod,
14
+ onChange: (method: InputMethod) => void,
15
+ ) {
16
+ const stateRef = useRef<'idle' | 'held' | 'tapped'>('idle');
17
+ const downTimestampRef = useRef(0);
18
+ const revertTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
19
+ const originalMethodRef = useRef<InputMethod>(inputMethod);
20
+
21
+ // Keep originalMethod in sync when user changes it via UI
22
+ useEffect(() => {
23
+ if (stateRef.current === 'idle') {
24
+ originalMethodRef.current = inputMethod;
25
+ }
26
+ }, [inputMethod]);
27
+
28
+ const otherMethod = useCallback(
29
+ (m: InputMethod): InputMethod => (m === 'drag' ? 'pinpoint' : 'drag'),
30
+ [],
31
+ );
32
+
33
+ useEffect(() => {
34
+ const handleKeyDown = (e: KeyboardEvent) => {
35
+ if (e.key !== 'Alt' || e.repeat) return;
36
+ // Don't interfere when user is typing
37
+ const tag = (e.target as HTMLElement)?.tagName;
38
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement)?.isContentEditable) return;
39
+ // Don't interfere when a quick label picker is open (it uses Alt+N shortcuts)
40
+ if (document.querySelector('[data-quick-label-picker]')) return;
41
+
42
+ if (stateRef.current === 'idle') {
43
+ // First press — switch immediately
44
+ originalMethodRef.current = inputMethod;
45
+ downTimestampRef.current = Date.now();
46
+ stateRef.current = 'held';
47
+ onChange(otherMethod(inputMethod));
48
+ } else if (stateRef.current === 'tapped') {
49
+ // Second press within window — make it permanent
50
+ clearTimeout(revertTimerRef.current);
51
+ stateRef.current = 'idle';
52
+ // Already switched from first tap, just persist it
53
+ originalMethodRef.current = otherMethod(originalMethodRef.current);
54
+ }
55
+ };
56
+
57
+ const handleKeyUp = (e: KeyboardEvent) => {
58
+ if (e.key !== 'Alt') return;
59
+
60
+ if (stateRef.current === 'held') {
61
+ const holdDuration = Date.now() - downTimestampRef.current;
62
+
63
+ if (holdDuration >= HOLD_THRESHOLD) {
64
+ // Long hold — revert immediately
65
+ onChange(originalMethodRef.current);
66
+ stateRef.current = 'idle';
67
+ } else {
68
+ // Short tap — wait for possible second tap
69
+ stateRef.current = 'tapped';
70
+ revertTimerRef.current = setTimeout(() => {
71
+ if (stateRef.current === 'tapped') {
72
+ onChange(originalMethodRef.current);
73
+ stateRef.current = 'idle';
74
+ }
75
+ }, DOUBLE_TAP_WINDOW);
76
+ }
77
+ }
78
+ };
79
+
80
+ window.addEventListener('keydown', handleKeyDown);
81
+ window.addEventListener('keyup', handleKeyUp);
82
+
83
+ return () => {
84
+ clearTimeout(revertTimerRef.current);
85
+ window.removeEventListener('keydown', handleKeyDown);
86
+ window.removeEventListener('keyup', handleKeyUp);
87
+ };
88
+ }, [inputMethod, onChange, otherMethod]);
89
+ }
@@ -0,0 +1,17 @@
1
+ import { useState, useEffect } from 'react';
2
+
3
+ export function useIsMobile(breakpoint = 768): boolean {
4
+ const [isMobile, setIsMobile] = useState(
5
+ () => typeof window !== 'undefined' ? window.innerWidth < breakpoint : false
6
+ );
7
+
8
+ useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`);
10
+ const onChange = (e: MediaQueryListEvent) => setIsMobile(e.matches);
11
+ mql.addEventListener('change', onChange);
12
+ setIsMobile(mql.matches);
13
+ return () => mql.removeEventListener('change', onChange);
14
+ }, [breakpoint]);
15
+
16
+ return isMobile;
17
+ }