✨ 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 { 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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
|
|||||||
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 {
|
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
Reference in New Issue
Block a user