md-redline 0.1.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 (207) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +169 -0
  3. package/bin/md-redline +255 -0
  4. package/bin/test-windows.ps1 +70 -0
  5. package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
  6. package/dist/assets/arc-DI2g9LXK.js +1 -0
  7. package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
  8. package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
  9. package/dist/assets/array-DOVTz2Mq.js +1 -0
  10. package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
  11. package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
  12. package/dist/assets/channel-DPCihw7y.js +1 -0
  13. package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
  14. package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
  15. package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
  16. package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
  17. package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
  18. package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
  19. package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
  20. package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
  21. package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
  22. package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
  23. package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
  24. package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
  25. package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
  26. package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
  27. package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
  28. package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
  29. package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
  30. package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
  31. package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
  32. package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
  33. package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
  34. package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
  35. package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
  36. package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
  37. package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
  38. package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
  39. package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
  40. package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
  41. package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
  42. package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
  43. package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
  44. package/dist/assets/clone-CI9aUwHe.js +1 -0
  45. package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
  46. package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
  47. package/dist/assets/dagre-CilMRazv.js +1 -0
  48. package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
  49. package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
  50. package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
  51. package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
  52. package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
  53. package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
  54. package/dist/assets/dist-C_eddq6m.js +1 -0
  55. package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
  56. package/dist/assets/flatten-B8XIuT0x.js +1 -0
  57. package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
  58. package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
  59. package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
  60. package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
  61. package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
  62. package/dist/assets/index-BoggyWS0.css +2 -0
  63. package/dist/assets/index-aLvjHQW4.js +104 -0
  64. package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
  65. package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
  66. package/dist/assets/init-Bft5Ffpj.js +1 -0
  67. package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
  68. package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
  69. package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
  70. package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
  71. package/dist/assets/katex-Uj9wLT16.js +265 -0
  72. package/dist/assets/line-CRxEwpOv.js +1 -0
  73. package/dist/assets/linear-PDPfFByd.js +1 -0
  74. package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
  75. package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
  76. package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
  77. package/dist/assets/ordinal-DIg8h6NI.js +1 -0
  78. package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
  79. package/dist/assets/path-DfRbCp9y.js +1 -0
  80. package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
  81. package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
  82. package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
  83. package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
  84. package/dist/assets/reduce-CV2X8n1a.js +1 -0
  85. package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
  86. package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
  87. package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
  88. package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
  89. package/dist/assets/src-PKe5NtkK.js +1 -0
  90. package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
  91. package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
  92. package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
  93. package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
  94. package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
  95. package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
  96. package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
  97. package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
  98. package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
  99. package/dist/favicon.svg +15 -0
  100. package/dist/index.html +14 -0
  101. package/dist/screenshot.png +0 -0
  102. package/index.html +13 -0
  103. package/package.json +105 -0
  104. package/public/favicon.svg +15 -0
  105. package/public/screenshot.png +0 -0
  106. package/server/index.test.ts +814 -0
  107. package/server/index.ts +736 -0
  108. package/server/preferences.test.ts +126 -0
  109. package/server/preferences.ts +76 -0
  110. package/src/App.tsx +1620 -0
  111. package/src/components/ActionButton.tsx +41 -0
  112. package/src/components/CommandPalette.tsx +191 -0
  113. package/src/components/CommentCard.tsx +556 -0
  114. package/src/components/CommentForm.tsx +285 -0
  115. package/src/components/CommentSidebar.tsx +428 -0
  116. package/src/components/ConfirmDialog.tsx +64 -0
  117. package/src/components/ContextMenu.tsx +220 -0
  118. package/src/components/DragHandles.tsx +48 -0
  119. package/src/components/FileExplorer.tsx +251 -0
  120. package/src/components/FileOpener.tsx +304 -0
  121. package/src/components/IconButton.tsx +32 -0
  122. package/src/components/KeyboardShortcutsPanel.tsx +136 -0
  123. package/src/components/MarkdownViewer.tsx +682 -0
  124. package/src/components/RawView.tsx +798 -0
  125. package/src/components/SearchBar.tsx +129 -0
  126. package/src/components/Separator.tsx +7 -0
  127. package/src/components/SettingsPanel.tsx +813 -0
  128. package/src/components/SplitIconButton.tsx +133 -0
  129. package/src/components/TabBar.tsx +594 -0
  130. package/src/components/TableOfContents.tsx +70 -0
  131. package/src/components/ThemeSelector.tsx +159 -0
  132. package/src/components/Toast.tsx +99 -0
  133. package/src/components/Toolbar.tsx +161 -0
  134. package/src/components/iconButtonVariants.ts +19 -0
  135. package/src/components/rawView.test.ts +291 -0
  136. package/src/contexts/SettingsContext.tsx +120 -0
  137. package/src/hooks/useAuthor.test.ts +58 -0
  138. package/src/hooks/useAuthor.ts +69 -0
  139. package/src/hooks/useAutoResize.ts +20 -0
  140. package/src/hooks/useCommentCardTriggers.ts +20 -0
  141. package/src/hooks/useComments.test.ts +773 -0
  142. package/src/hooks/useComments.ts +332 -0
  143. package/src/hooks/useContextMenu.ts +48 -0
  144. package/src/hooks/useContextMenuItems.ts +392 -0
  145. package/src/hooks/useDiffSnapshot.test.ts +130 -0
  146. package/src/hooks/useDiffSnapshot.ts +67 -0
  147. package/src/hooks/useDragHandles.ts +417 -0
  148. package/src/hooks/useFileWatcher.ts +45 -0
  149. package/src/hooks/useHeadingTracking.ts +84 -0
  150. package/src/hooks/useMermaidRenderer.ts +75 -0
  151. package/src/hooks/useModalState.ts +22 -0
  152. package/src/hooks/usePageVisible.test.ts +69 -0
  153. package/src/hooks/usePageVisible.ts +19 -0
  154. package/src/hooks/usePaneLayout.test.ts +108 -0
  155. package/src/hooks/usePaneLayout.ts +102 -0
  156. package/src/hooks/useRecentFiles.test.ts +103 -0
  157. package/src/hooks/useRecentFiles.ts +99 -0
  158. package/src/hooks/useResizablePanel.test.ts +84 -0
  159. package/src/hooks/useResizablePanel.ts +118 -0
  160. package/src/hooks/useSearch.test.ts +72 -0
  161. package/src/hooks/useSearch.ts +53 -0
  162. package/src/hooks/useSelection.ts +48 -0
  163. package/src/hooks/useSessionPersistence.test.ts +59 -0
  164. package/src/hooks/useSessionPersistence.ts +43 -0
  165. package/src/hooks/useTabs.test.ts +127 -0
  166. package/src/hooks/useTabs.ts +561 -0
  167. package/src/hooks/useThemePersistence.ts +41 -0
  168. package/src/hooks/useToast.ts +27 -0
  169. package/src/index.css +1047 -0
  170. package/src/lib/agent-prompts.test.ts +34 -0
  171. package/src/lib/agent-prompts.ts +68 -0
  172. package/src/lib/comment-editor-state.ts +6 -0
  173. package/src/lib/comment-parser.test.ts +1959 -0
  174. package/src/lib/comment-parser.ts +1021 -0
  175. package/src/lib/diff.test.ts +164 -0
  176. package/src/lib/diff.ts +139 -0
  177. package/src/lib/heading-slugs.test.ts +85 -0
  178. package/src/lib/heading-slugs.ts +44 -0
  179. package/src/lib/http.test.ts +43 -0
  180. package/src/lib/http.ts +29 -0
  181. package/src/lib/mermaid-highlights.test.ts +517 -0
  182. package/src/lib/mermaid-highlights.ts +936 -0
  183. package/src/lib/mermaid-renderer.test.ts +114 -0
  184. package/src/lib/mermaid-renderer.ts +89 -0
  185. package/src/lib/path-utils.test.ts +17 -0
  186. package/src/lib/path-utils.ts +7 -0
  187. package/src/lib/platform.test.ts +58 -0
  188. package/src/lib/platform.ts +14 -0
  189. package/src/lib/preferences-client.test.ts +177 -0
  190. package/src/lib/preferences-client.ts +94 -0
  191. package/src/lib/selection-resolver.test.ts +118 -0
  192. package/src/lib/selection-resolver.ts +37 -0
  193. package/src/lib/settings.test.ts +152 -0
  194. package/src/lib/settings.ts +78 -0
  195. package/src/lib/shortcut-label.tsx +18 -0
  196. package/src/lib/themes.ts +21 -0
  197. package/src/lib/visible-text.test.ts +86 -0
  198. package/src/lib/visible-text.ts +77 -0
  199. package/src/main.tsx +22 -0
  200. package/src/markdown/pipeline.test.ts +82 -0
  201. package/src/markdown/pipeline.ts +33 -0
  202. package/src/types.test.ts +43 -0
  203. package/src/types.ts +46 -0
  204. package/tsconfig.app.json +28 -0
  205. package/tsconfig.json +7 -0
  206. package/tsconfig.node.json +26 -0
  207. package/vite.config.ts +50 -0
package/src/App.tsx ADDED
@@ -0,0 +1,1620 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useMemo,
5
+ useCallback,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ type RefObject,
9
+ } from 'react';
10
+ import { useTabs } from './hooks/useTabs';
11
+ import { useSelection } from './hooks/useSelection';
12
+ import { useRecentFiles } from './hooks/useRecentFiles';
13
+ import { useFileWatcher } from './hooks/useFileWatcher';
14
+ import { usePageVisible } from './hooks/usePageVisible';
15
+ import { useResizablePanel } from './hooks/useResizablePanel';
16
+ import { useSessionPersistence, loadSession } from './hooks/useSessionPersistence';
17
+ import { parseComments } from './lib/comment-parser';
18
+ import { getEffectiveStatus } from './types';
19
+ import { MarkdownViewer, type MarkdownViewerHandle } from './components/MarkdownViewer';
20
+ import { TableOfContents } from './components/TableOfContents';
21
+ import { CommentSidebar } from './components/CommentSidebar';
22
+ import { CommentForm } from './components/CommentForm';
23
+ import { Toolbar } from './components/Toolbar';
24
+ import { TabBar } from './components/TabBar';
25
+ import { FileExplorer } from './components/FileExplorer';
26
+ import { FileOpener } from './components/FileOpener';
27
+ import { DragHandles } from './components/DragHandles';
28
+ import { RawView, type RawViewHandle } from './components/RawView';
29
+ import { Toast } from './components/Toast';
30
+
31
+ import { CommandPalette, type Command } from './components/CommandPalette';
32
+ import { ContextMenu } from './components/ContextMenu';
33
+ import { SettingsPanel } from './components/SettingsPanel';
34
+ import { SearchBar } from './components/SearchBar';
35
+ import { ConfirmDialog } from './components/ConfirmDialog';
36
+ import { KeyboardShortcutsPanel } from './components/KeyboardShortcutsPanel';
37
+ import { useDragHandles } from './hooks/useDragHandles';
38
+ import { useAuthor } from './hooks/useAuthor';
39
+ import { useContextMenu } from './hooks/useContextMenu';
40
+ import { useSettings } from './contexts/SettingsContext';
41
+ import { useThemePersistence } from './hooks/useThemePersistence';
42
+ import { migrateLocalStorageToDisk } from './lib/preferences-client';
43
+ import { readJsonResponse } from './lib/http';
44
+ import { ALL_THEMES } from './lib/themes';
45
+ import { usePaneLayout } from './hooks/usePaneLayout';
46
+ import { useToast } from './hooks/useToast';
47
+ import { useModalState } from './hooks/useModalState';
48
+ import { useSearch } from './hooks/useSearch';
49
+ import { useCommentCardTriggers } from './hooks/useCommentCardTriggers';
50
+ import { useDiffSnapshot } from './hooks/useDiffSnapshot';
51
+ import { useComments } from './hooks/useComments';
52
+ import { useHeadingTracking } from './hooks/useHeadingTracking';
53
+ import { useContextMenuItems } from './hooks/useContextMenuItems';
54
+ import type { SidebarCommentFocusRequest } from './components/CommentSidebar';
55
+
56
+ const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/.test(navigator.userAgent);
57
+ const modKey = isMac ? '\u2318' : 'Ctrl';
58
+ const prevTabShortcut = isMac ? '\u2318\u21e7[' : 'Ctrl+Shift+[';
59
+ const nextTabShortcut = isMac ? '\u2318\u21e7]' : 'Ctrl+Shift+]';
60
+
61
+ export default function App() {
62
+ // Load saved session lazily (deferred to first render, not module import time)
63
+ const [savedSession] = useState(() => loadSession());
64
+ const showToastRef = useRef<((msg: string) => void) | null>(null);
65
+ const onSaveError = useCallback(
66
+ (msg: string) => showToastRef.current?.(`Save failed: ${msg}`),
67
+ [],
68
+ );
69
+ const {
70
+ tabs,
71
+ activeFilePath,
72
+ rawMarkdown,
73
+ setRawMarkdown,
74
+ updateTab,
75
+ isLoading,
76
+ error,
77
+ isTabDirty,
78
+ openTab,
79
+ openTabInBackground,
80
+ closeTab: closeTabDirect,
81
+ closeOtherTabs: closeOtherTabsDirect,
82
+ closeAllTabs: closeAllTabsDirect,
83
+ closeTabsToRight: closeTabsToRightDirect,
84
+ switchTab,
85
+ saveFile,
86
+ reloadFile,
87
+ } = useTabs({ onSaveError });
88
+
89
+ // Dirty-tab close guard: when closing tabs that have unsaved changes
90
+ // (e.g. save failed), show a confirmation dialog before discarding.
91
+ const [pendingClose, setPendingClose] = useState<{
92
+ type: 'single' | 'others' | 'all' | 'right';
93
+ path?: string;
94
+ } | null>(null);
95
+
96
+ const executePendingClose = useCallback(() => {
97
+ if (!pendingClose) return;
98
+ switch (pendingClose.type) {
99
+ case 'single':
100
+ if (pendingClose.path) closeTabDirect(pendingClose.path);
101
+ break;
102
+ case 'others':
103
+ if (pendingClose.path) closeOtherTabsDirect(pendingClose.path);
104
+ break;
105
+ case 'all':
106
+ closeAllTabsDirect();
107
+ break;
108
+ case 'right':
109
+ if (pendingClose.path) closeTabsToRightDirect(pendingClose.path);
110
+ break;
111
+ }
112
+ setPendingClose(null);
113
+ }, [pendingClose, closeTabDirect, closeOtherTabsDirect, closeAllTabsDirect, closeTabsToRightDirect]);
114
+
115
+ const closeTab = useCallback(
116
+ (path: string) => {
117
+ if (isTabDirty(path)) {
118
+ setPendingClose({ type: 'single', path });
119
+ return;
120
+ }
121
+ closeTabDirect(path);
122
+ },
123
+ [isTabDirty, closeTabDirect],
124
+ );
125
+
126
+ const closeOtherTabs = useCallback(
127
+ (keepPath: string) => {
128
+ const hasDirty = tabs.some((t) => t.filePath !== keepPath && isTabDirty(t.filePath));
129
+ if (hasDirty) {
130
+ setPendingClose({ type: 'others', path: keepPath });
131
+ return;
132
+ }
133
+ closeOtherTabsDirect(keepPath);
134
+ },
135
+ [tabs, isTabDirty, closeOtherTabsDirect],
136
+ );
137
+
138
+ const closeAllTabs = useCallback(() => {
139
+ const hasDirty = tabs.some((t) => isTabDirty(t.filePath));
140
+ if (hasDirty) {
141
+ setPendingClose({ type: 'all' });
142
+ return;
143
+ }
144
+ closeAllTabsDirect();
145
+ }, [tabs, isTabDirty, closeAllTabsDirect]);
146
+
147
+ const closeTabsToRight = useCallback(
148
+ (path: string) => {
149
+ const idx = tabs.findIndex((t) => t.filePath === path);
150
+ const hasDirty = tabs.slice(idx + 1).some((t) => isTabDirty(t.filePath));
151
+ if (hasDirty) {
152
+ setPendingClose({ type: 'right', path });
153
+ return;
154
+ }
155
+ closeTabsToRightDirect(path);
156
+ },
157
+ [tabs, isTabDirty, closeTabsToRightDirect],
158
+ );
159
+
160
+ const [explorerDir, setExplorerDir] = useState<string | undefined>(undefined);
161
+ const { recentFiles, addRecentFile, clearRecentFiles } = useRecentFiles();
162
+ const { author, setAuthor } = useAuthor();
163
+ const { settings } = useSettings();
164
+ const { theme, setTheme } = useThemePersistence();
165
+ const { explorerWidth, sidebarWidth, onResizeStart, isDragging } = useResizablePanel();
166
+ const pageVisible = usePageVisible();
167
+ const {
168
+ explorerVisible,
169
+ setExplorerVisible,
170
+ sidebarVisible,
171
+ setSidebarVisible,
172
+ leftPanelView,
173
+ setLeftPanelView,
174
+ viewMode,
175
+ setViewMode,
176
+ diffEnabled,
177
+ setDiffEnabled,
178
+ } = usePaneLayout();
179
+
180
+ // One-time migration of localStorage preferences to disk
181
+ useEffect(() => {
182
+ migrateLocalStorageToDisk();
183
+ }, []);
184
+
185
+ // Session persistence (tabs only — pane layout is persisted by usePaneLayout)
186
+ const { persist } = useSessionPersistence();
187
+ useEffect(() => {
188
+ persist({
189
+ openTabs: tabs.map((t) => t.filePath),
190
+ activeFilePath,
191
+ });
192
+ }, [tabs, activeFilePath, persist]);
193
+
194
+ // Restore session tabs on first mount
195
+ const sessionRestoredRef = useRef(false);
196
+ useEffect(() => {
197
+ if (sessionRestoredRef.current) return;
198
+ sessionRestoredRef.current = true;
199
+ const params = new URLSearchParams(window.location.search);
200
+ if (params.get('file') || params.get('dir')) return;
201
+ if (savedSession && savedSession.openTabs.length > 0) {
202
+ // Open inactive tabs in background first, then the active tab last
203
+ // (openTab sets it active, avoiding the setTimeout race)
204
+ const activeTarget =
205
+ savedSession.activeFilePath && savedSession.openTabs.includes(savedSession.activeFilePath)
206
+ ? savedSession.activeFilePath
207
+ : savedSession.openTabs[0];
208
+ for (const path of savedSession.openTabs) {
209
+ if (path === activeTarget) continue;
210
+ openTabInBackground(path);
211
+ }
212
+ openTab(activeTarget);
213
+ }
214
+ }, [openTab, openTabInBackground, savedSession]);
215
+
216
+ // Toast notification state
217
+ const { toast, showToast, dismissToast } = useToast();
218
+ showToastRef.current = showToast;
219
+
220
+ // Accumulate external-change counts so rapid SSE events coalesce into one
221
+ // updating toast ("3 comments addressed") instead of flickering "1 comment" each time.
222
+ const accResolvedRef = useRef(0);
223
+ const accDeletedRef = useRef(0);
224
+ const accRepliesRef = useRef(0);
225
+
226
+ useEffect(() => {
227
+ if (!toast.visible) {
228
+ accResolvedRef.current = 0;
229
+ accDeletedRef.current = 0;
230
+ accRepliesRef.current = 0;
231
+ }
232
+ }, [toast.visible]);
233
+
234
+ // Auto-expand comment form state (Feature 3)
235
+ const [autoExpandForm, setAutoExpandForm] = useState(false);
236
+ const [requestedCommentFocus, setRequestedCommentFocus] =
237
+ useState<SidebarCommentFocusRequest | null>(null);
238
+
239
+ // Modal state — only one modal can be open at a time
240
+ const { activeModal, setActiveModal, toggleModal, openFilePicker } = useModalState();
241
+
242
+ const switchTabByOffset = useCallback(
243
+ (offset: number) => {
244
+ if (tabs.length === 0) return;
245
+
246
+ const activeIndex = activeFilePath
247
+ ? tabs.findIndex((tab) => tab.filePath === activeFilePath)
248
+ : -1;
249
+
250
+ const fallbackIndex = offset >= 0 ? 0 : tabs.length - 1;
251
+ const nextIndex =
252
+ activeIndex === -1 ? fallbackIndex : (activeIndex + offset + tabs.length) % tabs.length;
253
+
254
+ switchTab(tabs[nextIndex].filePath);
255
+ },
256
+ [tabs, activeFilePath, switchTab],
257
+ );
258
+
259
+ // Text search state
260
+ const showSearch = activeModal === 'search';
261
+ const {
262
+ searchQuery,
263
+ activeSearchIndex,
264
+ searchMatchCount,
265
+ searchFocusTrigger,
266
+ setSearchFocusTrigger,
267
+ handleSearchCount,
268
+ handleSearchNext,
269
+ handleSearchPrev,
270
+ handleSearchClose,
271
+ handleSearchQueryChange,
272
+ } = useSearch(() => setActiveModal(null));
273
+
274
+ // Platform info for context menu labels
275
+ const [revealLabel, setRevealLabel] = useState('Reveal in File Manager');
276
+ useEffect(() => {
277
+ const controller = new AbortController();
278
+ fetch('/api/platform', { signal: controller.signal })
279
+ .then((r) => {
280
+ if (!r.ok) throw new Error();
281
+ return readJsonResponse<{ platform?: string }>(r);
282
+ })
283
+ .then((data) => {
284
+ const platform = data?.platform;
285
+ if (platform === 'darwin') setRevealLabel('Reveal in Finder');
286
+ else if (platform === 'win32') setRevealLabel('Show in Explorer');
287
+ else setRevealLabel('Show in File Manager');
288
+ })
289
+ .catch(() => {});
290
+ return () => controller.abort();
291
+ }, []);
292
+
293
+ // Context menu state
294
+ const viewerCtxMenu = useContextMenu();
295
+ const explorerCtxMenu = useContextMenu();
296
+ const tabCtxMenu = useContextMenu();
297
+ const sidebarCtxMenu = useContextMenu();
298
+
299
+ // Triggers for remotely entering edit/reply mode on a CommentCard
300
+ const { requestedEditor, triggerEdit, triggerReply } = useCommentCardTriggers();
301
+
302
+ const viewerRef = useRef<MarkdownViewerHandle>(null);
303
+ const rawViewRef = useRef<RawViewHandle>(null);
304
+ const containerRef = useRef<HTMLDivElement>(null);
305
+
306
+ // Ref to avoid rawMarkdown in callback dependencies (stabilizes function identities).
307
+ const rawMarkdownRef = useRef(rawMarkdown);
308
+ useLayoutEffect(() => {
309
+ rawMarkdownRef.current = rawMarkdown;
310
+ }, [rawMarkdown]);
311
+
312
+ // Diff snapshot state
313
+ const { currentSnapshot, handleSnapshot, handleClearSnapshot } = useDiffSnapshot(
314
+ activeFilePath,
315
+ rawMarkdownRef,
316
+ showToast,
317
+ setDiffEnabled,
318
+ );
319
+
320
+ // Ref to access snapshot state inside callbacks without adding dependencies.
321
+ const currentSnapshotRef = useRef(currentSnapshot);
322
+ useLayoutEffect(() => {
323
+ currentSnapshotRef.current = currentSnapshot;
324
+ }, [currentSnapshot]);
325
+
326
+ // Track whether the diff has unseen external changes (badge indicator on diff button)
327
+ const [diffPending, setDiffPending] = useState(false);
328
+
329
+ const { selection, clearSelection, lockSelection } = useSelection(
330
+ containerRef as RefObject<HTMLElement | null>,
331
+ );
332
+ const requestCommentFocus = useCallback(
333
+ (commentId: string) => setRequestedCommentFocus({ commentId, token: Date.now() }),
334
+ [],
335
+ );
336
+
337
+ // Comment state and operations
338
+ const {
339
+ activeCommentId,
340
+ setActiveCommentId,
341
+ comments,
342
+ cleanMarkdown,
343
+ html,
344
+ missingAnchors,
345
+ commentCounts,
346
+ resolvedCommentCounts,
347
+ commentCount,
348
+ handleAddComment,
349
+ handleResolve,
350
+ handleUnresolve,
351
+ handleDelete,
352
+ handleEdit,
353
+ handleReply,
354
+ handleEditReply,
355
+ handleDeleteReply,
356
+ handleBulkDelete,
357
+ handleBulkResolve,
358
+ handleBulkDeleteResolved,
359
+ handleCopyAgentPrompt,
360
+ handleHighlightClick,
361
+ handleSidebarActivate,
362
+ handleAnchorChange,
363
+ handleJumpToNext,
364
+ handleJumpToPrev,
365
+ } = useComments({
366
+ rawMarkdown,
367
+ rawMarkdownRef,
368
+ setRawMarkdown,
369
+ saveFile,
370
+ author,
371
+ enableResolve: settings.enableResolve,
372
+ tabs,
373
+ activeFilePath,
374
+ viewerRef,
375
+ rawViewRef,
376
+ showToast,
377
+ clearSelection,
378
+ setAutoExpandForm,
379
+ requestCommentFocus,
380
+ });
381
+
382
+ // Combined handoff: snapshot + copy agent prompt
383
+ const handleHandoff = useCallback(
384
+ (filePaths: string[]) => {
385
+ // Build snapshot entries for background files so every handed-off file
386
+ // gets a diff baseline, not just the active tab.
387
+ const extra = new Map<string, string>();
388
+ for (const p of filePaths) {
389
+ if (p === activeFilePath) continue;
390
+ const tab = tabs.find((t) => t.filePath === p);
391
+ if (tab) extra.set(p, tab.rawMarkdown);
392
+ }
393
+ handleSnapshot(extra.size > 0 ? extra : undefined);
394
+ handleCopyAgentPrompt(filePaths);
395
+ },
396
+ [handleSnapshot, handleCopyAgentPrompt, activeFilePath, tabs],
397
+ );
398
+
399
+ const handleDiffToggle = useCallback(() => {
400
+ if (!diffEnabled) {
401
+ if (viewMode !== 'raw') setViewMode('raw');
402
+ setDiffEnabled(true);
403
+ setDiffPending(false);
404
+ } else {
405
+ setDiffEnabled(false);
406
+ }
407
+ }, [diffEnabled, viewMode, setViewMode, setDiffEnabled]);
408
+
409
+ // Heading tracking / table of contents
410
+ const { tocHeadings, activeHeadingId, setActiveHeadingId, spyDisabledRef, scrollSpyRafRef } =
411
+ useHeadingTracking(containerRef, viewerRef, html);
412
+
413
+ const handleHeadingNavigate = useCallback(
414
+ (id: string) => {
415
+ cancelAnimationFrame(scrollSpyRafRef.current);
416
+ spyDisabledRef.current = true;
417
+ setActiveHeadingId(id);
418
+
419
+ if (viewMode === 'raw') {
420
+ rawViewRef.current?.scrollToHeading(id);
421
+ return;
422
+ }
423
+
424
+ const el = containerRef.current?.querySelector(`#${CSS.escape(id)}`);
425
+ el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
426
+ },
427
+ [setActiveHeadingId, viewMode, scrollSpyRafRef, spyDisabledRef],
428
+ );
429
+
430
+ // Clear transient state on tab switch
431
+ const prevFilePathRef = useRef(activeFilePath);
432
+ useEffect(() => {
433
+ if (prevFilePathRef.current !== activeFilePath) {
434
+ prevFilePathRef.current = activeFilePath;
435
+ setActiveCommentId(null);
436
+ setDiffEnabled(false);
437
+ setDiffPending(false);
438
+ clearSelection();
439
+ }
440
+ }, [activeFilePath, setDiffEnabled, clearSelection, setActiveCommentId]);
441
+
442
+ // File watcher — live reload from server SSE (Feature 8: detect status transitions)
443
+ const onExternalChange = useCallback(
444
+ (content: string, mtime?: number) => {
445
+ // Detect comment changes before updating
446
+ let cleanContentChanged = false;
447
+ try {
448
+ const { comments: oldComments, cleanMarkdown: oldClean } = parseComments(
449
+ rawMarkdownRef.current,
450
+ );
451
+ const { comments: newComments, cleanMarkdown: newClean } = parseComments(content);
452
+ cleanContentChanged = oldClean !== newClean;
453
+ const newById = new Map(newComments.map((c) => [c.id, c]));
454
+
455
+ let deletedCount = 0;
456
+ let resolvedCount = 0;
457
+ let newReplyCount = 0;
458
+ for (const oldC of oldComments) {
459
+ const newC = newById.get(oldC.id);
460
+ if (!newC) {
461
+ deletedCount++;
462
+ continue;
463
+ }
464
+ if (settings.enableResolve) {
465
+ const oldStatus = getEffectiveStatus(oldC);
466
+ const newStatus = getEffectiveStatus(newC);
467
+ if (oldStatus === 'open' && newStatus === 'resolved') {
468
+ resolvedCount++;
469
+ }
470
+ }
471
+ const oldReplies = oldC.replies?.length ?? 0;
472
+ const newReplies = newC.replies?.length ?? 0;
473
+ if (newReplies > oldReplies) {
474
+ newReplyCount += newReplies - oldReplies;
475
+ }
476
+ }
477
+
478
+ // Accumulate across rapid events so the toast coalesces
479
+ accResolvedRef.current += resolvedCount;
480
+ accDeletedRef.current += deletedCount;
481
+ accRepliesRef.current += newReplyCount;
482
+
483
+ const r = accResolvedRef.current;
484
+ const d = accDeletedRef.current;
485
+ const rp = accRepliesRef.current;
486
+ if (r > 0 || d > 0 || rp > 0) {
487
+ const parts: string[] = [];
488
+ if (r > 0) parts.push(`${r} resolved`);
489
+ if (d > 0) parts.push(`${d} addressed`);
490
+ if (rp > 0) parts.push(`${rp} ${rp > 1 ? 'replies' : 'reply'} added`);
491
+ const diffAction =
492
+ cleanContentChanged && currentSnapshotRef.current
493
+ ? {
494
+ label: 'View diff',
495
+ onClick: () => {
496
+ setViewMode('raw');
497
+ setDiffEnabled(true);
498
+ setDiffPending(false);
499
+ },
500
+ }
501
+ : undefined;
502
+ showToast(`${parts.join(', ')} externally`, diffAction);
503
+ }
504
+ } catch {
505
+ // Ignore parse errors — still update the content
506
+ }
507
+
508
+ // Update content directly via updateTab (NOT setRawMarkdown which marks
509
+ // dirty:true). External changes already match disk, so dirty must be false.
510
+ // Also synchronously update rawMarkdownRef so back-to-back user edits
511
+ // (e.g. add-comment right after SSE) read the latest content, not stale state.
512
+ rawMarkdownRef.current = content;
513
+ if (activeFilePath) {
514
+ updateTab(activeFilePath, {
515
+ rawMarkdown: content,
516
+ ...(mtime != null ? { mtime } : {}),
517
+ dirty: false,
518
+ });
519
+ }
520
+
521
+ // Flag the diff button when content changed and a snapshot exists
522
+ if (cleanContentChanged && currentSnapshotRef.current) {
523
+ setDiffPending(true);
524
+ }
525
+ },
526
+ [
527
+ activeFilePath,
528
+ setDiffEnabled,
529
+ setViewMode,
530
+ settings.enableResolve,
531
+ showToast,
532
+ updateTab,
533
+ ],
534
+ );
535
+
536
+ // Keep a stable ref so the visibility-restore effect can call it without
537
+ // adding it to its dependency array (which would cause reconnect churn).
538
+ const onExternalChangeRef = useRef(onExternalChange);
539
+ onExternalChangeRef.current = onExternalChange;
540
+
541
+ useFileWatcher({ filePath: activeFilePath, onExternalChange });
542
+
543
+ // Watch background tabs for external changes so they stay fresh during handoff.
544
+ // The active tab is handled by useFileWatcher above; this covers the rest.
545
+ // Keyed by path list so connections only churn when tabs open/close/switch.
546
+ const backgroundPathsKey = tabs
547
+ .map((t) => t.filePath)
548
+ .filter((p) => p && p !== activeFilePath)
549
+ .join('\0');
550
+
551
+ useEffect(() => {
552
+ if (!pageVisible) return;
553
+ const paths = backgroundPathsKey ? backgroundPathsKey.split('\0') : [];
554
+ if (paths.length === 0) return;
555
+
556
+ // Single multiplexed SSE connection for all background tabs to avoid
557
+ // exhausting the browser's per-origin HTTP/1.1 connection limit (6).
558
+ // Also closed when the browser tab is hidden so multiple browser tabs
559
+ // to the same server don't exhaust the limit.
560
+ const params = paths.map((p) => `path=${encodeURIComponent(p)}`).join('&');
561
+ const es = new EventSource(`/api/watch?${params}`);
562
+ es.addEventListener('change', (e) => {
563
+ try {
564
+ const { content, path, mtime } = JSON.parse(e.data);
565
+ updateTab(path, { rawMarkdown: content, ...(mtime != null ? { mtime } : {}) });
566
+ } catch {
567
+ // ignore malformed events
568
+ }
569
+ });
570
+
571
+ return () => es.close();
572
+ }, [backgroundPathsKey, pageVisible, updateTab]);
573
+
574
+ // When the browser tab becomes visible again, fetch the active file and
575
+ // route through onExternalChange so the user gets toast/blue-dot notifications
576
+ // for changes that happened while SSE was disconnected.
577
+ const wasHiddenRef = useRef(false);
578
+ useEffect(() => {
579
+ if (!pageVisible) {
580
+ wasHiddenRef.current = true;
581
+ return;
582
+ }
583
+ if (wasHiddenRef.current && activeFilePath) {
584
+ wasHiddenRef.current = false;
585
+ const controller = new AbortController();
586
+ fetch(`/api/file?path=${encodeURIComponent(activeFilePath)}`, {
587
+ signal: controller.signal,
588
+ })
589
+ .then((res) => (res.ok ? res.json() : null))
590
+ .then((data: { content?: string; mtime?: number } | null) => {
591
+ if (!data?.content) return;
592
+ if (data.content !== rawMarkdownRef.current) {
593
+ onExternalChangeRef.current(data.content, data.mtime);
594
+ }
595
+ })
596
+ .catch((err) => {
597
+ if (err instanceof DOMException && err.name === 'AbortError') return;
598
+ // Network error — fall back to silent reload
599
+ reloadFile();
600
+ });
601
+ return () => controller.abort();
602
+ }
603
+ }, [pageVisible, activeFilePath, reloadFile]);
604
+
605
+ // Load initial file/dir from URL params, CLI arg, or restored session
606
+ useEffect(() => {
607
+ const params = new URLSearchParams(window.location.search);
608
+ const urlFile = params.get('file');
609
+ const urlDir = params.get('dir');
610
+
611
+ if (urlFile) {
612
+ openTab(urlFile);
613
+ addRecentFile(urlFile);
614
+ window.history.replaceState({}, '', window.location.pathname);
615
+ return;
616
+ }
617
+ if (urlDir) {
618
+ setExplorerDir(urlDir);
619
+ setExplorerVisible(true);
620
+ window.history.replaceState({}, '', window.location.pathname);
621
+ return;
622
+ }
623
+ if (savedSession && savedSession.openTabs.length > 0) return;
624
+ fetch('/api/config')
625
+ .then((r) => {
626
+ if (!r.ok) throw new Error();
627
+ return readJsonResponse<{ initialFile?: string; initialDir?: string }>(r);
628
+ })
629
+ .then((data) => {
630
+ if (!data) return;
631
+ if (data.initialFile) {
632
+ openTab(data.initialFile);
633
+ }
634
+ if (data.initialDir) {
635
+ setExplorerDir(data.initialDir);
636
+ setExplorerVisible(true);
637
+ }
638
+ })
639
+ .catch(() => {});
640
+ }, [openTab, addRecentFile, setExplorerVisible, savedSession]);
641
+
642
+ const handleOpenFile = useCallback(
643
+ (path: string) => {
644
+ if (path.trim()) {
645
+ openTab(path.trim());
646
+ addRecentFile(path.trim());
647
+ }
648
+ },
649
+ [openTab, addRecentFile],
650
+ );
651
+
652
+ const revealInFinder = useCallback(
653
+ (path: string) => {
654
+ fetch('/api/reveal', {
655
+ method: 'POST',
656
+ headers: { 'Content-Type': 'application/json' },
657
+ body: JSON.stringify({ path }),
658
+ })
659
+ .then((r) => {
660
+ if (!r.ok) showToast('Could not reveal file');
661
+ })
662
+ .catch(() => {
663
+ showToast('Could not reveal file');
664
+ });
665
+ },
666
+ [showToast],
667
+ );
668
+
669
+ const handleExplorerOpenFile = useCallback(
670
+ (path: string) => {
671
+ openTab(path.trim());
672
+ addRecentFile(path.trim());
673
+ },
674
+ [openTab, addRecentFile],
675
+ );
676
+
677
+ const { handlePositions, onHandleMouseDown } = useDragHandles({
678
+ viewerRef,
679
+ scrollContainerRef: containerRef,
680
+ activeCommentId,
681
+ comments,
682
+ onAnchorChange: handleAnchorChange,
683
+ });
684
+
685
+ // Stable ref for selection to use in keyboard handler without re-creating it
686
+ const selectionRef = useRef(selection);
687
+ selectionRef.current = selection;
688
+
689
+ // Context menu handlers
690
+ const {
691
+ ctxMenuItems,
692
+ explorerCtxMenuItems,
693
+ tabCtxMenuItems,
694
+ sidebarCtxMenuItems,
695
+ handleViewerContextMenu,
696
+ handleExplorerContextMenu,
697
+ handleTabContextMenu,
698
+ handleSidebarContextMenu,
699
+ } = useContextMenuItems({
700
+ comments,
701
+ enableResolve: settings.enableResolve,
702
+ templates: settings.templates,
703
+ handleResolve,
704
+ handleUnresolve,
705
+ handleDelete,
706
+ handleAddComment,
707
+ setActiveCommentId,
708
+ setSidebarVisible,
709
+ selectionRef,
710
+ lockSelection,
711
+ setAutoExpandForm,
712
+ triggerEdit,
713
+ triggerReply,
714
+ viewerRef,
715
+ handleExplorerOpenFile,
716
+ openTabInBackground,
717
+ addRecentFile,
718
+ revealInFinder,
719
+ revealLabel,
720
+ setExplorerDir,
721
+ setExplorerVisible,
722
+ tabs,
723
+ closeTab,
724
+ closeOtherTabs,
725
+ closeAllTabs,
726
+ closeTabsToRight,
727
+ viewerCtxMenu,
728
+ explorerCtxMenu,
729
+ tabCtxMenu,
730
+ sidebarCtxMenu,
731
+ });
732
+
733
+ // --- Keyboard shortcuts ---
734
+ useEffect(() => {
735
+ const handler = (e: KeyboardEvent) => {
736
+ const mod = e.metaKey || e.ctrlKey;
737
+ const target = e.target as HTMLElement;
738
+ const isInput =
739
+ target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
740
+
741
+ // Cmd+K : Open command palette (works even in inputs)
742
+ if (mod && e.key === 'k') {
743
+ e.preventDefault();
744
+ toggleModal('commandPalette');
745
+ return;
746
+ }
747
+
748
+ // Cmd+, : Open settings (works even in inputs)
749
+ if (mod && e.key === ',') {
750
+ e.preventDefault();
751
+ toggleModal('settings');
752
+ return;
753
+ }
754
+
755
+ // Cmd+Shift+O : Toggle outline (must come before Cmd+O)
756
+ if (mod && e.shiftKey && e.key.toLowerCase() === 'o') {
757
+ e.preventDefault();
758
+ if (explorerVisible && leftPanelView === 'outline') {
759
+ setExplorerVisible(false);
760
+ } else {
761
+ setExplorerVisible(true);
762
+ setLeftPanelView('outline');
763
+ }
764
+ return;
765
+ }
766
+
767
+ // Cmd+O : Open file
768
+ if (mod && e.key === 'o') {
769
+ e.preventDefault();
770
+ openFilePicker();
771
+ return;
772
+ }
773
+
774
+ // Cmd+B : Toggle file explorer
775
+ if (mod && e.key === 'b') {
776
+ e.preventDefault();
777
+ if (explorerVisible && leftPanelView === 'explorer') {
778
+ setExplorerVisible(false);
779
+ } else {
780
+ setExplorerVisible(true);
781
+ setLeftPanelView('explorer');
782
+ }
783
+ return;
784
+ }
785
+
786
+ // Cmd+F : Find in document
787
+ if (mod && e.key === 'f') {
788
+ e.preventDefault();
789
+ setActiveModal('search');
790
+ setSearchFocusTrigger((t) => t + 1);
791
+ return;
792
+ }
793
+
794
+ // Cmd+\ : Toggle sidebar
795
+ if (mod && e.key === '\\') {
796
+ e.preventDefault();
797
+ setSidebarVisible((prev) => !prev);
798
+ return;
799
+ }
800
+
801
+ // Cmd+Enter : Expand comment form when text is selected (Feature 3)
802
+ if (mod && e.key === 'Enter' && !isInput && selectionRef.current && viewMode === 'rendered') {
803
+ e.preventDefault();
804
+ lockSelection();
805
+ setAutoExpandForm(true);
806
+ return;
807
+ }
808
+
809
+ // Cmd+Shift+M : Start commenting on selection
810
+ if (mod && e.shiftKey && e.key.toLowerCase() === 'm') {
811
+ e.preventDefault();
812
+ if (selectionRef.current) {
813
+ lockSelection();
814
+ }
815
+ return;
816
+ }
817
+
818
+ // Cmd+Shift+[ / ] : Switch tabs (matches VS Code / Safari)
819
+ if (mod && e.shiftKey && !isInput && activeModal !== 'commandPalette') {
820
+ if (e.code === 'BracketLeft') {
821
+ e.preventDefault();
822
+ switchTabByOffset(-1);
823
+ return;
824
+ }
825
+
826
+ if (e.code === 'BracketRight') {
827
+ e.preventDefault();
828
+ switchTabByOffset(1);
829
+ return;
830
+ }
831
+ }
832
+
833
+ // Keys below only work outside inputs and when command palette is closed
834
+ if (isInput || activeModal === 'commandPalette') return;
835
+
836
+ // ? : Toggle keyboard shortcuts help
837
+ if (e.key === '?') {
838
+ e.preventDefault();
839
+ toggleModal('shortcuts');
840
+ return;
841
+ }
842
+
843
+ if (mod || e.shiftKey || e.altKey) return;
844
+
845
+ const key = e.key.toLowerCase();
846
+
847
+ // Escape : Close search bar
848
+ if (key === 'escape' && showSearch) {
849
+ handleSearchClose();
850
+ return;
851
+ }
852
+
853
+ // N / P : Jump to next / previous comment
854
+ if (key === 'n') {
855
+ e.preventDefault();
856
+ handleJumpToNext();
857
+ return;
858
+ }
859
+ if (key === 'p') {
860
+ e.preventDefault();
861
+ handleJumpToPrev();
862
+ return;
863
+ }
864
+
865
+ // j / k : Navigate comments in sidebar (vim-style)
866
+ if (key === 'j') {
867
+ e.preventDefault();
868
+ handleJumpToNext();
869
+ return;
870
+ }
871
+ if (key === 'k') {
872
+ e.preventDefault();
873
+ handleJumpToPrev();
874
+ return;
875
+ }
876
+
877
+ // A/X : Resolve active comment (only when resolve enabled)
878
+ if ((key === 'a' || key === 'x') && activeCommentId && settings.enableResolve) {
879
+ const comment = comments.find((c) => c.id === activeCommentId);
880
+ if (comment && getEffectiveStatus(comment) === 'open') {
881
+ e.preventDefault();
882
+ handleResolve(activeCommentId);
883
+ }
884
+ return;
885
+ }
886
+
887
+ // U : Unresolve/reopen active comment (only when resolve enabled)
888
+ if (key === 'u' && activeCommentId && settings.enableResolve) {
889
+ const comment = comments.find((c) => c.id === activeCommentId);
890
+ if (comment && getEffectiveStatus(comment) === 'resolved') {
891
+ e.preventDefault();
892
+ handleUnresolve(activeCommentId);
893
+ }
894
+ return;
895
+ }
896
+
897
+ // D : Delete active comment
898
+ if (key === 'd' && activeCommentId) {
899
+ e.preventDefault();
900
+ handleDelete(activeCommentId);
901
+ return;
902
+ }
903
+ };
904
+
905
+ document.addEventListener('keydown', handler);
906
+ return () => document.removeEventListener('keydown', handler);
907
+ }, [
908
+ lockSelection,
909
+ handleJumpToNext,
910
+ handleJumpToPrev,
911
+ handleAddComment,
912
+ handleDelete,
913
+ handleResolve,
914
+ handleUnresolve,
915
+ viewMode,
916
+ activeCommentId,
917
+ comments,
918
+ settings.enableResolve,
919
+ activeModal,
920
+ toggleModal,
921
+ handleSearchClose,
922
+ explorerVisible,
923
+ leftPanelView,
924
+ openFilePicker,
925
+ setExplorerVisible,
926
+ setLeftPanelView,
927
+ setSidebarVisible,
928
+ switchTabByOffset,
929
+ showSearch,
930
+ setActiveModal,
931
+ setSearchFocusTrigger,
932
+ ]);
933
+
934
+ // Command palette commands — split into categories for manageable dependency arrays
935
+ const navigationCommands = useMemo(
936
+ (): Command[] => [
937
+ {
938
+ id: 'next-comment',
939
+ label: 'Jump to next comment',
940
+ shortcut: 'N / J',
941
+ section: 'Navigation',
942
+ onExecute: handleJumpToNext,
943
+ },
944
+ {
945
+ id: 'prev-comment',
946
+ label: 'Jump to previous comment',
947
+ shortcut: 'P / K',
948
+ section: 'Navigation',
949
+ onExecute: handleJumpToPrev,
950
+ },
951
+ {
952
+ id: 'prev-tab',
953
+ label: 'Previous tab',
954
+ shortcut: prevTabShortcut,
955
+ section: 'Tabs',
956
+ onExecute: () => switchTabByOffset(-1),
957
+ },
958
+ {
959
+ id: 'next-tab',
960
+ label: 'Next tab',
961
+ shortcut: nextTabShortcut,
962
+ section: 'Tabs',
963
+ onExecute: () => switchTabByOffset(1),
964
+ },
965
+ {
966
+ id: 'find',
967
+ label: 'Find in document',
968
+ shortcut: `${modKey}+F`,
969
+ section: 'Navigation',
970
+ onExecute: () => {
971
+ setActiveModal('search');
972
+ setSearchFocusTrigger((t) => t + 1);
973
+ },
974
+ },
975
+ ],
976
+ [handleJumpToNext, handleJumpToPrev, switchTabByOffset, setActiveModal, setSearchFocusTrigger],
977
+ );
978
+
979
+ const viewCommands = useMemo((): Command[] => {
980
+ const cmds: Command[] = [
981
+ {
982
+ id: 'toggle-sidebar',
983
+ label: 'Toggle sidebar',
984
+ shortcut: `${modKey}+\\`,
985
+ section: 'View',
986
+ onExecute: () => setSidebarVisible((p) => !p),
987
+ },
988
+ {
989
+ id: 'view-rendered',
990
+ label: 'Switch to rendered view',
991
+ section: 'View',
992
+ onExecute: () => setViewMode('rendered'),
993
+ },
994
+ {
995
+ id: 'view-raw',
996
+ label: 'Switch to raw markdown',
997
+ section: 'View',
998
+ onExecute: () => setViewMode('raw'),
999
+ },
1000
+ {
1001
+ id: 'toggle-explorer',
1002
+ label: 'Toggle file explorer',
1003
+ shortcut: `${modKey}+B`,
1004
+ section: 'View',
1005
+ onExecute: () => {
1006
+ if (explorerVisible && leftPanelView === 'explorer') {
1007
+ setExplorerVisible(false);
1008
+ } else {
1009
+ setExplorerVisible(true);
1010
+ setLeftPanelView('explorer');
1011
+ }
1012
+ },
1013
+ },
1014
+ {
1015
+ id: 'toggle-outline',
1016
+ label: 'Toggle document outline',
1017
+ shortcut: `${modKey}+Shift+O`,
1018
+ section: 'View',
1019
+ onExecute: () => {
1020
+ if (explorerVisible && leftPanelView === 'outline') {
1021
+ setExplorerVisible(false);
1022
+ } else {
1023
+ setExplorerVisible(true);
1024
+ setLeftPanelView('outline');
1025
+ }
1026
+ },
1027
+ },
1028
+ ];
1029
+ if (currentSnapshot) {
1030
+ cmds.push({
1031
+ id: 'toggle-diff-overlay',
1032
+ label: diffEnabled ? 'Hide diff overlay' : 'Show diff overlay',
1033
+ section: 'View',
1034
+ onExecute: handleDiffToggle,
1035
+ });
1036
+ }
1037
+ return cmds;
1038
+ }, [
1039
+ setSidebarVisible,
1040
+ setViewMode,
1041
+ setExplorerVisible,
1042
+ setLeftPanelView,
1043
+ explorerVisible,
1044
+ leftPanelView,
1045
+ currentSnapshot,
1046
+ diffEnabled,
1047
+ handleDiffToggle,
1048
+ ]);
1049
+
1050
+ const fileCommands = useMemo((): Command[] => {
1051
+ const cmds: Command[] = [
1052
+ { id: 'reload-file', label: 'Reload file', section: 'File', onExecute: reloadFile },
1053
+ {
1054
+ id: 'open-file',
1055
+ label: 'Open file',
1056
+ shortcut: `${modKey}+O`,
1057
+ section: 'File',
1058
+ onExecute: openFilePicker,
1059
+ },
1060
+ ];
1061
+ if (currentSnapshot) {
1062
+ cmds.push({
1063
+ id: 'clear-snapshot',
1064
+ label: 'Clear diff snapshot',
1065
+ section: 'File',
1066
+ onExecute: handleClearSnapshot,
1067
+ });
1068
+ }
1069
+ return cmds;
1070
+ }, [reloadFile, openFilePicker, handleClearSnapshot, currentSnapshot]);
1071
+
1072
+ const generalCommands = useMemo(
1073
+ (): Command[] => [
1074
+ {
1075
+ id: 'open-settings',
1076
+ label: 'Open settings',
1077
+ shortcut: `${modKey}+,`,
1078
+ section: 'General',
1079
+ onExecute: () => setActiveModal('settings'),
1080
+ },
1081
+ {
1082
+ id: 'keyboard-shortcuts',
1083
+ label: 'Keyboard shortcuts',
1084
+ shortcut: '?',
1085
+ section: 'General',
1086
+ onExecute: () => setActiveModal('shortcuts'),
1087
+ },
1088
+ {
1089
+ id: 'theme-system',
1090
+ label: 'Theme: System',
1091
+ section: 'Theme',
1092
+ onExecute: () => setTheme('system'),
1093
+ },
1094
+ ...ALL_THEMES.map((t) => ({
1095
+ id: `theme-${t.key}`,
1096
+ label: `Theme: ${t.label}`,
1097
+ section: 'Theme',
1098
+ onExecute: () => setTheme(t.key),
1099
+ })),
1100
+ ],
1101
+ [setTheme, setActiveModal],
1102
+ );
1103
+
1104
+ const commentCommands = useMemo((): Command[] => {
1105
+ const cmds: Command[] = [];
1106
+ if (settings.enableResolve && commentCount > 0) {
1107
+ cmds.push({
1108
+ id: 'resolve-all',
1109
+ label: 'Resolve all open comments',
1110
+ section: 'Comments',
1111
+ onExecute: handleBulkResolve,
1112
+ });
1113
+ }
1114
+ if (commentCount > 0) {
1115
+ cmds.push({
1116
+ id: 'delete-all',
1117
+ label: 'Delete all comments',
1118
+ section: 'Comments',
1119
+ onExecute: handleBulkDelete,
1120
+ });
1121
+ cmds.push({
1122
+ id: 'copy-agent-prompt',
1123
+ label: 'Hand off to agent (copy instructions)',
1124
+ section: 'Comments',
1125
+ onExecute: () => activeFilePath && handleHandoff([activeFilePath]),
1126
+ });
1127
+ }
1128
+ if (activeCommentId) {
1129
+ if (settings.enableResolve) {
1130
+ const activeComment = comments.find((c) => c.id === activeCommentId);
1131
+ if (activeComment) {
1132
+ const status = getEffectiveStatus(activeComment);
1133
+ if (status === 'open') {
1134
+ cmds.push({
1135
+ id: 'resolve-active',
1136
+ label: 'Resolve active comment',
1137
+ shortcut: 'A',
1138
+ section: 'Comments',
1139
+ onExecute: () => handleResolve(activeCommentId),
1140
+ });
1141
+ }
1142
+ if (status === 'resolved') {
1143
+ cmds.push({
1144
+ id: 'unresolve-active',
1145
+ label: 'Reopen active comment',
1146
+ shortcut: 'U',
1147
+ section: 'Comments',
1148
+ onExecute: () => handleUnresolve(activeCommentId),
1149
+ });
1150
+ }
1151
+ }
1152
+ }
1153
+ cmds.push({
1154
+ id: 'delete-active',
1155
+ label: 'Delete active comment',
1156
+ shortcut: 'D',
1157
+ section: 'Comments',
1158
+ onExecute: () => handleDelete(activeCommentId),
1159
+ });
1160
+ }
1161
+ return cmds;
1162
+ }, [
1163
+ commentCount,
1164
+ settings.enableResolve,
1165
+ handleBulkDelete,
1166
+ handleBulkResolve,
1167
+ handleHandoff,
1168
+ activeFilePath,
1169
+ activeCommentId,
1170
+ comments,
1171
+ handleResolve,
1172
+ handleUnresolve,
1173
+ handleDelete,
1174
+ ]);
1175
+
1176
+ const headingCommands = useMemo(
1177
+ (): Command[] =>
1178
+ tocHeadings.map((h) => ({
1179
+ id: `heading-${h.id}`,
1180
+ label: `${'\u2003'.repeat(h.level - 1)}${h.text}`,
1181
+ section: 'Headings' as const,
1182
+ onExecute: () => handleHeadingNavigate(h.id),
1183
+ })),
1184
+ [tocHeadings, handleHeadingNavigate],
1185
+ );
1186
+
1187
+ const paletteCommands = useMemo(
1188
+ () => [
1189
+ ...navigationCommands,
1190
+ ...viewCommands,
1191
+ ...fileCommands,
1192
+ ...generalCommands,
1193
+ ...commentCommands,
1194
+ ...headingCommands,
1195
+ ],
1196
+ [
1197
+ navigationCommands,
1198
+ viewCommands,
1199
+ fileCommands,
1200
+ generalCommands,
1201
+ commentCommands,
1202
+ headingCommands,
1203
+ ],
1204
+ );
1205
+
1206
+ return (
1207
+ <div className="h-screen flex flex-col bg-surface">
1208
+ <Toolbar
1209
+ error={error}
1210
+ isLoading={isLoading}
1211
+ showExplorer={explorerVisible}
1212
+ sidebarVisible={sidebarVisible}
1213
+ author={author}
1214
+ onAuthorChange={setAuthor}
1215
+ onToggleExplorer={() => setExplorerVisible((p) => !p)}
1216
+ onToggleSidebar={() => setSidebarVisible((p) => !p)}
1217
+ onOpenSettings={() => setActiveModal('settings')}
1218
+ />
1219
+ <TabBar
1220
+ tabs={tabs}
1221
+ activeFilePath={activeFilePath}
1222
+ commentCounts={commentCounts}
1223
+ resolvedCommentCounts={resolvedCommentCounts}
1224
+ onSwitchTab={switchTab}
1225
+ onCloseTab={closeTab}
1226
+ onOpenFile={openFilePicker}
1227
+ onTabContextMenu={handleTabContextMenu}
1228
+ viewMode={viewMode}
1229
+ diffPending={diffPending}
1230
+ commentCount={commentCount}
1231
+ enableResolve={settings.enableResolve}
1232
+ onViewModeChange={(mode) => {
1233
+ setViewMode(mode);
1234
+ if (mode === 'raw') {
1235
+ clearSelection();
1236
+ if (currentSnapshot) {
1237
+ setDiffEnabled(true);
1238
+ setDiffPending(false);
1239
+ }
1240
+ }
1241
+ if (mode === 'rendered') setDiffEnabled(false);
1242
+ }}
1243
+ onSearch={() => {
1244
+ if (showSearch) {
1245
+ handleSearchClose();
1246
+ } else {
1247
+ setActiveModal('search');
1248
+ setSearchFocusTrigger((t) => t + 1);
1249
+ }
1250
+ }}
1251
+ searchActive={showSearch}
1252
+ onCopyAgentPrompt={handleHandoff}
1253
+ />
1254
+
1255
+ <>
1256
+ <div className="flex-1 flex min-h-0 relative">
1257
+ {/* Left pane (Explorer / Outline) */}
1258
+ <div
1259
+ className={`border-r border-border bg-surface-secondary shrink-0 flex flex-col overflow-hidden ${
1260
+ explorerVisible ? '' : 'w-0 border-r-0'
1261
+ } ${isDragging ? '' : 'transition-[width] duration-200 ease-in-out'}`}
1262
+ style={explorerVisible ? { width: explorerWidth } : undefined}
1263
+ >
1264
+ <div
1265
+ className={`h-full flex flex-col min-w-0 ${explorerVisible ? '' : 'invisible pointer-events-none'}`}
1266
+ aria-hidden={!explorerVisible}
1267
+ >
1268
+ {/* Tab bar */}
1269
+ <div className="h-10 flex items-center justify-between pl-1 pr-2 shrink-0">
1270
+ <div className="flex items-center gap-0.5">
1271
+ <button
1272
+ onClick={() => setLeftPanelView('explorer')}
1273
+ className={`px-2.5 py-1.5 rounded text-xs font-medium transition-colors ${
1274
+ leftPanelView === 'explorer'
1275
+ ? 'bg-surface-inset text-content'
1276
+ : 'text-content-muted hover:text-content-secondary hover:bg-tint/50'
1277
+ }`}
1278
+ title="File explorer"
1279
+ >
1280
+ <svg
1281
+ className="w-3.5 h-3.5 inline-block mr-1 -mt-0.5"
1282
+ fill="none"
1283
+ viewBox="0 0 24 24"
1284
+ stroke="currentColor"
1285
+ strokeWidth={2}
1286
+ >
1287
+ <path
1288
+ strokeLinecap="round"
1289
+ strokeLinejoin="round"
1290
+ d="M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 00-1.883 2.542l.857 6a2.25 2.25 0 002.227 1.932H19.05a2.25 2.25 0 002.227-1.932l.857-6a2.25 2.25 0 00-1.883-2.542m-16.5 0V6A2.25 2.25 0 016 3.75h3.879a1.5 1.5 0 011.06.44l2.122 2.12a1.5 1.5 0 001.06.44H18A2.25 2.25 0 0120.25 9v.776"
1291
+ />
1292
+ </svg>
1293
+ Explorer
1294
+ </button>
1295
+ <button
1296
+ onClick={() => setLeftPanelView('outline')}
1297
+ className={`px-2.5 py-1.5 rounded text-xs font-medium transition-colors ${
1298
+ leftPanelView === 'outline'
1299
+ ? 'bg-surface-inset text-content'
1300
+ : 'text-content-muted hover:text-content-secondary hover:bg-tint/50'
1301
+ }`}
1302
+ title="Document outline"
1303
+ >
1304
+ <svg
1305
+ className="w-3.5 h-3.5 inline-block mr-1 -mt-0.5"
1306
+ fill="none"
1307
+ viewBox="0 0 24 24"
1308
+ stroke="currentColor"
1309
+ strokeWidth={2}
1310
+ >
1311
+ <path
1312
+ strokeLinecap="round"
1313
+ strokeLinejoin="round"
1314
+ d="M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.007v.008H3.75V6.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM3.75 12h.007v.008H3.75V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zm-.375 5.25h.007v.008H3.75v-.008zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"
1315
+ />
1316
+ </svg>
1317
+ Outline
1318
+ </button>
1319
+ </div>
1320
+ <button
1321
+ onClick={() => setExplorerVisible(false)}
1322
+ className="shrink-0 p-1 rounded-md text-content-muted hover:text-content-secondary hover:bg-tint transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
1323
+ title="Close panel"
1324
+ aria-label="Close panel"
1325
+ >
1326
+ <svg
1327
+ className="w-3.5 h-3.5"
1328
+ viewBox="0 0 24 24"
1329
+ fill="none"
1330
+ stroke="currentColor"
1331
+ strokeWidth={2}
1332
+ >
1333
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
1334
+ </svg>
1335
+ </button>
1336
+ </div>
1337
+ {/* Panel content */}
1338
+ {leftPanelView === 'explorer' ? (
1339
+ <FileExplorer
1340
+ initialDir={explorerDir}
1341
+ activeFilePath={activeFilePath}
1342
+ onOpenFile={handleExplorerOpenFile}
1343
+ onClose={() => setExplorerVisible(false)}
1344
+ onContextMenu={handleExplorerContextMenu}
1345
+ hideHeader
1346
+ />
1347
+ ) : (
1348
+ <TableOfContents
1349
+ headings={tocHeadings}
1350
+ activeHeadingId={activeHeadingId}
1351
+ onHeadingClick={handleHeadingNavigate}
1352
+ />
1353
+ )}
1354
+ </div>
1355
+ </div>
1356
+ {explorerVisible && (
1357
+ <div
1358
+ className="w-px shrink-0 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors relative group"
1359
+ onMouseDown={(e) => onResizeStart('explorer', e)}
1360
+ >
1361
+ <div className="absolute inset-y-0 -left-1 -right-1" />
1362
+ </div>
1363
+ )}
1364
+
1365
+ {/* Markdown viewer */}
1366
+ <div className="flex-1 min-h-0 min-w-0 relative bg-surface panel-center">
1367
+ {showSearch && (
1368
+ <SearchBar
1369
+ query={searchQuery}
1370
+ onQueryChange={handleSearchQueryChange}
1371
+ matchCount={searchMatchCount}
1372
+ activeIndex={activeSearchIndex}
1373
+ onNext={handleSearchNext}
1374
+ onPrev={handleSearchPrev}
1375
+ onClose={handleSearchClose}
1376
+ focusTrigger={searchFocusTrigger}
1377
+ />
1378
+ )}
1379
+ {viewMode === 'raw' ? (
1380
+ <div ref={containerRef} className="h-full relative flex flex-col">
1381
+ <RawView
1382
+ ref={rawViewRef}
1383
+ rawMarkdown={rawMarkdown}
1384
+ searchQuery={showSearch ? searchQuery : undefined}
1385
+ searchActiveIndex={activeSearchIndex}
1386
+ onSearchCount={handleSearchCount}
1387
+ activeCommentId={activeCommentId}
1388
+ diffSnapshot={currentSnapshot}
1389
+ diffEnabled={diffEnabled}
1390
+ onDiffToggle={handleDiffToggle}
1391
+ onClearSnapshot={handleClearSnapshot}
1392
+ />
1393
+ </div>
1394
+ ) : (
1395
+ <div
1396
+ ref={containerRef}
1397
+ className="h-full overflow-y-auto px-8 pt-6 pb-[50vh] lg:px-12 xl:px-16 relative"
1398
+ >
1399
+ <div className="max-w-3xl mx-auto">
1400
+ <MarkdownViewer
1401
+ ref={viewerRef}
1402
+ html={html}
1403
+ cleanMarkdown={cleanMarkdown}
1404
+ comments={comments}
1405
+ activeCommentId={activeCommentId}
1406
+ selectionText={selection?.text ?? null}
1407
+ selectionOffset={selection?.offset ?? null}
1408
+ onHighlightClick={handleHighlightClick}
1409
+ onContextMenu={handleViewerContextMenu}
1410
+ enableResolve={settings.enableResolve}
1411
+ searchQuery={showSearch ? searchQuery : undefined}
1412
+ searchActiveIndex={activeSearchIndex}
1413
+ onSearchCount={handleSearchCount}
1414
+ theme={theme}
1415
+ />
1416
+ <DragHandles
1417
+ startPos={handlePositions?.start ?? null}
1418
+ endPos={handlePositions?.end ?? null}
1419
+ onMouseDown={onHandleMouseDown}
1420
+ />
1421
+ </div>
1422
+ </div>
1423
+ )}
1424
+ </div>
1425
+
1426
+ {/* Comment sidebar */}
1427
+ {sidebarVisible && (
1428
+ <div
1429
+ className="w-px shrink-0 cursor-col-resize hover:bg-primary/30 active:bg-primary/50 transition-colors relative group"
1430
+ onMouseDown={(e) => onResizeStart('sidebar', e)}
1431
+ >
1432
+ <div className="absolute inset-y-0 -left-1 -right-1" />
1433
+ </div>
1434
+ )}
1435
+ <div
1436
+ className={`border-l border-border bg-surface-secondary shrink-0 flex flex-col overflow-hidden ${
1437
+ sidebarVisible ? '' : 'w-0 border-l-0'
1438
+ } ${isDragging ? '' : 'transition-[width] duration-200 ease-in-out'}`}
1439
+ style={sidebarVisible ? { width: sidebarWidth } : undefined}
1440
+ >
1441
+ <div className="h-full flex flex-col min-w-0">
1442
+ <div className="h-10 flex items-center justify-between pl-1 pr-2 shrink-0">
1443
+ <div className="flex items-center gap-0.5">
1444
+ <h2 className="px-2.5 py-1.5 rounded text-xs font-medium text-content flex items-center gap-1">
1445
+ <svg
1446
+ className="w-3.5 h-3.5"
1447
+ fill="none"
1448
+ viewBox="0 0 24 24"
1449
+ stroke="currentColor"
1450
+ strokeWidth={2}
1451
+ >
1452
+ <path
1453
+ strokeLinecap="round"
1454
+ strokeLinejoin="round"
1455
+ d="M7.5 8.25h9m-9 3H12m-9.75 1.51c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 011.037-.443 48.282 48.282 0 005.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0012 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018z"
1456
+ />
1457
+ </svg>
1458
+ Comments
1459
+ </h2>
1460
+ </div>
1461
+ <button
1462
+ onClick={() => setSidebarVisible(false)}
1463
+ className="shrink-0 p-1 rounded-md text-content-muted hover:text-content-secondary hover:bg-tint transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
1464
+ title="Close comments panel"
1465
+ aria-label="Close comments panel"
1466
+ >
1467
+ <svg
1468
+ className="w-3.5 h-3.5"
1469
+ viewBox="0 0 24 24"
1470
+ fill="none"
1471
+ stroke="currentColor"
1472
+ strokeWidth={2}
1473
+ >
1474
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
1475
+ </svg>
1476
+ </button>
1477
+ </div>
1478
+ <div className="flex-1 min-h-0">
1479
+ <CommentSidebar
1480
+ comments={comments}
1481
+ activeCommentId={activeCommentId}
1482
+ missingAnchors={missingAnchors}
1483
+ onActivate={handleSidebarActivate}
1484
+ onResolve={handleResolve}
1485
+ onUnresolve={handleUnresolve}
1486
+ onDelete={handleDelete}
1487
+ onEdit={handleEdit}
1488
+ onReply={handleReply}
1489
+ onEditReply={handleEditReply}
1490
+ onDeleteReply={handleDeleteReply}
1491
+ onBulkDelete={handleBulkDelete}
1492
+ onBulkResolve={handleBulkResolve}
1493
+ onBulkDeleteResolved={handleBulkDeleteResolved}
1494
+ onContextMenu={handleSidebarContextMenu}
1495
+ requestedEditor={requestedEditor}
1496
+ requestedFocus={requestedCommentFocus}
1497
+ onFocusHandled={() => setRequestedCommentFocus(null)}
1498
+ />
1499
+ </div>
1500
+ </div>
1501
+ </div>
1502
+ </div>
1503
+
1504
+ {/* Floating comment form (disabled in raw/diff view) */}
1505
+ {selection && viewMode === 'rendered' && (
1506
+ <CommentForm
1507
+ selection={selection}
1508
+ autoExpand={autoExpandForm}
1509
+ onSubmit={(anchor, text, ctxBefore, ctxAfter, hintOffset) => {
1510
+ handleAddComment(anchor, text, ctxBefore, ctxAfter, hintOffset);
1511
+ setAutoExpandForm(false);
1512
+ }}
1513
+ onCancel={() => {
1514
+ clearSelection();
1515
+ setAutoExpandForm(false);
1516
+ }}
1517
+ onLock={lockSelection}
1518
+ />
1519
+ )}
1520
+ </>
1521
+
1522
+ {/* Toast notification (Feature 8) */}
1523
+ <Toast
1524
+ message={toast.message}
1525
+ visible={toast.visible}
1526
+ onDismiss={dismissToast}
1527
+ action={toast.action}
1528
+ />
1529
+
1530
+ {/* Command palette */}
1531
+ <CommandPalette
1532
+ commands={paletteCommands}
1533
+ open={activeModal === 'commandPalette'}
1534
+ onClose={() => setActiveModal(null)}
1535
+ />
1536
+
1537
+ {/* File opener */}
1538
+ <FileOpener
1539
+ open={activeModal === 'fileOpener'}
1540
+ onClose={() => setActiveModal(null)}
1541
+ onOpenFile={(path) => {
1542
+ handleOpenFile(path);
1543
+ setActiveModal(null);
1544
+ }}
1545
+ recentFiles={recentFiles}
1546
+ activeFilePath={activeFilePath}
1547
+ onClearRecent={clearRecentFiles}
1548
+ />
1549
+
1550
+ {/* Context menus */}
1551
+ {viewerCtxMenu.isOpen && (
1552
+ <ContextMenu
1553
+ items={ctxMenuItems}
1554
+ position={viewerCtxMenu.position}
1555
+ onClose={viewerCtxMenu.close}
1556
+ />
1557
+ )}
1558
+ {explorerCtxMenu.isOpen && (
1559
+ <ContextMenu
1560
+ items={explorerCtxMenuItems}
1561
+ position={explorerCtxMenu.position}
1562
+ onClose={explorerCtxMenu.close}
1563
+ />
1564
+ )}
1565
+ {tabCtxMenu.isOpen && (
1566
+ <ContextMenu
1567
+ items={tabCtxMenuItems}
1568
+ position={tabCtxMenu.position}
1569
+ onClose={tabCtxMenu.close}
1570
+ />
1571
+ )}
1572
+ {sidebarCtxMenu.isOpen && (
1573
+ <ContextMenu
1574
+ items={sidebarCtxMenuItems}
1575
+ position={sidebarCtxMenu.position}
1576
+ onClose={sidebarCtxMenu.close}
1577
+ />
1578
+ )}
1579
+
1580
+ {/* Settings panel */}
1581
+ <SettingsPanel
1582
+ open={activeModal === 'settings'}
1583
+ onClose={() => setActiveModal(null)}
1584
+ author={author}
1585
+ onAuthorChange={setAuthor}
1586
+ />
1587
+ <KeyboardShortcutsPanel
1588
+ open={activeModal === 'shortcuts'}
1589
+ onClose={() => setActiveModal(null)}
1590
+ resolveEnabled={settings.enableResolve}
1591
+ />
1592
+
1593
+ <ConfirmDialog
1594
+ open={pendingClose !== null}
1595
+ title="Unsaved changes"
1596
+ message="This file has unsaved changes that will be lost. Close anyway?"
1597
+ confirmLabel="Close"
1598
+ cancelLabel="Cancel"
1599
+ onConfirm={executePendingClose}
1600
+ onCancel={() => setPendingClose(null)}
1601
+ />
1602
+
1603
+ {/* Keyboard shortcuts hint */}
1604
+ <div className="h-6 bg-surface-secondary border-t border-border flex items-center px-4 gap-4 text-[10px] text-content-muted shrink-0">
1605
+ <span>
1606
+ <kbd className="px-1 py-0.5 bg-surface rounded border border-border-subtle text-content-secondary font-mono">
1607
+ {modKey}+K
1608
+ </kbd>{' '}
1609
+ Commands
1610
+ </span>
1611
+ <span className="ml-auto">
1612
+ <kbd className="px-1 py-0.5 bg-surface rounded border border-border-subtle text-content-secondary font-mono">
1613
+ ?
1614
+ </kbd>{' '}
1615
+ Shortcuts
1616
+ </span>
1617
+ </div>
1618
+ </div>
1619
+ );
1620
+ }