summaryrefslogtreecommitdiff
path: root/src/renderer
diff options
context:
space:
mode:
Diffstat (limited to 'src/renderer')
-rw-r--r--src/renderer/src/App.css196
-rw-r--r--src/renderer/src/App.tsx43
-rw-r--r--src/renderer/src/components/Editor.tsx3
-rw-r--r--src/renderer/src/components/PdfViewer.tsx207
-rw-r--r--src/renderer/src/components/SearchPanel.tsx132
-rw-r--r--src/renderer/src/components/Toolbar.tsx4
-rw-r--r--src/renderer/src/ot/overleafSync.ts4
-rw-r--r--src/renderer/src/stores/appStore.ts13
8 files changed, 596 insertions, 6 deletions
diff --git a/src/renderer/src/App.css b/src/renderer/src/App.css
index cdf4bce..7f7b24c 100644
--- a/src/renderer/src/App.css
+++ b/src/renderer/src/App.css
@@ -349,6 +349,115 @@ html, body, #root {
min-height: 0;
}
+/* ── Search Panel ────────────────────────────────────────────── */
+
+.search-panel {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ background: var(--bg-secondary);
+}
+
+.search-panel-header {
+ padding: 8px;
+ border-bottom: 1px solid var(--border);
+}
+
+.search-input-row {
+ display: flex;
+ gap: 4px;
+}
+
+.search-input {
+ flex: 1;
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ padding: 5px 8px;
+ outline: none;
+}
+
+.search-input:focus {
+ border-color: var(--accent-blue);
+}
+
+.search-case-btn {
+ background: var(--bg-hover);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-muted);
+ font-size: 11px;
+ font-weight: 600;
+ padding: 2px 6px;
+ cursor: pointer;
+}
+
+.search-case-btn.active {
+ background: var(--accent-blue);
+ color: white;
+ border-color: var(--accent-blue);
+}
+
+.search-status {
+ font-size: 11px;
+ color: var(--text-muted);
+ margin-top: 4px;
+ padding: 0 2px;
+}
+
+.search-results {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+}
+
+.search-file-group {
+ border-bottom: 1px solid var(--border);
+}
+
+.search-file-name {
+ padding: 4px 8px;
+ font-size: 11px;
+ font-weight: 600;
+ color: var(--accent);
+ background: var(--bg-tertiary);
+ position: sticky;
+ top: 0;
+ z-index: 1;
+}
+
+.search-result-item {
+ display: flex;
+ gap: 8px;
+ padding: 3px 8px 3px 16px;
+ font-size: 12px;
+ font-family: var(--font-mono);
+ cursor: pointer;
+ align-items: baseline;
+}
+
+.search-result-item:hover {
+ background: var(--bg-hover);
+}
+
+.search-result-line {
+ color: var(--text-muted);
+ font-size: 10px;
+ min-width: 28px;
+ text-align: right;
+ flex-shrink: 0;
+}
+
+.search-result-content {
+ color: var(--text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
/* ── File Tree ───────────────────────────────────────────────── */
.file-tree {
@@ -1271,6 +1380,61 @@ html, body, #root {
height: 100%;
}
+/* CodeMirror search panel — Cosmic Latte theme */
+.cm-editor .cm-search {
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+ padding: 4px 8px;
+ font-family: var(--font-sans);
+ font-size: 12px;
+ color: var(--text-primary);
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ align-items: center;
+}
+.cm-editor .cm-search input,
+.cm-editor .cm-search select {
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ padding: 2px 6px;
+ outline: none;
+}
+.cm-editor .cm-search input:focus {
+ border-color: var(--accent-blue);
+}
+.cm-editor .cm-search button {
+ background: var(--bg-hover);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-size: 11px;
+ padding: 2px 8px;
+ cursor: pointer;
+}
+.cm-editor .cm-search button:hover {
+ background: var(--bg-active);
+}
+.cm-editor .cm-search label {
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+.cm-editor .cm-search .cm-button {
+ background: var(--bg-hover);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+}
+.cm-editor .cm-searchMatch {
+ background: rgba(184, 134, 11, 0.3);
+}
+.cm-editor .cm-searchMatch-selected {
+ background: rgba(184, 134, 11, 0.6);
+}
+
/* ── PDF Viewer ──────────────────────────────────────────────── */
.pdf-panel {
@@ -1328,6 +1492,38 @@ html, body, #root {
text-align: center;
}
+.pdf-search-bar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ background: var(--bg-secondary);
+ border-bottom: 1px solid var(--border);
+}
+
+.pdf-search-input {
+ flex: 1;
+ background: var(--bg-primary);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ color: var(--text-primary);
+ font-family: var(--font-sans);
+ font-size: 12px;
+ padding: 3px 8px;
+ outline: none;
+}
+
+.pdf-search-input:focus {
+ border-color: var(--accent-blue);
+}
+
+.pdf-search-count {
+ font-size: 11px;
+ color: var(--text-muted);
+ min-width: 60px;
+ text-align: center;
+}
+
.pdf-container {
flex: 1;
overflow: auto;
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 6992a17..1aedbd6 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -14,6 +14,7 @@ import PdfViewer from './components/PdfViewer'
import Terminal from './components/Terminal'
import ReviewPanel from './components/ReviewPanel'
import ChatPanel from './components/ChatPanel'
+import SearchPanel from './components/SearchPanel'
import StatusBar from './components/StatusBar'
import type { OverleafDocSync } from './ot/overleafSync'
import { colorForUser, type RemoteCursor } from './extensions/remoteCursors'
@@ -49,6 +50,7 @@ export default function App() {
showFileTree,
showReviewPanel,
showChat,
+ showSearch,
} = useAppStore()
const [checkingSession, setCheckingSession] = useState(true)
@@ -206,12 +208,39 @@ export default function App() {
e.preventDefault()
useAppStore.getState().toggleTerminal()
}
+ if (e.key === 'Enter') {
+ e.preventDefault()
+ handleForwardSearch()
+ }
+ if (e.key === 'f' && e.shiftKey) {
+ e.preventDefault()
+ useAppStore.getState().toggleSearch()
+ }
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [screen])
+ const handleForwardSearch = async () => {
+ const state = useAppStore.getState()
+ const activeTab = state.activeTab
+ if (!activeTab) return
+ // Get cursor line from the active editor view
+ const docId = state.pathDocMap[activeTab]
+ const sync = docId ? activeDocSyncs.get(docId) : null
+ const view = sync?.editorView
+ if (!view) return
+ const cursor = view.state.selection.main.head
+ const line = view.state.doc.lineAt(cursor)
+ const lineNum = line.number
+ const col = cursor - line.from
+ const result = await window.api.synctexView(lineNum, col, activeTab)
+ if (result) {
+ state.setPendingPdfGoTo({ page: result.page, y: result.v })
+ }
+ }
+
const handleCompile = async () => {
const state = useAppStore.getState()
const mainDoc = state.mainDocument || state.overleafProject?.rootDocId
@@ -383,13 +412,17 @@ export default function App() {
<Toolbar onCompile={handleCompile} onLocalCompile={handleLocalCompile} onBack={handleBackToProjects} />
<div className="main-content">
<PanelGroup direction="horizontal">
- {showFileTree && (
+ {(showFileTree || showSearch) && (
<>
<Panel defaultSize={18} minSize={12} maxSize={35}>
- <div className="sidebar-panel">
- <FileTree />
- <OutlineView />
- </div>
+ {showSearch ? (
+ <SearchPanel />
+ ) : (
+ <div className="sidebar-panel">
+ <FileTree />
+ <OutlineView />
+ </div>
+ )}
</Panel>
<PanelResizeHandle className="resize-handle resize-handle-h" />
</>
diff --git a/src/renderer/src/components/Editor.tsx b/src/renderer/src/components/Editor.tsx
index 91b944c..d3a1e3f 100644
--- a/src/renderer/src/components/Editor.tsx
+++ b/src/renderer/src/components/Editor.tsx
@@ -7,7 +7,7 @@ import { EditorState } from '@codemirror/state'
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { bracketMatching, foldGutter, indentOnInput, StreamLanguage, syntaxHighlighting, HighlightStyle } from '@codemirror/language'
import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete'
-import { searchKeymap, highlightSelectionMatches } from '@codemirror/search'
+import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search'
import { stex } from '@codemirror/legacy-modes/mode/stex'
import { tags } from '@lezer/highlight'
import { useAppStore } from '../stores/appStore'
@@ -239,6 +239,7 @@ export default function Editor() {
foldGutter(),
history(),
highlightSelectionMatches(),
+ search({ top: true }),
StreamLanguage.define(stex),
syntaxHighlighting(cosmicLatteHighlight),
keymap.of([
diff --git a/src/renderer/src/components/PdfViewer.tsx b/src/renderer/src/components/PdfViewer.tsx
index 36c5f80..ef37b5b 100644
--- a/src/renderer/src/components/PdfViewer.tsx
+++ b/src/renderer/src/components/PdfViewer.tsx
@@ -128,6 +128,7 @@ type LogFilter = 'all' | 'error' | 'warning'
export default function PdfViewer() {
const { pdfPath, compileLog, compiling } = useAppStore()
+ const pendingPdfGoTo = useAppStore((s) => s.pendingPdfGoTo)
const containerRef = useRef<HTMLDivElement>(null) // scroll viewport
const wrapperRef = useRef<HTMLDivElement>(null) // inner wrapper (CSS transform target)
const [scale, setScale] = useState(1.0)
@@ -143,6 +144,15 @@ export default function PdfViewer() {
const prevCompilingRef = useRef(false)
const renderingRef = useRef(false)
+ // PDF text search state
+ const [pdfSearchQuery, setPdfSearchQuery] = useState('')
+ const [pdfSearchVisible, setPdfSearchVisible] = useState(false)
+ const [pdfSearchResults, setPdfSearchResults] = useState<Array<{ page: number; index: number }>>([])
+ const [pdfSearchCurrent, setPdfSearchCurrent] = useState(-1)
+ const pdfTextCache = useRef<Map<number, string>>(new Map())
+ const pdfSearchInputRef = useRef<HTMLInputElement>(null)
+ const pdfDocRef = useRef<any>(null)
+
// Parse and sort log entries (errors first, then warnings)
const logEntries = compileLog ? parseCompileLog(compileLog) : []
const levelOrder = { error: 0, warning: 1, info: 2 }
@@ -294,6 +304,8 @@ export default function PdfViewer() {
const arrayBuffer = await window.api.readBinary(pdfPath)
const data = new Uint8Array(arrayBuffer)
const pdf = await pdfjsLib.getDocument({ data }).promise
+ pdfDocRef.current = pdf
+ pdfTextCache.current.clear()
setNumPages(pdf.numPages)
const wrapper = wrapperRef.current
@@ -407,6 +419,153 @@ export default function PdfViewer() {
renderPdf()
}, [renderPdf])
+ // PDF search: Cmd+F when on PDF tab opens search bar
+ useEffect(() => {
+ const handler = (e: KeyboardEvent) => {
+ if ((e.metaKey || e.ctrlKey) && e.key === 'f' && tab === 'pdf') {
+ // Only if focus is in the PDF area (not editor)
+ const active = document.activeElement
+ const inEditor = active?.closest('.cm-editor')
+ if (inEditor) return
+ e.preventDefault()
+ setPdfSearchVisible(true)
+ setTimeout(() => pdfSearchInputRef.current?.focus(), 50)
+ }
+ if (e.key === 'Escape' && pdfSearchVisible) {
+ setPdfSearchVisible(false)
+ setPdfSearchQuery('')
+ setPdfSearchResults([])
+ setPdfSearchCurrent(-1)
+ }
+ }
+ window.addEventListener('keydown', handler)
+ return () => window.removeEventListener('keydown', handler)
+ }, [tab, pdfSearchVisible])
+
+ // PDF search: extract text and find matches
+ useEffect(() => {
+ if (!pdfSearchQuery.trim() || !pdfDocRef.current) {
+ setPdfSearchResults([])
+ setPdfSearchCurrent(-1)
+ return
+ }
+
+ const search = async () => {
+ const pdf = pdfDocRef.current
+ if (!pdf) return
+ const q = pdfSearchQuery.toLowerCase()
+ const results: Array<{ page: number; index: number }> = []
+
+ for (let i = 1; i <= pdf.numPages; i++) {
+ let text = pdfTextCache.current.get(i)
+ if (!text) {
+ try {
+ const page = await pdf.getPage(i)
+ const tc = await page.getTextContent()
+ text = tc.items.map((item: any) => item.str).join(' ')
+ pdfTextCache.current.set(i, text)
+ } catch {
+ continue
+ }
+ }
+ const lower = text.toLowerCase()
+ let pos = 0
+ while ((pos = lower.indexOf(q, pos)) !== -1) {
+ results.push({ page: i, index: pos })
+ pos += q.length
+ if (results.length >= 500) break
+ }
+ if (results.length >= 500) break
+ }
+
+ setPdfSearchResults(results)
+ setPdfSearchCurrent(results.length > 0 ? 0 : -1)
+ }
+
+ const timer = setTimeout(search, 200)
+ return () => clearTimeout(timer)
+ }, [pdfSearchQuery])
+
+ // PDF search: scroll to current result
+ useEffect(() => {
+ if (pdfSearchCurrent < 0 || pdfSearchCurrent >= pdfSearchResults.length) return
+ const result = pdfSearchResults[pdfSearchCurrent]
+ if (!result || !wrapperRef.current || !containerRef.current) return
+
+ const canvases = wrapperRef.current.querySelectorAll('canvas.pdf-page')
+ const canvas = canvases[result.page - 1] as HTMLCanvasElement | undefined
+ if (!canvas) return
+
+ const container = containerRef.current
+ const containerRect = container.getBoundingClientRect()
+ const canvasRect = canvas.getBoundingClientRect()
+ const offsetInContainer = canvasRect.top - containerRect.top + container.scrollTop
+
+ // Scroll to roughly the right area of the page
+ container.scrollTo({ top: Math.max(0, offsetInContainer - containerRect.height / 3), behavior: 'smooth' })
+ }, [pdfSearchCurrent, pdfSearchResults])
+
+ const pdfSearchNext = () => {
+ if (pdfSearchResults.length === 0) return
+ setPdfSearchCurrent((c) => (c + 1) % pdfSearchResults.length)
+ }
+
+ const pdfSearchPrev = () => {
+ if (pdfSearchResults.length === 0) return
+ setPdfSearchCurrent((c) => (c - 1 + pdfSearchResults.length) % pdfSearchResults.length)
+ }
+
+ // Handle forward SyncTeX navigation (scroll PDF to page+position)
+ useEffect(() => {
+ if (!pendingPdfGoTo || !wrapperRef.current || !containerRef.current) return
+ const { page, y } = pendingPdfGoTo
+ useAppStore.getState().setPendingPdfGoTo(null)
+
+ // Switch to PDF tab
+ setTab('pdf')
+
+ // Wait a frame for tab switch / render
+ requestAnimationFrame(() => {
+ const wrapper = wrapperRef.current
+ const container = containerRef.current
+ if (!wrapper || !container) return
+
+ const canvases = wrapper.querySelectorAll('canvas.pdf-page')
+ const targetCanvas = canvases[page - 1] as HTMLCanvasElement | undefined
+ if (!targetCanvas) return
+
+ const vpInfo = pageViewportsRef.current.get(page)
+ if (!vpInfo) return
+
+ // y is in PDF points from top; convert to fraction of page height
+ const yFrac = y / vpInfo.height
+ const cssScale = scaleRef.current / renderedScaleRef.current
+
+ // Calculate scroll position
+ const canvasRect = targetCanvas.getBoundingClientRect()
+ const containerRect = container.getBoundingClientRect()
+ const offsetInContainer = canvasRect.top - containerRect.top + container.scrollTop
+ const targetY = offsetInContainer + yFrac * canvasRect.height - containerRect.height / 3
+
+ container.scrollTo({ top: Math.max(0, targetY), behavior: 'smooth' })
+
+ // Flash highlight on the line
+ const highlight = document.createElement('div')
+ highlight.style.cssText = `
+ position: absolute; left: 0; right: 0;
+ height: ${14 * cssScale}px;
+ top: ${canvasRect.top - containerRect.top + container.scrollTop + yFrac * canvasRect.height}px;
+ background: rgba(74, 111, 165, 0.3);
+ pointer-events: none; z-index: 10;
+ transition: opacity 1.5s ease-out;
+ `
+ container.style.position = 'relative'
+ container.appendChild(highlight)
+ setTimeout(() => { highlight.style.opacity = '0' }, 500)
+ setTimeout(() => { highlight.remove() }, 2000)
+ })
+ }, [pendingPdfGoTo])
+
// Empty state
if (!pdfPath && !compileLog) {
return (
@@ -443,6 +602,17 @@ export default function PdfViewer() {
<span className="pdf-scale">{Math.round(scale * 100)}%</span>
<button className="toolbar-btn" onClick={() => setScale((s) => Math.min(3, s + 0.25))}>+</button>
<button className="toolbar-btn" onClick={() => setScale(1.0)}>Fit</button>
+ <button
+ className={`toolbar-btn ${pdfSearchVisible ? 'active' : ''}`}
+ onClick={() => {
+ setPdfSearchVisible(!pdfSearchVisible)
+ if (!pdfSearchVisible) setTimeout(() => pdfSearchInputRef.current?.focus(), 50)
+ else { setPdfSearchQuery(''); setPdfSearchResults([]); setPdfSearchCurrent(-1) }
+ }}
+ title="Search in PDF (Cmd+F)"
+ >
+ &#x2315;
+ </button>
{pdfPath && (
<button className="toolbar-btn" onClick={() => window.api.savePdf(pdfPath)} title="Download PDF">
@@ -465,6 +635,43 @@ export default function PdfViewer() {
)}
</div>
+ {/* PDF search bar */}
+ {pdfSearchVisible && tab === 'pdf' && (
+ <div className="pdf-search-bar">
+ <input
+ ref={pdfSearchInputRef}
+ className="pdf-search-input"
+ type="text"
+ placeholder="Search in PDF..."
+ value={pdfSearchQuery}
+ onChange={(e) => setPdfSearchQuery(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' && e.shiftKey) pdfSearchPrev()
+ else if (e.key === 'Enter') pdfSearchNext()
+ else if (e.key === 'Escape') {
+ setPdfSearchVisible(false)
+ setPdfSearchQuery('')
+ setPdfSearchResults([])
+ setPdfSearchCurrent(-1)
+ }
+ }}
+ />
+ <span className="pdf-search-count">
+ {pdfSearchResults.length > 0
+ ? `${pdfSearchCurrent + 1}/${pdfSearchResults.length}`
+ : pdfSearchQuery ? 'No results' : ''}
+ </span>
+ <button className="toolbar-btn" onClick={pdfSearchPrev} disabled={pdfSearchResults.length === 0} title="Previous (Shift+Enter)">&#x2191;</button>
+ <button className="toolbar-btn" onClick={pdfSearchNext} disabled={pdfSearchResults.length === 0} title="Next (Enter)">&#x2193;</button>
+ <button className="toolbar-btn" onClick={() => {
+ setPdfSearchVisible(false)
+ setPdfSearchQuery('')
+ setPdfSearchResults([])
+ setPdfSearchCurrent(-1)
+ }} title="Close">&#x2715;</button>
+ </div>
+ )}
+
{/* PDF view — always mounted, hidden when log is shown */}
<div className="pdf-container" ref={containerRef} style={{ display: tab === 'pdf' ? undefined : 'none' }}>
<div className="pdf-wrapper" ref={wrapperRef} />
diff --git a/src/renderer/src/components/SearchPanel.tsx b/src/renderer/src/components/SearchPanel.tsx
new file mode 100644
index 0000000..2655d41
--- /dev/null
+++ b/src/renderer/src/components/SearchPanel.tsx
@@ -0,0 +1,132 @@
+// Copyright (c) 2026 Yuren Hao
+// Licensed under AGPL-3.0 - see LICENSE file
+
+import { useState, useRef, useEffect, useCallback } from 'react'
+import { useAppStore } from '../stores/appStore'
+
+interface SearchResult {
+ file: string
+ line: number
+ content: string
+ col: number
+}
+
+export default function SearchPanel() {
+ const [query, setQuery] = useState('')
+ const [caseSensitive, setCaseSensitive] = useState(false)
+ const [results, setResults] = useState<SearchResult[]>([])
+ const [searching, setSearching] = useState(false)
+ const inputRef = useRef<HTMLInputElement>(null)
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
+
+ useEffect(() => {
+ inputRef.current?.focus()
+ }, [])
+
+ const doSearch = useCallback(async (q: string, cs: boolean) => {
+ if (!q.trim()) {
+ setResults([])
+ return
+ }
+ setSearching(true)
+ const res = await window.api.searchFiles(q, cs)
+ setResults(res)
+ setSearching(false)
+ }, [])
+
+ const handleInputChange = (val: string) => {
+ setQuery(val)
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ debounceRef.current = setTimeout(() => doSearch(val, caseSensitive), 300)
+ }
+
+ const handleCaseSensitiveToggle = () => {
+ const newCs = !caseSensitive
+ setCaseSensitive(newCs)
+ if (query.trim()) doSearch(query, newCs)
+ }
+
+ const handleResultClick = async (result: SearchResult) => {
+ const store = useAppStore.getState()
+
+ if (store.fileContents[result.file]) {
+ store.openFile(result.file, result.file.split('/').pop() || result.file)
+ store.setPendingGoTo({ file: result.file, line: result.line })
+ return
+ }
+
+ const docId = store.pathDocMap[result.file]
+ if (docId) {
+ try {
+ const joinResult = await window.api.otJoinDoc(docId)
+ if (joinResult.success && joinResult.content !== undefined) {
+ useAppStore.getState().setFileContent(result.file, joinResult.content)
+ if (joinResult.version !== undefined) {
+ useAppStore.getState().setDocVersion(docId, joinResult.version)
+ }
+ useAppStore.getState().openFile(result.file, result.file.split('/').pop() || result.file)
+ useAppStore.getState().setPendingGoTo({ file: result.file, line: result.line })
+ }
+ } catch { /* failed to join doc */ }
+ }
+ }
+
+ // Group results by file
+ const grouped = new Map<string, SearchResult[]>()
+ for (const r of results) {
+ const list = grouped.get(r.file) || []
+ list.push(r)
+ grouped.set(r.file, list)
+ }
+
+ return (
+ <div className="search-panel">
+ <div className="search-panel-header">
+ <div className="search-input-row">
+ <input
+ ref={inputRef}
+ className="search-input"
+ type="text"
+ placeholder="Search in files..."
+ value={query}
+ onChange={(e) => handleInputChange(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Escape') useAppStore.getState().toggleSearch()
+ if (e.key === 'Enter') doSearch(query, caseSensitive)
+ }}
+ />
+ <button
+ className={`search-case-btn ${caseSensitive ? 'active' : ''}`}
+ onClick={handleCaseSensitiveToggle}
+ title="Case sensitive"
+ >
+ Aa
+ </button>
+ </div>
+ {query && (
+ <div className="search-status">
+ {searching ? 'Searching...' : `${results.length} result${results.length !== 1 ? 's' : ''} in ${grouped.size} file${grouped.size !== 1 ? 's' : ''}`}
+ {results.length >= 200 && ' (limited)'}
+ </div>
+ )}
+ </div>
+ <div className="search-results">
+ {[...grouped.entries()].map(([file, matches]) => (
+ <div key={file} className="search-file-group">
+ <div className="search-file-name">{file}</div>
+ {matches.map((r, i) => (
+ <div
+ key={i}
+ className="search-result-item"
+ onClick={() => handleResultClick(r)}
+ >
+ <span className="search-result-line">{r.line}</span>
+ <span className="search-result-content">{r.content}</span>
+ </div>
+ ))}
+ </div>
+ ))}
+ </div>
+ </div>
+ )
+}
diff --git a/src/renderer/src/components/Toolbar.tsx b/src/renderer/src/components/Toolbar.tsx
index 94ddb5e..0cee7e1 100644
--- a/src/renderer/src/components/Toolbar.tsx
+++ b/src/renderer/src/components/Toolbar.tsx
@@ -14,6 +14,7 @@ export default function Toolbar({ onCompile, onLocalCompile, onBack }: ToolbarPr
const {
compiling, toggleTerminal, toggleFileTree, showTerminal, showFileTree,
showReviewPanel, toggleReviewPanel, showChat, toggleChat,
+ showSearch, toggleSearch,
connectionState, overleafProject, onlineUsersCount
} = useAppStore()
@@ -48,6 +49,9 @@ export default function Toolbar({ onCompile, onLocalCompile, onBack }: ToolbarPr
<button className="toolbar-btn" onClick={toggleFileTree} title="Toggle file tree">
{showFileTree ? '◧' : '☰'}
</button>
+ <button className={`toolbar-btn ${showSearch ? 'active' : ''}`} onClick={toggleSearch} title="Search in files (Cmd+Shift+F)">
+ Search
+ </button>
<span className="project-name">
<span className={`connection-dot ${connectionDot}`} title={connectionState} />
{projectName}
diff --git a/src/renderer/src/ot/overleafSync.ts b/src/renderer/src/ot/overleafSync.ts
index e288c56..e9cb749 100644
--- a/src/renderer/src/ot/overleafSync.ts
+++ b/src/renderer/src/ot/overleafSync.ts
@@ -36,6 +36,10 @@ export class OverleafDocSync {
return this.otClient.version
}
+ get editorView(): EditorView | null {
+ return this.view
+ }
+
setView(view: EditorView) {
this.view = view
}
diff --git a/src/renderer/src/stores/appStore.ts b/src/renderer/src/stores/appStore.ts
index 9a1d441..6b3b6cd 100644
--- a/src/renderer/src/stores/appStore.ts
+++ b/src/renderer/src/stores/appStore.ts
@@ -94,6 +94,10 @@ interface AppState {
syncDir: string
setSyncDir: (dir: string) => void
+ // Search panel
+ showSearch: boolean
+ toggleSearch: () => void
+
// Review panel
showReviewPanel: boolean
toggleReviewPanel: () => void
@@ -121,6 +125,8 @@ interface AppState {
// Navigation
pendingGoTo: { file: string; line?: number; pos?: number; highlight?: string } | null
setPendingGoTo: (g: { file: string; line?: number; pos?: number; highlight?: string } | null) => void
+ pendingPdfGoTo: { page: number; y: number } | null
+ setPendingPdfGoTo: (g: { page: number; y: number } | null) => void
// Status
statusMessage: string
@@ -210,6 +216,9 @@ export const useAppStore = create<AppState>((set) => ({
syncDir: '',
setSyncDir: (dir) => set({ syncDir: dir }),
+ showSearch: false,
+ toggleSearch: () => set((s) => ({ showSearch: !s.showSearch })),
+
showReviewPanel: false,
toggleReviewPanel: () => set((s) => ({ showReviewPanel: !s.showReviewPanel })),
@@ -232,6 +241,8 @@ export const useAppStore = create<AppState>((set) => ({
pendingGoTo: null,
setPendingGoTo: (g) => set({ pendingGoTo: g }),
+ pendingPdfGoTo: null,
+ setPendingPdfGoTo: (g) => set({ pendingPdfGoTo: g }),
statusMessage: 'Ready',
setStatusMessage: (m) => set({ statusMessage: m }),
@@ -245,6 +256,7 @@ export const useAppStore = create<AppState>((set) => ({
pdfPath: null,
compileLog: '',
compiling: false,
+ showSearch: false,
overleafProjectId: null,
connectionState: 'disconnected',
docPathMap: {},
@@ -260,6 +272,7 @@ export const useAppStore = create<AppState>((set) => ({
hoveredThreadId: null,
focusedThreadId: null,
pendingGoTo: null,
+ pendingPdfGoTo: null,
statusMessage: 'Ready',
showChat: false,
onlineUsersCount: 0