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.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/bin/md-redline +255 -0
- package/bin/test-windows.ps1 +70 -0
- package/dist/assets/_baseFor-Ck08IaSF.js +1 -0
- package/dist/assets/arc-DI2g9LXK.js +1 -0
- package/dist/assets/architecture-YZFGNWBL-BDgMfc-b.js +1 -0
- package/dist/assets/architectureDiagram-Q4EWVU46-Dg1hcUEa.js +36 -0
- package/dist/assets/array-DOVTz2Mq.js +1 -0
- package/dist/assets/blockDiagram-DXYQGD6D-BAXkTCAk.js +132 -0
- package/dist/assets/c4Diagram-AHTNJAMY-BIkgwQSx.js +10 -0
- package/dist/assets/channel-DPCihw7y.js +1 -0
- package/dist/assets/chunk-2KRD3SAO-Dc_tBGsw.js +1 -0
- package/dist/assets/chunk-336JU56O-Dhi-ID9Y.js +2 -0
- package/dist/assets/chunk-426QAEUC-DnFdrNMW.js +1 -0
- package/dist/assets/chunk-4BX2VUAB-Z63FkGov.js +1 -0
- package/dist/assets/chunk-4TB4RGXK-BAiBlfyy.js +206 -0
- package/dist/assets/chunk-55IACEB6-BXDWXbxy.js +1 -0
- package/dist/assets/chunk-5FUZZQ4R-C72e1c_O.js +62 -0
- package/dist/assets/chunk-5PVQY5BW-BBHW_uCu.js +2 -0
- package/dist/assets/chunk-67CJDMHE-3Cf_D9m6.js +1 -0
- package/dist/assets/chunk-7N4EOEYR-DAXUXJ2c.js +1 -0
- package/dist/assets/chunk-AA7GKIK3-Dr7fOryc.js +1 -0
- package/dist/assets/chunk-BSJP7CBP-BmsSs1Nt.js +1 -0
- package/dist/assets/chunk-CIAEETIT-QDzV-X_Y.js +1 -0
- package/dist/assets/chunk-EDXVE4YY-C25WFHxY.js +1 -0
- package/dist/assets/chunk-ENJZ2VHE-_OzxcZOU.js +10 -0
- package/dist/assets/chunk-FMBD7UC4-CjsTKY4u.js +15 -0
- package/dist/assets/chunk-FOC6F5B3-g-xaH5nc.js +1 -0
- package/dist/assets/chunk-ICPOFSXX-iKiUSjDK.js +121 -0
- package/dist/assets/chunk-K5T4RW27-CKR-lPBN.js +94 -0
- package/dist/assets/chunk-KGLVRYIC-DRccT-B_.js +1 -0
- package/dist/assets/chunk-LIHQZDEY-DTbMwMXj.js +1 -0
- package/dist/assets/chunk-ORNJ4GCN-DlerdcWX.js +1 -0
- package/dist/assets/chunk-OYMX7WX6-Dekv1on2.js +231 -0
- package/dist/assets/chunk-QZHKN3VN-BHu0RdKl.js +1 -0
- package/dist/assets/chunk-U2HBQHQK-BvtlVHAg.js +70 -0
- package/dist/assets/chunk-X2U36JSP-BI_g8mub.js +1 -0
- package/dist/assets/chunk-XPW4576I-B39JkmSE.js +32 -0
- package/dist/assets/chunk-YZCP3GAM-BfPcXRm2.js +1 -0
- package/dist/assets/chunk-ZZ45TVLE-Bg4q68wZ.js +1 -0
- package/dist/assets/classDiagram-6PBFFD2Q-p73p727_.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-C4Ftpivp.js +1 -0
- package/dist/assets/clone-CI9aUwHe.js +1 -0
- package/dist/assets/cose-bilkent-S5V4N54A-7BpAeDh5.js +1 -0
- package/dist/assets/cytoscape.esm-DoTFyJaN.js +321 -0
- package/dist/assets/dagre-CilMRazv.js +1 -0
- package/dist/assets/dagre-KV5264BT-DDMqpjkB.js +4 -0
- package/dist/assets/defaultLocale-Ck2Xxk-C.js +1 -0
- package/dist/assets/diagram-5BDNPKRD-BFeyfnCx.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-DoqT-PtF.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-BPV6KADk.js +43 -0
- package/dist/assets/diagram-TYMM5635-okvcTBtl.js +24 -0
- package/dist/assets/dist-C_eddq6m.js +1 -0
- package/dist/assets/erDiagram-SMLLAGMA-Dl-Ixy8n.js +85 -0
- package/dist/assets/flatten-B8XIuT0x.js +1 -0
- package/dist/assets/flowDiagram-DWJPFMVM-CsqWAx5r.js +162 -0
- package/dist/assets/ganttDiagram-T4ZO3ILL-mIt6zVeF.js +292 -0
- package/dist/assets/gitGraph-7Q5UKJZL-COXHGMvj.js +1 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-syVqZJX_.js +106 -0
- package/dist/assets/graphlib-Bpd0q3yO.js +1 -0
- package/dist/assets/index-BoggyWS0.css +2 -0
- package/dist/assets/index-aLvjHQW4.js +104 -0
- package/dist/assets/info-OMHHGYJF-B-0wfxwL.js +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-C0_uqsVa.js +2 -0
- package/dist/assets/init-Bft5Ffpj.js +1 -0
- package/dist/assets/isEmpty-BrFi5AqV.js +1 -0
- package/dist/assets/ishikawaDiagram-UXIWVN3A-CTjFbDBV.js +70 -0
- package/dist/assets/journeyDiagram-VCZTEJTY-BDBcej1q.js +139 -0
- package/dist/assets/kanban-definition-6JOO6SKY-Ylgzakw7.js +89 -0
- package/dist/assets/katex-Uj9wLT16.js +265 -0
- package/dist/assets/line-CRxEwpOv.js +1 -0
- package/dist/assets/linear-PDPfFByd.js +1 -0
- package/dist/assets/mermaid-parser.core-CY-XNOOy.js +4 -0
- package/dist/assets/mermaid.core-BPlTADIX.js +11 -0
- package/dist/assets/mindmap-definition-QFDTVHPH-TefzJnBM.js +96 -0
- package/dist/assets/ordinal-DIg8h6NI.js +1 -0
- package/dist/assets/packet-4T2RLAQJ-BW1T_A-C.js +1 -0
- package/dist/assets/path-DfRbCp9y.js +1 -0
- package/dist/assets/pie-ZZUOXDRM-DkKU-SFu.js +1 -0
- package/dist/assets/pieDiagram-DEJITSTG-BCXuaeEy.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-VSBAicWL.js +7 -0
- package/dist/assets/radar-PYXPWWZC-CYvTacKJ.js +1 -0
- package/dist/assets/reduce-CV2X8n1a.js +1 -0
- package/dist/assets/requirementDiagram-MS252O5E-4NeL9Z6J.js +84 -0
- package/dist/assets/rough.esm-Bbn_-PMU.js +1 -0
- package/dist/assets/sankeyDiagram-XADWPNL6-DMBSDnrH.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-DVpzcZUi.js +157 -0
- package/dist/assets/src-PKe5NtkK.js +1 -0
- package/dist/assets/stateDiagram-FHFEXIEX-BkHTlCjL.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-nMeWu9fP.js +1 -0
- package/dist/assets/timeline-definition-GMOUNBTQ-CyLt92nf.js +120 -0
- package/dist/assets/treeView-SZITEDCU-BUgcJ4eR.js +1 -0
- package/dist/assets/treemap-W4RFUUIX-BIWGQ4Pw.js +1 -0
- package/dist/assets/vennDiagram-DHZGUBPP-BCK0xB_m.js +34 -0
- package/dist/assets/wardley-RL74JXVD-DMZZRlby.js +1 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-BisBgfsF.js +20 -0
- package/dist/assets/xychartDiagram-5P7HB3ND-D_REDciv.js +7 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +14 -0
- package/dist/screenshot.png +0 -0
- package/index.html +13 -0
- package/package.json +105 -0
- package/public/favicon.svg +15 -0
- package/public/screenshot.png +0 -0
- package/server/index.test.ts +814 -0
- package/server/index.ts +736 -0
- package/server/preferences.test.ts +126 -0
- package/server/preferences.ts +76 -0
- package/src/App.tsx +1620 -0
- package/src/components/ActionButton.tsx +41 -0
- package/src/components/CommandPalette.tsx +191 -0
- package/src/components/CommentCard.tsx +556 -0
- package/src/components/CommentForm.tsx +285 -0
- package/src/components/CommentSidebar.tsx +428 -0
- package/src/components/ConfirmDialog.tsx +64 -0
- package/src/components/ContextMenu.tsx +220 -0
- package/src/components/DragHandles.tsx +48 -0
- package/src/components/FileExplorer.tsx +251 -0
- package/src/components/FileOpener.tsx +304 -0
- package/src/components/IconButton.tsx +32 -0
- package/src/components/KeyboardShortcutsPanel.tsx +136 -0
- package/src/components/MarkdownViewer.tsx +682 -0
- package/src/components/RawView.tsx +798 -0
- package/src/components/SearchBar.tsx +129 -0
- package/src/components/Separator.tsx +7 -0
- package/src/components/SettingsPanel.tsx +813 -0
- package/src/components/SplitIconButton.tsx +133 -0
- package/src/components/TabBar.tsx +594 -0
- package/src/components/TableOfContents.tsx +70 -0
- package/src/components/ThemeSelector.tsx +159 -0
- package/src/components/Toast.tsx +99 -0
- package/src/components/Toolbar.tsx +161 -0
- package/src/components/iconButtonVariants.ts +19 -0
- package/src/components/rawView.test.ts +291 -0
- package/src/contexts/SettingsContext.tsx +120 -0
- package/src/hooks/useAuthor.test.ts +58 -0
- package/src/hooks/useAuthor.ts +69 -0
- package/src/hooks/useAutoResize.ts +20 -0
- package/src/hooks/useCommentCardTriggers.ts +20 -0
- package/src/hooks/useComments.test.ts +773 -0
- package/src/hooks/useComments.ts +332 -0
- package/src/hooks/useContextMenu.ts +48 -0
- package/src/hooks/useContextMenuItems.ts +392 -0
- package/src/hooks/useDiffSnapshot.test.ts +130 -0
- package/src/hooks/useDiffSnapshot.ts +67 -0
- package/src/hooks/useDragHandles.ts +417 -0
- package/src/hooks/useFileWatcher.ts +45 -0
- package/src/hooks/useHeadingTracking.ts +84 -0
- package/src/hooks/useMermaidRenderer.ts +75 -0
- package/src/hooks/useModalState.ts +22 -0
- package/src/hooks/usePageVisible.test.ts +69 -0
- package/src/hooks/usePageVisible.ts +19 -0
- package/src/hooks/usePaneLayout.test.ts +108 -0
- package/src/hooks/usePaneLayout.ts +102 -0
- package/src/hooks/useRecentFiles.test.ts +103 -0
- package/src/hooks/useRecentFiles.ts +99 -0
- package/src/hooks/useResizablePanel.test.ts +84 -0
- package/src/hooks/useResizablePanel.ts +118 -0
- package/src/hooks/useSearch.test.ts +72 -0
- package/src/hooks/useSearch.ts +53 -0
- package/src/hooks/useSelection.ts +48 -0
- package/src/hooks/useSessionPersistence.test.ts +59 -0
- package/src/hooks/useSessionPersistence.ts +43 -0
- package/src/hooks/useTabs.test.ts +127 -0
- package/src/hooks/useTabs.ts +561 -0
- package/src/hooks/useThemePersistence.ts +41 -0
- package/src/hooks/useToast.ts +27 -0
- package/src/index.css +1047 -0
- package/src/lib/agent-prompts.test.ts +34 -0
- package/src/lib/agent-prompts.ts +68 -0
- package/src/lib/comment-editor-state.ts +6 -0
- package/src/lib/comment-parser.test.ts +1959 -0
- package/src/lib/comment-parser.ts +1021 -0
- package/src/lib/diff.test.ts +164 -0
- package/src/lib/diff.ts +139 -0
- package/src/lib/heading-slugs.test.ts +85 -0
- package/src/lib/heading-slugs.ts +44 -0
- package/src/lib/http.test.ts +43 -0
- package/src/lib/http.ts +29 -0
- package/src/lib/mermaid-highlights.test.ts +517 -0
- package/src/lib/mermaid-highlights.ts +936 -0
- package/src/lib/mermaid-renderer.test.ts +114 -0
- package/src/lib/mermaid-renderer.ts +89 -0
- package/src/lib/path-utils.test.ts +17 -0
- package/src/lib/path-utils.ts +7 -0
- package/src/lib/platform.test.ts +58 -0
- package/src/lib/platform.ts +14 -0
- package/src/lib/preferences-client.test.ts +177 -0
- package/src/lib/preferences-client.ts +94 -0
- package/src/lib/selection-resolver.test.ts +118 -0
- package/src/lib/selection-resolver.ts +37 -0
- package/src/lib/settings.test.ts +152 -0
- package/src/lib/settings.ts +78 -0
- package/src/lib/shortcut-label.tsx +18 -0
- package/src/lib/themes.ts +21 -0
- package/src/lib/visible-text.test.ts +86 -0
- package/src/lib/visible-text.ts +77 -0
- package/src/main.tsx +22 -0
- package/src/markdown/pipeline.test.ts +82 -0
- package/src/markdown/pipeline.ts +33 -0
- package/src/types.test.ts +43 -0
- package/src/types.ts +46 -0
- package/tsconfig.app.json +28 -0
- package/tsconfig.json +7 -0
- package/tsconfig.node.json +26 -0
- 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
|
+
}
|