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 { 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
}

View File

@@ -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')
}
]
},

View File

@@ -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