✨ 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
|
||||
|
||||
Reference in New Issue
Block a user