diff options
Diffstat (limited to 'src/renderer')
| -rw-r--r-- | src/renderer/src/App.css | 79 | ||||
| -rw-r--r-- | src/renderer/src/App.tsx | 11 | ||||
| -rw-r--r-- | src/renderer/src/components/Editor.tsx | 17 | ||||
| -rw-r--r-- | src/renderer/src/components/ReviewPanel.tsx | 237 | ||||
| -rw-r--r-- | src/renderer/src/components/Terminal.tsx | 85 | ||||
| -rw-r--r-- | src/renderer/src/stores/appStore.ts | 5 |
6 files changed, 369 insertions, 65 deletions
diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css index aae0b4b..a6a4003 100644 --- a/src/renderer/src/App.css +++ b/src/renderer/src/App.css @@ -1465,10 +1465,14 @@ html, body, #root { min-width: 280px; height: 100%; border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + overflow: hidden; } .review-panel { - height: 100%; + flex: 1; + min-height: 0; display: flex; flex-direction: column; background: var(--bg-primary); @@ -1812,17 +1816,6 @@ html, body, #root { gap: 4px; } -.terminal-toolbar .pdf-tab { - color: #A09880; -} -.terminal-toolbar .pdf-tab:hover { - color: #C8BFA0; -} -.terminal-toolbar .pdf-tab.active { - background: #3D3830; - color: #E8DFC0; -} - .terminal-content { flex: 1; padding: 4px; @@ -1850,6 +1843,68 @@ html, body, #root { color: #E8DFC0 !important; } +.terminal-tab-bar { + display: flex; + align-items: center; + height: 28px; + background: #1E1B15; + border-top: 1px solid #3D3830; + padding: 0 4px; + gap: 2px; + flex-shrink: 0; +} + +.terminal-tab { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 10px; + font-size: 11px; + color: #8B7D5E; + cursor: pointer; + border-radius: 4px; + user-select: none; +} +.terminal-tab:hover { + color: #C8BFA0; + background: #2D2A24; +} +.terminal-tab.active { + color: #E8DFC0; + background: #3D3830; +} + +.terminal-tab-close { + font-size: 13px; + line-height: 1; + opacity: 0; + cursor: pointer; + padding: 0 2px; + border-radius: 2px; +} +.terminal-tab:hover .terminal-tab-close { + opacity: 0.6; +} +.terminal-tab-close:hover { + opacity: 1 !important; + background: #5C5040; +} + +.terminal-tab-add { + background: none; + border: none; + color: #6B5B3E; + font-size: 16px; + cursor: pointer; + padding: 0 6px; + line-height: 1; + border-radius: 4px; +} +.terminal-tab-add:hover { + color: #E8DFC0; + background: #2D2A24; +} + /* ── Status Bar ──────────────────────────────────────────────── */ .status-bar { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 82306c9..ca2fc1e 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -111,6 +111,15 @@ export default function App() { } }) + // Listen for initial comment data (threads + contexts) from background fetch on connect + const unsubInitThreads = window.api.onCommentsInitThreads?.((data) => { + const store = useAppStore.getState() + store.setResolvedThreadIds(new Set(data.resolvedIds)) + }) + const unsubInitContexts = window.api.onCommentsInitContexts?.((data) => { + useAppStore.getState().setCommentContexts(data.contexts) + }) + // Listen for remote cursor updates const unsubCursorUpdate = window.api.onCursorRemoteUpdate((raw) => { const data = raw as { @@ -168,6 +177,8 @@ export default function App() { unsubRejoined() unsubExternalEdit() unsubNewDoc() + unsubInitThreads?.() + unsubInitContexts?.() unsubCursorUpdate() unsubCursorDisconnected() remoteCursors.clear() diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx index 75b3872..4252464 100644 --- a/src/renderer/src/components/Editor.tsx +++ b/src/renderer/src/components/Editor.tsx @@ -79,6 +79,7 @@ export default function Editor() { const pendingGoTo = useAppStore((s) => s.pendingGoTo) const commentContexts = useAppStore((s) => s.commentContexts) + const resolvedThreadIds = useAppStore((s) => s.resolvedThreadIds) const hoveredThreadId = useAppStore((s) => s.hoveredThreadId) const overleafProjectId = useAppStore((s) => s.overleafProjectId) const pathDocMap = useAppStore((s) => s.pathDocMap) @@ -118,10 +119,18 @@ export default function Editor() { ) setSubmittingComment(false) if (result.success) { + // Add context immediately so highlight + review panel update without re-fetch + if (result.threadId && activeTab) { + const store = useAppStore.getState() + store.setCommentContexts({ + ...store.commentContexts, + [result.threadId]: { file: activeTab, text: newComment.text, pos: newComment.from } + }) + } setNewComment(null) setCommentInput('') } - }, [newComment, commentInput, overleafProjectId, getDocIdForFile]) + }, [newComment, commentInput, overleafProjectId, activeTab, getDocIdForFile]) // Handle goTo when file is already open useEffect(() => { @@ -325,12 +334,12 @@ export default function Editor() { } }, [activeTab, pathDocMap]) - // Sync comment ranges to CodeMirror + // Sync comment ranges to CodeMirror (exclude resolved threads) useEffect(() => { if (!viewRef.current || !activeTab) return const ranges: CommentRange[] = [] for (const [threadId, ctx] of Object.entries(commentContexts)) { - if (ctx.file === activeTab && ctx.text) { + if (ctx.file === activeTab && ctx.text && !resolvedThreadIds.has(threadId)) { ranges.push({ threadId, from: ctx.pos, @@ -340,7 +349,7 @@ export default function Editor() { } } viewRef.current.dispatch({ effects: setCommentRangesEffect.of(ranges) }) - }, [commentContexts, activeTab]) + }, [commentContexts, activeTab, resolvedThreadIds]) // Sync hover state useEffect(() => { diff --git a/src/renderer/src/components/ReviewPanel.tsx b/src/renderer/src/components/ReviewPanel.tsx index 7edac50..7e6c9e5 100644 --- a/src/renderer/src/components/ReviewPanel.tsx +++ b/src/renderer/src/components/ReviewPanel.tsx @@ -45,24 +45,36 @@ export default function ReviewPanel() { const [editText, setEditText] = useState('') const threadRefs = useRef<Record<string, HTMLDivElement | null>>({}) + // Initial fetch — threads from REST, contexts only if not cached const fetchThreads = useCallback(async () => { if (!overleafProjectId) return setLoading(true) setError('') - const [threadResult, ctxResult] = await Promise.all([ - window.api.overleafGetThreads(overleafProjectId), - window.api.otFetchAllCommentContexts() - ]) + const hasContexts = Object.keys(useAppStore.getState().commentContexts).length > 0 + const threadPromise = window.api.overleafGetThreads(overleafProjectId) + const ctxPromise = !hasContexts ? window.api.otFetchAllCommentContexts() : null + + const threadResult = await threadPromise setLoading(false) if (threadResult.success && threadResult.threads) { - setThreads(threadResult.threads as ThreadMap) + const tm = threadResult.threads as ThreadMap + setThreads(tm) + const resolved = new Set<string>() + for (const [tid, t] of Object.entries(tm)) { + if (t.resolved) resolved.add(tid) + } + useAppStore.getState().setResolvedThreadIds(resolved) } else { setError(threadResult.message || 'Failed to fetch comments') } - if (ctxResult.success && ctxResult.contexts) { - useAppStore.getState().setCommentContexts(ctxResult.contexts) + + if (ctxPromise) { + const ctxResult = await ctxPromise + if (ctxResult.success && ctxResult.contexts) { + useAppStore.getState().setCommentContexts(ctxResult.contexts) + } } }, [overleafProjectId]) @@ -70,38 +82,188 @@ export default function ReviewPanel() { fetchThreads() }, [fetchThreads]) + // Handle real-time comment events from Overleaf socket — update state locally + useEffect(() => { + if (!window.api.onCommentsEvent) return + return window.api.onCommentsEvent((event) => { + const { type, args } = event + switch (type) { + case 'new-comment': { + const threadId = args[0] as string + const comment = args[1] as Message + setThreads(prev => { + if (prev[threadId]) { + // Reply to existing thread + return { + ...prev, + [threadId]: { + ...prev[threadId], + messages: [...prev[threadId].messages, comment] + } + } + } + // New thread — add it + return { ...prev, [threadId]: { messages: [comment] } } + }) + break + } + case 'resolve-thread': { + const threadId = args[0] as string + const user = args[1] as User | undefined + setThreads(prev => { + if (!prev[threadId]) return prev + return { + ...prev, + [threadId]: { + ...prev[threadId], + resolved: true, + resolved_by_user: user, + resolved_at: new Date().toISOString() + } + } + }) + const store = useAppStore.getState() + store.setResolvedThreadIds(new Set([...store.resolvedThreadIds, threadId])) + break + } + case 'reopen-thread': { + const threadId = args[0] as string + setThreads(prev => { + if (!prev[threadId]) return prev + const t = { ...prev[threadId] } + delete t.resolved + delete t.resolved_by_user + delete t.resolved_at + return { ...prev, [threadId]: t } + }) + const store = useAppStore.getState() + const ids = new Set(store.resolvedThreadIds) + ids.delete(threadId) + store.setResolvedThreadIds(ids) + break + } + case 'delete-thread': { + const threadId = args[0] as string + setThreads(prev => { + const next = { ...prev } + delete next[threadId] + return next + }) + // Remove context so highlight disappears + const store = useAppStore.getState() + const newCtx = { ...store.commentContexts } + delete newCtx[threadId] + store.setCommentContexts(newCtx) + const ids = new Set(store.resolvedThreadIds) + ids.delete(threadId) + store.setResolvedThreadIds(ids) + break + } + case 'edit-message': { + const threadId = args[0] as string + const messageId = args[1] as string + const content = args[2] as string + setThreads(prev => { + if (!prev[threadId]) return prev + return { + ...prev, + [threadId]: { + ...prev[threadId], + messages: prev[threadId].messages.map(m => + m.id === messageId ? { ...m, content } : m + ) + } + } + }) + break + } + case 'delete-message': { + const threadId = args[0] as string + const messageId = args[1] as string + setThreads(prev => { + if (!prev[threadId]) return prev + return { + ...prev, + [threadId]: { + ...prev[threadId], + messages: prev[threadId].messages.filter(m => m.id !== messageId) + } + } + }) + break + } + } + }) + }, []) + useEffect(() => { if (!focusedThreadId) return const el = threadRefs.current[focusedThreadId] if (el) el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) }, [focusedThreadId]) + // --- Local actions: optimistic updates, no REST re-fetch --- + const handleReply = async (threadId: string) => { if (!replyText.trim() || !overleafProjectId) return - const result = await window.api.overleafReplyThread(overleafProjectId, threadId, replyText.trim()) - if (result.success) { - setReplyText('') - setReplyingTo(null) - fetchThreads() - } + const content = replyText.trim() + setReplyText('') + setReplyingTo(null) + // Server will broadcast new-comment event which updates state + await window.api.overleafReplyThread(overleafProjectId, threadId, content) + } + + const getDocIdForThread = (threadId: string): string | undefined => { + const ctx = contexts[threadId] + if (!ctx) return undefined + const { pathDocMap } = useAppStore.getState() + return pathDocMap[ctx.file] } const handleResolve = async (threadId: string) => { if (!overleafProjectId) return - await window.api.overleafResolveThread(overleafProjectId, threadId) - fetchThreads() + // Optimistic: mark resolved immediately + setThreads(prev => { + if (!prev[threadId]) return prev + return { ...prev, [threadId]: { ...prev[threadId], resolved: true, resolved_at: new Date().toISOString() } } + }) + const store = useAppStore.getState() + store.setResolvedThreadIds(new Set([...store.resolvedThreadIds, threadId])) + await window.api.overleafResolveThread(overleafProjectId, threadId, getDocIdForThread(threadId)) } const handleReopen = async (threadId: string) => { if (!overleafProjectId) return - await window.api.overleafReopenThread(overleafProjectId, threadId) - fetchThreads() + // Optimistic: mark unresolved immediately + setThreads(prev => { + if (!prev[threadId]) return prev + const t = { ...prev[threadId] } + delete t.resolved + delete t.resolved_by_user + delete t.resolved_at + return { ...prev, [threadId]: t } + }) + const store = useAppStore.getState() + const ids = new Set(store.resolvedThreadIds) + ids.delete(threadId) + store.setResolvedThreadIds(ids) + await window.api.overleafReopenThread(overleafProjectId, threadId, getDocIdForThread(threadId)) } const handleDeleteMessage = async (threadId: string, messageId: string) => { if (!overleafProjectId) return + // Optimistic: remove message immediately + setThreads(prev => { + if (!prev[threadId]) return prev + return { + ...prev, + [threadId]: { + ...prev[threadId], + messages: prev[threadId].messages.filter(m => m.id !== messageId) + } + } + }) await window.api.overleafDeleteMessage(overleafProjectId, threadId, messageId) - fetchThreads() } const handleStartEdit = (threadId: string, msg: Message) => { @@ -111,25 +273,51 @@ export default function ReviewPanel() { const handleSaveEdit = async () => { if (!editingMsg || !editText.trim() || !overleafProjectId) return - await window.api.overleafEditMessage(overleafProjectId, editingMsg.threadId, editingMsg.messageId, editText.trim()) + const { threadId, messageId } = editingMsg + const content = editText.trim() + // Optimistic: update message immediately + setThreads(prev => { + if (!prev[threadId]) return prev + return { + ...prev, + [threadId]: { + ...prev[threadId], + messages: prev[threadId].messages.map(m => + m.id === messageId ? { ...m, content } : m + ) + } + } + }) setEditingMsg(null) setEditText('') - fetchThreads() + await window.api.overleafEditMessage(overleafProjectId, threadId, messageId, content) } const handleDeleteThread = async (threadId: string) => { if (!overleafProjectId) return const ctx = contexts[threadId] const store = useAppStore.getState() + + // Optimistic: remove thread, context, and highlight immediately + setThreads(prev => { + const next = { ...prev } + delete next[threadId] + return next + }) + const newCtx = { ...store.commentContexts } + delete newCtx[threadId] + store.setCommentContexts(newCtx) + const ids = new Set(store.resolvedThreadIds) + ids.delete(threadId) + store.setResolvedThreadIds(ids) + if (ctx) { const docId = store.pathDocMap[ctx.file] if (docId) { await window.api.overleafDeleteThread(overleafProjectId, docId, threadId) - fetchThreads() return } } - fetchThreads() } const getUserName = (msg: Message) => { @@ -137,7 +325,7 @@ export default function ReviewPanel() { return msg.user.last_name ? `${msg.user.first_name} ${msg.user.last_name}` : msg.user.first_name } if (msg.user?.email) return msg.user.email.split('@')[0] - return msg.user_id.slice(-6) + return msg.user_id?.slice(-6) || '?' } const formatTime = (ts: number) => { @@ -154,12 +342,10 @@ export default function ReviewPanel() { return d.toLocaleDateString() } - // Navigate to comment position — always works for current file since it's already open const handleClickContext = (threadId: string) => { const ctx = contexts[threadId] if (!ctx) return const store = useAppStore.getState() - // File should already be open since we only show current file's comments store.setPendingGoTo({ file: ctx.file, pos: ctx.pos, highlight: ctx.text }) } @@ -172,7 +358,6 @@ export default function ReviewPanel() { ) } - // Filter threads to only show ones belonging to the current file const threadEntries = Object.entries(threads) const fileThreads = activeTab ? threadEntries.filter(([threadId]) => { diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index bc29633..ea54343 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -124,39 +124,78 @@ function TerminalInstance({ id, cwd, cmd, args, visible }: { ) } +interface TabInfo { + id: string + name: string +} + +let nextTabId = 0 + export default function Terminal() { - const [mode, setMode] = useState<'terminal' | 'claude'>('terminal') - const [claudeSpawned, setClaudeSpawned] = useState(false) + const [tabs, setTabs] = useState<TabInfo[]>(() => [ + { id: `term-${++nextTabId}`, name: 'Terminal' } + ]) + const [activeTabId, setActiveTabId] = useState(() => `term-${nextTabId}`) const syncDir = useAppStore((s) => s.syncDir) || '/tmp' - const launchClaude = useCallback(() => { - setClaudeSpawned(true) - setMode('claude') + const addTab = useCallback(() => { + const id = `term-${++nextTabId}` + setTabs((prev) => { + const name = `Terminal ${prev.length + 1}` + return [...prev, { id, name }] + }) + setActiveTabId(id) }, []) + const closeTab = useCallback((tabId: string) => { + setTabs((prev) => { + if (prev.length <= 1) return prev + const idx = prev.findIndex((t) => t.id === tabId) + const next = prev.filter((t) => t.id !== tabId) + // If closing the active tab, switch to adjacent + if (tabId === activeTabId) { + const newIdx = Math.min(idx, next.length - 1) + setActiveTabId(next[newIdx].id) + } + return next + }) + }, [activeTabId]) + return ( <div className="terminal-panel"> <div className="terminal-toolbar"> - <button - className={`pdf-tab ${mode === 'terminal' ? 'active' : ''}`} - onClick={() => setMode('terminal')} - > - Terminal - </button> - <button - className={`pdf-tab ${mode === 'claude' ? 'active' : ''}`} - onClick={launchClaude} - > - Claude - </button> - <div className="pdf-toolbar-spacer" /> - <QuickActions ptyId={claudeSpawned ? 'claude' : 'terminal'} /> + <QuickActions ptyId={activeTabId} /> </div> - <TerminalInstance id="terminal" cwd={syncDir} visible={mode === 'terminal'} /> - {claudeSpawned && ( - <TerminalInstance id="claude" cwd={syncDir} args={['-l', '-c', 'claude']} visible={mode === 'claude'} /> - )} + {tabs.map((tab) => ( + <TerminalInstance + key={tab.id} + id={tab.id} + cwd={syncDir} + visible={tab.id === activeTabId} + /> + ))} + + <div className="terminal-tab-bar"> + {tabs.map((tab) => ( + <div + key={tab.id} + className={`terminal-tab ${tab.id === activeTabId ? 'active' : ''}`} + onClick={() => setActiveTabId(tab.id)} + > + <span className="terminal-tab-name">{tab.name}</span> + {tabs.length > 1 && ( + <span + className="terminal-tab-close" + onClick={(e) => { e.stopPropagation(); closeTab(tab.id) }} + > + × + </span> + )} + </div> + ))} + <button className="terminal-tab-add" onClick={addTab}>+</button> + </div> </div> ) } diff --git a/src/renderer/src/stores/appStore.ts b/src/renderer/src/stores/appStore.ts index e9c47ed..ff56c28 100644 --- a/src/renderer/src/stores/appStore.ts +++ b/src/renderer/src/stores/appStore.ts @@ -109,6 +109,8 @@ interface AppState { // Comment data commentContexts: Record<string, CommentContext> setCommentContexts: (c: Record<string, CommentContext>) => void + resolvedThreadIds: Set<string> + setResolvedThreadIds: (ids: Set<string>) => void overleafDocs: Record<string, string> setOverleafDocs: (d: Record<string, string>) => void hoveredThreadId: string | null @@ -219,6 +221,8 @@ export const useAppStore = create<AppState>((set) => ({ commentContexts: {}, setCommentContexts: (c) => set({ commentContexts: c }), + resolvedThreadIds: new Set<string>(), + setResolvedThreadIds: (ids) => set({ resolvedThreadIds: ids }), overleafDocs: {}, setOverleafDocs: (d) => set({ overleafDocs: d }), hoveredThreadId: null, @@ -251,6 +255,7 @@ export const useAppStore = create<AppState>((set) => ({ rootFolderId: '', syncDir: '', commentContexts: {}, + resolvedThreadIds: new Set<string>(), overleafDocs: {}, hoveredThreadId: null, focusedThreadId: null, |
