summaryrefslogtreecommitdiff
path: root/src/renderer
diff options
context:
space:
mode:
Diffstat (limited to 'src/renderer')
-rw-r--r--src/renderer/src/App.css79
-rw-r--r--src/renderer/src/App.tsx11
-rw-r--r--src/renderer/src/components/Editor.tsx17
-rw-r--r--src/renderer/src/components/ReviewPanel.tsx237
-rw-r--r--src/renderer/src/components/Terminal.tsx85
-rw-r--r--src/renderer/src/stores/appStore.ts5
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,