✨ global search
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { readdir, readFile, writeFile, mkdir, unlink, rename as fsRename, rm } from 'fs/promises'
|
||||
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 =
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -85,6 +85,11 @@ function buildAppMenu(win: BrowserWindow): void {
|
||||
label: 'Find / Replace',
|
||||
accelerator: 'CmdOrCtrl+F',
|
||||
click: () => send(win, 'find')
|
||||
},
|
||||
{
|
||||
label: 'Project Find / Replace',
|
||||
accelerator: 'Shift+CmdOrCtrl+F',
|
||||
click: () => send(win, 'projectSearch')
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ipcMain, dialog, BrowserWindow } from 'electron'
|
||||
import { readFileSync } from 'fs'
|
||||
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 type { AIPayload, Attachment } from '../renderer/types/editor'
|
||||
|
||||
@@ -121,6 +122,14 @@ export function registerIpcHandlers(): void {
|
||||
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) => {
|
||||
try {
|
||||
const storyBibleContent = (await readStoryBibleFile()) ?? undefined
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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', {
|
||||
listFiles: (): Promise<FileNode[]> => ipcRenderer.invoke('fs:listFiles'),
|
||||
@@ -96,5 +102,11 @@ contextBridge.exposeInMainWorld('api', {
|
||||
const listener = (_: Electron.IpcRendererEvent, action: string): void => handler(action)
|
||||
ipcRenderer.on('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),
|
||||
})
|
||||
|
||||
@@ -5,13 +5,15 @@ import { DocumentOutline } from './components/Editor/DocumentOutline'
|
||||
import { ChatPanel } from './components/AIChat/ChatPanel'
|
||||
import { AnalysisToolbar } from './components/Toolbar/AnalysisToolbar'
|
||||
import { RevisionPanel } from './components/Revisions/RevisionPanel'
|
||||
import { ProjectSearchModal } from './components/Search/ProjectSearchModal'
|
||||
import { useEditorStore } from './store/editorStore'
|
||||
import './styles/app.css'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
const {
|
||||
setFileTree, activeFilePath, isDirty, markSaved, activeFileContent, theme, toggleTheme,
|
||||
loadSession, revisionPanelOpen, toggleRevisionPanel, fontSize, setFontSize
|
||||
loadSession, revisionPanelOpen, toggleRevisionPanel, fontSize, setFontSize,
|
||||
openProjectSearch
|
||||
} = useEditorStore()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(
|
||||
() => localStorage.getItem('sidebarOpen') !== 'false'
|
||||
@@ -50,11 +52,13 @@ export default function App(): JSX.Element {
|
||||
setFontSize(fontSize - 1)
|
||||
} else if (action === 'fontReset') {
|
||||
setFontSize(15)
|
||||
} else if (action === 'projectSearch') {
|
||||
openProjectSearch()
|
||||
}
|
||||
})
|
||||
}, [activeFilePath, isDirty, activeFileContent, fontSize])
|
||||
|
||||
// Handle Cmd+S / Ctrl+S
|
||||
// Handle Cmd+S / Ctrl+S and Cmd+Shift+F / Ctrl+Shift+F
|
||||
useEffect(() => {
|
||||
const handler = async (e: KeyboardEvent): Promise<void> => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
@@ -64,6 +68,9 @@ export default function App(): JSX.Element {
|
||||
await window.api.saveRevision(activeFilePath, activeFileContent)
|
||||
markSaved()
|
||||
}
|
||||
} else if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'f') {
|
||||
e.preventDefault()
|
||||
openProjectSearch()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handler)
|
||||
@@ -124,6 +131,7 @@ export default function App(): JSX.Element {
|
||||
<aside className="chat-area">
|
||||
<ChatPanel />
|
||||
</aside>
|
||||
<ProjectSearchModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -439,7 +439,7 @@ export function MarkdownEditor(): JSX.Element {
|
||||
const [hasSelection, setHasSelection] = useState(false)
|
||||
const [pendingComment, setPendingComment] = useState<{ from: number; to: number; text: string } | null>(null)
|
||||
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 {
|
||||
if (!pendingComment || !commentDraft.trim()) return
|
||||
@@ -625,6 +625,18 @@ export function MarkdownEditor(): JSX.Element {
|
||||
})
|
||||
}, [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
|
||||
useEffect(() => {
|
||||
const view = viewRef.current
|
||||
|
||||
281
src/renderer/components/Search/ProjectSearch.css
Normal file
281
src/renderer/components/Search/ProjectSearch.css
Normal 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);
|
||||
}
|
||||
275
src/renderer/components/Search/ProjectSearchModal.tsx
Normal file
275
src/renderer/components/Search/ProjectSearchModal.tsx
Normal 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -85,6 +85,16 @@ interface EditorState {
|
||||
outlineOpen: boolean
|
||||
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
|
||||
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 () => {
|
||||
const api = (window as unknown as { api?: { readSession: () => Promise<Record<string, unknown>>; readFile: (p: string) => Promise<string> } }).api
|
||||
if (!api) return
|
||||
|
||||
@@ -64,3 +64,16 @@ export interface RevisionMeta {
|
||||
timestamp: 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[]
|
||||
}
|
||||
|
||||
10
src/renderer/types/global.d.ts
vendored
10
src/renderer/types/global.d.ts
vendored
@@ -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 {
|
||||
interface Window {
|
||||
@@ -26,6 +32,8 @@ declare global {
|
||||
openStoryBible: () => Promise<{ path: string; content: string }>
|
||||
writeStoryBible: (content: string) => Promise<string>
|
||||
onMenuAction: (handler: (action: string) => void) => () => void
|
||||
searchFiles: (query: string, options: SearchOptions) => Promise<SearchFileResult[]>
|
||||
replaceInFiles: (query: string, replacement: string, options: SearchOptions, filePaths: string[]) => Promise<string[]>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user