global search

This commit is contained in:
2026-03-03 21:07:24 +10:00
parent 63842b8805
commit 636c0f378f
13 changed files with 733 additions and 10 deletions

View File

@@ -1,6 +1,6 @@
import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises' import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises'
import { join, dirname, basename } from 'path' import { join, dirname, basename } from 'path'
import type { FileNode, RevisionMeta } from '../renderer/types/editor' import type { FileNode, RevisionMeta, SearchMatch, SearchFileResult } from '../renderer/types/editor'
const DRAFT_ROOT = const DRAFT_ROOT =
process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft' process.env.DRAFT_PATH ?? '/Users/pori/WebstormProjects/hohoff/draft'
@@ -407,3 +407,85 @@ export async function moveFileOrDir(sourcePath: string, targetDirPath: string):
await fsRename(sourcePath, newPath) await fsRename(sourcePath, newPath)
return newPath return newPath
} }
// ─── Project search/replace ───────────────────────────────────────────────────
export interface SearchOptions {
caseSensitive: boolean
wholeWord: boolean
isRegex: boolean
}
function buildSearchRegex(query: string, opts: SearchOptions): RegExp {
let pattern = opts.isRegex ? query : query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
if (opts.wholeWord) pattern = `\\b${pattern}\\b`
const flags = opts.caseSensitive ? 'g' : 'gi'
return new RegExp(pattern, flags)
}
function searchFileContent(content: string, regex: RegExp, filePath: string, relativePath: string): SearchFileResult | null {
const lines = content.split('\n')
const matches: SearchMatch[] = []
for (let i = 0; i < lines.length; i++) {
const lineText = lines[i]
regex.lastIndex = 0
let m: RegExpExecArray | null
while ((m = regex.exec(lineText)) !== null) {
matches.push({
lineNumber: i + 1,
lineText,
matchStart: m.index,
matchEnd: m.index + m[0].length
})
if (!regex.global) break
}
}
if (matches.length === 0) return null
return { filePath, relativePath, matches }
}
export async function searchAcrossFiles(query: string, opts: SearchOptions): Promise<SearchFileResult[]> {
if (!query) return []
const regex = buildSearchRegex(query, opts)
const docs = await readAllDraftFiles()
const results: SearchFileResult[] = []
for (const doc of docs) {
const result = searchFileContent(doc.content, regex, doc.path, doc.relativePath)
if (result) results.push(result)
}
// Also search the Story Bible
const bibleContent = await readStoryBibleFile()
if (bibleContent !== null) {
const prefix = DRAFT_ROOT + '/'
const rel = (STORY_BIBLE_PATH.startsWith(prefix) ? STORY_BIBLE_PATH.slice(prefix.length) : STORY_BIBLE_PATH).replace(/\.md$/, '')
const result = searchFileContent(bibleContent, regex, STORY_BIBLE_PATH, rel)
if (result) results.push(result)
}
return results
}
export async function replaceInFiles(
query: string,
replacement: string,
opts: SearchOptions,
filePaths: string[]
): Promise<string[]> {
if (!query) return []
const regex = buildSearchRegex(query, opts)
const modified: string[] = []
for (const filePath of filePaths) {
assertInDraftRoot(filePath)
const original = await readFile(filePath, 'utf-8')
regex.lastIndex = 0
const updated = original.replace(regex, replacement)
if (updated !== original) {
await saveRevision(filePath, original)
await writeFile(filePath, updated, 'utf-8')
modified.push(filePath)
}
}
return modified
}

View File

@@ -85,6 +85,11 @@ function buildAppMenu(win: BrowserWindow): void {
label: 'Find / Replace', label: 'Find / Replace',
accelerator: 'CmdOrCtrl+F', accelerator: 'CmdOrCtrl+F',
click: () => send(win, 'find') click: () => send(win, 'find')
},
{
label: 'Project Find / Replace',
accelerator: 'Shift+CmdOrCtrl+F',
click: () => send(win, 'projectSearch')
} }
] ]
}, },

View File

@@ -1,7 +1,8 @@
import { ipcMain, dialog, BrowserWindow } from 'electron' import { ipcMain, dialog, BrowserWindow } from 'electron'
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { extname, basename } from 'path' import { extname, basename } from 'path'
import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile } from './fileSystem' import { listDraftFiles, readMarkdownFile, writeMarkdownFile, getProjectWordCount, saveOrderFile, readSession, writeSession, saveRevision, listRevisions, loadRevision, deleteRevision, renameFileOrDir, deleteFileOrDir, createMarkdownFile, createSubdirectory, moveFileOrDir, readStoryBibleFile, openStoryBibleFile, writeStoryBibleFile, searchAcrossFiles, replaceInFiles } from './fileSystem'
import type { SearchOptions } from './fileSystem'
import { streamMessage } from './aiService' import { streamMessage } from './aiService'
import type { AIPayload, Attachment } from '../renderer/types/editor' import type { AIPayload, Attachment } from '../renderer/types/editor'
@@ -121,6 +122,14 @@ export function registerIpcHandlers(): void {
return attachments return attachments
}) })
ipcMain.handle('fs:search', async (_event, query: string, opts: SearchOptions) => {
return await searchAcrossFiles(query, opts)
})
ipcMain.handle('fs:replace', async (_event, query: string, replacement: string, opts: SearchOptions, filePaths: string[]) => {
return await replaceInFiles(query, replacement, opts, filePaths)
})
ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => { ipcMain.handle('ai:streamMessage', async (event, payload: AIPayload) => {
try { try {
const storyBibleContent = (await readStoryBibleFile()) ?? undefined const storyBibleContent = (await readStoryBibleFile()) ?? undefined

View File

@@ -1,5 +1,11 @@
import { contextBridge, ipcRenderer } from 'electron' import { contextBridge, ipcRenderer } from 'electron'
import type { FileNode, AIPayload, RevisionMeta, Attachment } from '../renderer/types/editor' import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult } from '../renderer/types/editor'
interface SearchOptions {
caseSensitive: boolean
wholeWord: boolean
isRegex: boolean
}
contextBridge.exposeInMainWorld('api', { contextBridge.exposeInMainWorld('api', {
listFiles: (): Promise<FileNode[]> => ipcRenderer.invoke('fs:listFiles'), listFiles: (): Promise<FileNode[]> => ipcRenderer.invoke('fs:listFiles'),
@@ -96,5 +102,11 @@ contextBridge.exposeInMainWorld('api', {
const listener = (_: Electron.IpcRendererEvent, action: string): void => handler(action) const listener = (_: Electron.IpcRendererEvent, action: string): void => handler(action)
ipcRenderer.on('menu:action', listener) ipcRenderer.on('menu:action', listener)
return () => ipcRenderer.removeListener('menu:action', listener) return () => ipcRenderer.removeListener('menu:action', listener)
} },
searchFiles: (query: string, options: SearchOptions): Promise<SearchFileResult[]> =>
ipcRenderer.invoke('fs:search', query, options),
replaceInFiles: (query: string, replacement: string, options: SearchOptions, filePaths: string[]): Promise<string[]> =>
ipcRenderer.invoke('fs:replace', query, replacement, options, filePaths),
}) })

View File

@@ -5,13 +5,15 @@ import { DocumentOutline } from './components/Editor/DocumentOutline'
import { ChatPanel } from './components/AIChat/ChatPanel' import { ChatPanel } from './components/AIChat/ChatPanel'
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar' import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
import { RevisionPanel } from './components/Revisions/RevisionPanel' import { RevisionPanel } from './components/Revisions/RevisionPanel'
import { ProjectSearchModal } from './components/Search/ProjectSearchModal'
import { useEditorStore } from './store/editorStore' import { useEditorStore } from './store/editorStore'
import './styles/app.css' import './styles/app.css'
export default function App(): JSX.Element { export default function App(): JSX.Element {
const { const {
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme, setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
loadSession, revisionPanelOpen, toggleRevisionPanel, fontSize, setFontSize loadSession, revisionPanelOpen, toggleRevisionPanel, fontSize, setFontSize,
openProjectSearch
} = useEditorStore() } = useEditorStore()
const [sidebarOpen, setSidebarOpen] = useState( const [sidebarOpen, setSidebarOpen] = useState(
() => localStorage.getItem('sidebarOpen') !== 'false' () => localStorage.getItem('sidebarOpen') !== 'false'
@@ -50,11 +52,13 @@ export default function App(): JSX.Element {
setFontSize(fontSize - 1) setFontSize(fontSize - 1)
} else if (action === 'fontReset') { } else if (action === 'fontReset') {
setFontSize(15) setFontSize(15)
} else if (action === 'projectSearch') {
openProjectSearch()
} }
}) })
}, [activeFilePath, isDirty, activeFileContent, fontSize]) }, [activeFilePath, isDirty, activeFileContent, fontSize])
// Handle Cmd+S / Ctrl+S // Handle Cmd+S / Ctrl+S and Cmd+Shift+F / Ctrl+Shift+F
useEffect(() => { useEffect(() => {
const handler = async (e: KeyboardEvent): Promise<void> => { const handler = async (e: KeyboardEvent): Promise<void> => {
if ((e.metaKey || e.ctrlKey) && e.key === 's') { if ((e.metaKey || e.ctrlKey) && e.key === 's') {
@@ -64,6 +68,9 @@ export default function App(): JSX.Element {
await window.api.saveRevision(activeFilePath, activeFileContent) await window.api.saveRevision(activeFilePath, activeFileContent)
markSaved() markSaved()
} }
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'f') {
e.preventDefault()
openProjectSearch()
} }
} }
window.addEventListener('keydown', handler) window.addEventListener('keydown', handler)
@@ -124,6 +131,7 @@ export default function App(): JSX.Element {
<aside className="chat-area"> <aside className="chat-area">
<ChatPanel /> <ChatPanel />
</aside> </aside>
<ProjectSearchModal />
</div> </div>
) )
} }

View File

@@ -439,7 +439,7 @@ export function MarkdownEditor(): JSX.Element {
const [hasSelection, setHasSelection] = useState(false) const [hasSelection, setHasSelection] = useState(false)
const [pendingComment, setPendingComment] = useState<{ from: number; to: number; text: string } | null>(null) const [pendingComment, setPendingComment] = useState<{ from: number; to: number; text: string } | null>(null)
const [commentDraft, setCommentDraft] = useState('') const [commentDraft, setCommentDraft] = useState('')
const { activeFilePath, activeFileContent, setContent, annotations, fontSize, theme, scrollPositions } = useEditorStore() const { activeFilePath, activeFileContent, setContent, annotations, fontSize, theme, scrollPositions, pendingScrollToLine, clearPendingScrollToLine } = useEditorStore()
function saveComment(): void { function saveComment(): void {
if (!pendingComment || !commentDraft.trim()) return if (!pendingComment || !commentDraft.trim()) return
@@ -625,6 +625,18 @@ export function MarkdownEditor(): JSX.Element {
}) })
}, [annotations]) }, [annotations])
// Scroll to line requested by project search navigation
useEffect(() => {
if (pendingScrollToLine === null) return
const view = viewRef.current
if (!view) return
const lineCount = view.state.doc.lines
const lineNum = Math.max(1, Math.min(pendingScrollToLine, lineCount))
const line = view.state.doc.line(lineNum)
view.dispatch({ effects: EditorView.scrollIntoView(line.from, { y: 'center' }) })
clearPendingScrollToLine()
}, [pendingScrollToLine])
// Reconfigure theme when font size or colour theme changes // Reconfigure theme when font size or colour theme changes
useEffect(() => { useEffect(() => {
const view = viewRef.current const view = viewRef.current

View File

@@ -0,0 +1,281 @@
/* ─── Project Search Modal ───────────────────────────────────────── */
.ps-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
z-index: 1000;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 80px;
}
.ps-modal {
background: var(--sidebar-bg);
border: 1px solid var(--border);
border-radius: 6px;
width: 640px;
max-width: calc(100vw - 48px);
max-height: 70vh;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
overflow: hidden;
}
/* ─── Header inputs ──────────────────────────────────────────────── */
.ps-header {
padding: 12px 12px 10px;
border-bottom: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 6px;
}
.ps-row {
display: flex;
align-items: center;
gap: 6px;
}
.ps-input {
flex: 1;
background: var(--input-bg);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-primary);
font-size: 13px;
font-family: var(--font-sans);
padding: 5px 8px;
outline: none;
transition: border-color 0.15s;
}
.ps-input:focus {
border-color: var(--accent);
}
.ps-input::placeholder {
color: var(--text-muted);
}
/* Option toggle buttons (Aa / W / .*) */
.ps-opt {
background: none;
border: 1px solid var(--border);
border-radius: 3px;
color: var(--text-muted);
font-size: 11px;
font-family: var(--font-sans);
font-weight: 600;
padding: 4px 6px;
cursor: pointer;
line-height: 1;
transition: background 0.12s, color 0.12s, border-color 0.12s;
white-space: nowrap;
}
.ps-opt:hover {
color: var(--text-secondary);
border-color: var(--text-muted);
}
.ps-opt.active {
background: var(--active-bg);
border-color: var(--accent);
color: var(--accent);
}
/* Close button */
.ps-close {
background: none;
border: none;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 2px 4px;
border-radius: 3px;
line-height: 1;
transition: color 0.12s;
margin-left: 2px;
}
.ps-close:hover {
color: var(--text-primary);
}
/* Replace All button */
.ps-replace-btn {
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-sans);
padding: 4px 10px;
cursor: pointer;
white-space: nowrap;
transition: border-color 0.12s, color 0.12s;
}
.ps-replace-btn:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
.ps-replace-btn:disabled {
opacity: 0.4;
cursor: default;
}
/* ─── Results ────────────────────────────────────────────────────── */
.ps-results {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.ps-empty {
padding: 24px 16px;
color: var(--text-muted);
font-size: 12px;
font-family: var(--font-sans);
text-align: center;
}
/* File group */
.ps-file-group {
border-bottom: 1px solid var(--border);
}
.ps-file-group:last-child {
border-bottom: none;
}
.ps-file-header {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px 5px;
background: var(--toolbar-bg);
cursor: pointer;
user-select: none;
}
.ps-file-header:hover {
background: var(--hover-bg);
}
.ps-chevron {
color: var(--text-muted);
font-size: 9px;
width: 10px;
flex-shrink: 0;
}
.ps-file-name {
color: var(--text-secondary);
font-size: 12px;
font-family: var(--font-sans);
font-weight: 600;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ps-match-count {
color: var(--text-muted);
font-size: 11px;
font-family: var(--font-sans);
flex-shrink: 0;
}
.ps-file-replace-btn {
background: none;
border: 1px solid var(--border);
border-radius: 3px;
color: var(--text-muted);
font-size: 10px;
font-family: var(--font-sans);
padding: 2px 6px;
cursor: pointer;
flex-shrink: 0;
transition: border-color 0.12s, color 0.12s;
}
.ps-file-replace-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
/* Match rows */
.ps-matches {
display: flex;
flex-direction: column;
}
.ps-match-row {
display: flex;
align-items: baseline;
gap: 0;
padding: 3px 10px 3px 20px;
cursor: pointer;
font-size: 12px;
font-family: monospace;
transition: background 0.1s;
}
.ps-match-row:hover {
background: var(--hover-bg);
}
.ps-line-num {
color: var(--text-muted);
min-width: 36px;
flex-shrink: 0;
text-align: right;
padding-right: 10px;
font-size: 11px;
user-select: none;
}
.ps-line-text {
color: var(--text-secondary);
white-space: pre;
overflow: hidden;
text-overflow: ellipsis;
}
.ps-line-text mark {
background: rgba(167, 139, 95, 0.35);
color: var(--accent-hover);
border-radius: 2px;
padding: 0 1px;
}
/* ─── Footer ─────────────────────────────────────────────────────── */
.ps-footer {
border-top: 1px solid var(--border);
padding: 6px 12px;
display: flex;
align-items: center;
justify-content: space-between;
}
.ps-stats {
color: var(--text-muted);
font-size: 11px;
font-family: var(--font-sans);
}
.ps-error {
color: #c0624a;
font-size: 11px;
font-family: var(--font-sans);
}

View File

@@ -0,0 +1,275 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { useEditorStore } from '../../store/editorStore'
import type { SearchFileResult } from '../../types/editor'
import './ProjectSearch.css'
interface SearchOptions {
caseSensitive: boolean
wholeWord: boolean
isRegex: boolean
}
function escapeHtml(text: string): string {
return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function buildMatchLine(lineText: string, matchStart: number, matchEnd: number): string {
const before = escapeHtml(lineText.slice(0, matchStart))
const match = escapeHtml(lineText.slice(matchStart, matchEnd))
const after = escapeHtml(lineText.slice(matchEnd))
return `${before}<mark>${match}</mark>${after}`
}
interface FileGroupProps {
result: SearchFileResult
hasReplace: boolean
onMatchClick: (filePath: string, lineNumber: number) => void
onFileReplace: (filePath: string) => void
}
function FileGroup({ result, hasReplace, onMatchClick, onFileReplace }: FileGroupProps): JSX.Element {
const [collapsed, setCollapsed] = useState(false)
const matchWord = result.matches.length === 1 ? 'match' : 'matches'
return (
<div className="ps-file-group">
<div className="ps-file-header" onClick={() => setCollapsed((v) => !v)}>
<span className="ps-chevron">{collapsed ? '▶' : '▼'}</span>
<span className="ps-file-name" title={result.relativePath}>{result.relativePath}</span>
<span className="ps-match-count">{result.matches.length} {matchWord}</span>
{hasReplace && (
<button
className="ps-file-replace-btn"
onClick={(e) => { e.stopPropagation(); onFileReplace(result.filePath) }}
title={`Replace in ${result.relativePath}`}
>
Replace
</button>
)}
</div>
{!collapsed && (
<div className="ps-matches">
{result.matches.map((match, idx) => (
<div
key={idx}
className="ps-match-row"
onClick={() => onMatchClick(result.filePath, match.lineNumber)}
title={`Line ${match.lineNumber}: ${match.lineText.trim()}`}
>
<span className="ps-line-num">{match.lineNumber}</span>
<span
className="ps-line-text"
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: buildMatchLine(match.lineText, match.matchStart, match.matchEnd) }}
/>
</div>
))}
</div>
)}
</div>
)
}
export function ProjectSearchModal(): JSX.Element | null {
const { projectSearchOpen, closeProjectSearch, setActiveFile, scrollEditorToLine, activeFilePath } = useEditorStore()
const [query, setQuery] = useState('')
const [replaceValue, setReplaceValue] = useState('')
const [opts, setOpts] = useState<SearchOptions>({ caseSensitive: false, wholeWord: false, isRegex: false })
const [results, setResults] = useState<SearchFileResult[]>([])
const [regexError, setRegexError] = useState<string | null>(null)
const [isSearching, setIsSearching] = useState(false)
const searchInputRef = useRef<HTMLInputElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// Focus search input when modal opens
useEffect(() => {
if (projectSearchOpen) {
setTimeout(() => searchInputRef.current?.focus(), 30)
} else {
setResults([])
setRegexError(null)
}
}, [projectSearchOpen])
// Run search with debounce
const runSearch = useCallback((q: string, options: SearchOptions) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
if (!q.trim()) {
setResults([])
setRegexError(null)
return
}
debounceRef.current = setTimeout(async () => {
// Validate regex if enabled
if (options.isRegex) {
try {
new RegExp(q)
} catch (e) {
setRegexError(e instanceof Error ? e.message : 'Invalid regex')
setResults([])
return
}
}
setRegexError(null)
setIsSearching(true)
try {
const found = await window.api.searchFiles(q, options)
setResults(found)
} catch {
setResults([])
} finally {
setIsSearching(false)
}
}, 150)
}, [])
const handleQueryChange = (value: string): void => {
setQuery(value)
runSearch(value, opts)
}
const toggleOpt = (key: keyof SearchOptions): void => {
const next = { ...opts, [key]: !opts[key] }
setOpts(next)
runSearch(query, next)
}
const handleMatchClick = async (filePath: string, lineNumber: number): Promise<void> => {
closeProjectSearch()
if (filePath !== activeFilePath) {
const content = await window.api.readFile(filePath)
setActiveFile(filePath, content)
}
// Small delay to let the editor mount/settle before scrolling
setTimeout(() => scrollEditorToLine(lineNumber), 50)
}
const doReplace = async (filePaths: string[]): Promise<void> => {
if (!query.trim()) return
const modified = await window.api.replaceInFiles(query, replaceValue, opts, filePaths)
if (modified.length > 0) {
// Re-run search to refresh results
const found = await window.api.searchFiles(query, opts)
setResults(found)
// Reload active file if it was modified
if (activeFilePath && modified.includes(activeFilePath)) {
const content = await window.api.readFile(activeFilePath)
setActiveFile(activeFilePath, content)
}
}
}
const handleReplaceAll = (): void => {
const allPaths = results.map((r) => r.filePath)
doReplace(allPaths)
}
const handleFileReplace = (filePath: string): void => {
doReplace([filePath])
}
// Keyboard: Escape closes
useEffect(() => {
if (!projectSearchOpen) return
const handler = (e: KeyboardEvent): void => {
if (e.key === 'Escape') closeProjectSearch()
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [projectSearchOpen, closeProjectSearch])
if (!projectSearchOpen) return null
const totalMatches = results.reduce((sum, r) => sum + r.matches.length, 0)
const hasReplace = replaceValue !== '' || false // show replace buttons whenever replace field has content
return (
<div className="ps-backdrop" onClick={(e) => { if (e.target === e.currentTarget) closeProjectSearch() }}>
<div className="ps-modal">
<div className="ps-header">
{/* Search row */}
<div className="ps-row">
<input
ref={searchInputRef}
className="ps-input"
placeholder="Search across all files…"
value={query}
onChange={(e) => handleQueryChange(e.target.value)}
spellCheck={false}
/>
<button
className={`ps-opt${opts.caseSensitive ? ' active' : ''}`}
onClick={() => toggleOpt('caseSensitive')}
title="Match case"
>Aa</button>
<button
className={`ps-opt${opts.wholeWord ? ' active' : ''}`}
onClick={() => toggleOpt('wholeWord')}
title="Match whole word"
>W</button>
<button
className={`ps-opt${opts.isRegex ? ' active' : ''}`}
onClick={() => toggleOpt('isRegex')}
title="Use regular expression"
>.*</button>
<button className="ps-close" onClick={closeProjectSearch} title="Close (Esc)">×</button>
</div>
{/* Replace row */}
<div className="ps-row">
<input
className="ps-input"
placeholder="Replace with…"
value={replaceValue}
onChange={(e) => setReplaceValue(e.target.value)}
spellCheck={false}
/>
<button
className="ps-replace-btn"
onClick={handleReplaceAll}
disabled={results.length === 0 || !query.trim()}
title="Replace all matches across all files"
>
Replace All
</button>
</div>
</div>
<div className="ps-results">
{regexError ? (
<div className="ps-empty">Invalid regex: {regexError}</div>
) : isSearching ? (
<div className="ps-empty">Searching</div>
) : query.trim() && results.length === 0 ? (
<div className="ps-empty">No results</div>
) : results.length > 0 ? (
results.map((r) => (
<FileGroup
key={r.filePath}
result={r}
hasReplace={hasReplace}
onMatchClick={handleMatchClick}
onFileReplace={handleFileReplace}
/>
))
) : null}
</div>
<div className="ps-footer">
{regexError ? (
<span className="ps-error">Regex error</span>
) : (
<span className="ps-stats">
{query.trim() && !isSearching
? totalMatches > 0
? `${totalMatches} match${totalMatches === 1 ? '' : 'es'} in ${results.length} file${results.length === 1 ? '' : 's'}`
: ''
: ''}
</span>
)}
</div>
</div>
</div>
)
}

View File

@@ -85,6 +85,16 @@ interface EditorState {
outlineOpen: boolean outlineOpen: boolean
toggleOutline: () => void toggleOutline: () => void
// Project search
projectSearchOpen: boolean
openProjectSearch: () => void
closeProjectSearch: () => void
// Scroll-to-line (set when navigating to a search result)
pendingScrollToLine: number | null
scrollEditorToLine: (line: number) => void
clearPendingScrollToLine: () => void
// Session persistence // Session persistence
loadSession: () => Promise<void> loadSession: () => Promise<void>
} }
@@ -554,6 +564,14 @@ export const useEditorStore = create<EditorState>((set, get) => ({
}) })
}, },
projectSearchOpen: false,
openProjectSearch: () => set({ projectSearchOpen: true }),
closeProjectSearch: () => set({ projectSearchOpen: false }),
pendingScrollToLine: null,
scrollEditorToLine: (line) => set({ pendingScrollToLine: line }),
clearPendingScrollToLine: () => set({ pendingScrollToLine: null }),
loadSession: async () => { loadSession: async () => {
const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api
if (!api) return if (!api) return

View File

@@ -64,3 +64,16 @@ export interface RevisionMeta {
timestamp: number timestamp: number
wordCount: number wordCount: number
} }
export interface SearchMatch {
lineNumber: number // 1-based
lineText: string
matchStart: number // offset within lineText
matchEnd: number
}
export interface SearchFileResult {
filePath: string
relativePath: string
matches: SearchMatch[]
}

View File

@@ -1,4 +1,10 @@
import type { FileNode, AIPayload, RevisionMeta, Attachment } from './editor' import type { FileNode, AIPayload, RevisionMeta, Attachment, SearchFileResult } from './editor'
interface SearchOptions {
caseSensitive: boolean
wholeWord: boolean
isRegex: boolean
}
declare global { declare global {
interface Window { interface Window {
@@ -26,6 +32,8 @@ declare global {
openStoryBible: () => Promise<{ path: string; content: string }> openStoryBible: () => Promise<{ path: string; content: string }>
writeStoryBible: (content: string) => Promise<string> writeStoryBible: (content: string) => Promise<string>
onMenuAction: (handler: (action: string) => void) => () => void onMenuAction: (handler: (action: string) => void) => () => void
searchFiles: (query: string, options: SearchOptions) => Promise<SearchFileResult[]>
replaceInFiles: (query: string, replacement: string, options: SearchOptions, filePaths: string[]) => Promise<string[]>
} }
} }
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long