feat: v0.6.2 — sqlark 0.7.4 升级(KVStore) + 14 项缺陷修复 + 多文件搜索
- chore: 升级 @metona-team/metona-sqlark 0.4.4 → 0.7.4,存储后端迁移 KVStore 引擎(内存索引+快照/日志,OPFS 落盘,jsdom 回退 memory), 启动时自动删除旧 IndexedDB 库(aria-MarkLiteV2 / Dexie MarkLite), 不做向下兼容 - fix: 保存竞态 — 快照比对后才清 isModified,防止保存期间的新输入被 误清标记导致永不落盘(自动保存与手动保存均修复) - fix: 另存为后标签重绑新路径(此前 Ctrl+S/自动保存仍写回旧文件), 并同步最近文件与快照持久化 - fix: tabSwitched 只依赖活动文件路径(此前每次击键都触发 IPC 并 重启主进程文件 watcher) - fix: 主进程关闭兜底超时 5s→12s(长于 confirm 10s,防强杀丢编辑) - fix: 外部修改检测覆盖非活动标签,banner 显示文件名, 标签有未保存修改时显式提示不再静默 - fix: 导入备份后 flush + closeDatabase 再 reload(防 OPFS 未落盘丢数据), 导入整体事务原子化(防半导入状态) - fix: 文档大纲跳过代码块内标题;切换标签恢复光标位置 - fix: 首次启动主题跟随系统偏好(settings 无记录时 load 返回 null) - fix: watcher error 恢复时重置 isSelfWriting,防外部修改通知被永久吞掉 - fix: 打开失败路径(最近文件/文件树/打开对话框)显式提示 - fix: 保存时保留原文件编码(UTF-16 LE/BE BOM 同编码写回) - feat: 多文件搜索 — dir:search IPC + SearchPanel 弹层 + Ctrl+Shift+F, 文件夹内递归搜索(大小写/正则,结果定位到行) - feat: 标签恢复时与磁盘 mtime 比对,自动同步磁盘最新内容 - test: 新增 outlineUtils(6) / searchInDir(7) / updateTabFilePath(2) 测试 - docs: README/DESIGN 同步 kv 后端与版本号 v0.6.2
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { searchInDir } from '../file-system'
|
||||
|
||||
describe('searchInDir (主进程多文件搜索)', () => {
|
||||
let dir: string
|
||||
|
||||
beforeAll(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'marklite-search-'))
|
||||
mkdirSync(join(dir, 'sub'))
|
||||
mkdirSync(join(dir, 'node_modules'))
|
||||
writeFileSync(join(dir, 'a.md'), '# Hello\nline two\nWorld hello again\n')
|
||||
writeFileSync(join(dir, 'sub', 'b.markdown'), 'nothing here\nHELLO UPPER\n')
|
||||
writeFileSync(join(dir, 'skip.js'), 'hello in js\n')
|
||||
writeFileSync(join(dir, 'node_modules', 'c.md'), 'hello in deps\n')
|
||||
writeFileSync(join(dir, '.hidden.md'), 'hello hidden\n')
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('should find matches across files (case-insensitive default)', async () => {
|
||||
const result = await searchInDir({ dirPath: dir, query: 'hello' })
|
||||
expect(result.success).toBe(true)
|
||||
// a.md: 2 处(# Hello / World hello again) + b.markdown: 1 处(HELLO UPPER)
|
||||
expect(result.matches).toHaveLength(3)
|
||||
expect(result.totalFiles).toBe(2)
|
||||
})
|
||||
|
||||
it('should respect case-sensitive option', async () => {
|
||||
const result = await searchInDir({ dirPath: dir, query: 'HELLO', caseSensitive: true })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(result.matches?.[0].filePath).toContain('b.markdown')
|
||||
})
|
||||
|
||||
it('should support regex search', async () => {
|
||||
const result = await searchInDir({ dirPath: dir, query: '^line', useRegex: true })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.matches).toHaveLength(1)
|
||||
expect(result.matches?.[0].line).toBe(2)
|
||||
})
|
||||
|
||||
it('should reject invalid regex with error', async () => {
|
||||
const result = await searchInDir({ dirPath: dir, query: '([', useRegex: true })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error).toBe('无效的正则表达式')
|
||||
})
|
||||
|
||||
it('should skip node_modules and dotfiles', async () => {
|
||||
const result = await searchInDir({ dirPath: dir, query: 'hidden' })
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.matches).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should reject empty query', async () => {
|
||||
const result = await searchInDir({ dirPath: dir, query: '' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
it('should return error for invalid directory', async () => {
|
||||
const result = await searchInDir({ dirPath: join(dir, 'nope'), query: 'x' })
|
||||
// 目录不存在:walk 内部吞掉 readdir 错误,返回 0 结果(success: true)
|
||||
expect(result.success).toBe(true)
|
||||
expect(result.matches).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
+122
-2
@@ -1,7 +1,13 @@
|
||||
import { readFile, stat, writeFile, rename, readdir, unlink, lstat, realpath } from 'fs/promises'
|
||||
import { join, extname, dirname, basename } from 'path'
|
||||
import { randomBytes } from 'crypto'
|
||||
import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types'
|
||||
import type {
|
||||
ReadFileResult,
|
||||
SaveFileResult,
|
||||
FileNode,
|
||||
SearchInDirPayload,
|
||||
SearchInDirResult,
|
||||
} from '../shared/types'
|
||||
import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants'
|
||||
|
||||
const ALLOWED_EXTENSIONS_SET = new Set<string>(ALLOWED_EXTENSIONS)
|
||||
@@ -42,11 +48,34 @@ export async function readFileContent(filePath: string): Promise<ReadFileResult>
|
||||
|
||||
// C-02: 临时文件写到与目标相同目录(避免跨盘 rename 失败)
|
||||
// M-01: 使用随机 hex 后缀替代 Date.now(),防止本地攻击者预创建 symlink
|
||||
// B14-fix: 检测原文件编码(UTF-16 LE/BE BOM),保存时保持同编码,
|
||||
// 避免 UTF-16 文件被强制改写为 UTF-8 造成乱码
|
||||
export async function saveFileContent(filePath: string, content: string): Promise<SaveFileResult> {
|
||||
const randomSuffix = randomBytes(8).toString('hex')
|
||||
const tmpFile = join(dirname(filePath), `.marklite-tmp-${randomSuffix}-${basename(filePath)}`)
|
||||
try {
|
||||
await writeFile(tmpFile, content, 'utf-8')
|
||||
let data: Buffer
|
||||
try {
|
||||
const existing = await readFile(filePath)
|
||||
if (existing.length >= 2 && existing[0] === 0xfe && existing[1] === 0xff) {
|
||||
// 原文件 UTF-16 BE:BOM + BE 编码写回
|
||||
const bom = Buffer.from([0xfe, 0xff])
|
||||
const body = Buffer.from(content, 'utf16le')
|
||||
body.swap16()
|
||||
data = Buffer.concat([bom, body])
|
||||
} else if (existing.length >= 2 && existing[0] === 0xff && existing[1] === 0xfe) {
|
||||
// 原文件 UTF-16 LE:BOM + LE 编码写回
|
||||
const bom = Buffer.from([0xff, 0xfe])
|
||||
const body = Buffer.from(content, 'utf16le')
|
||||
data = Buffer.concat([bom, body])
|
||||
} else {
|
||||
data = Buffer.from(content, 'utf-8')
|
||||
}
|
||||
} catch {
|
||||
// 原文件不存在/不可读:按 UTF-8 写新文件
|
||||
data = Buffer.from(content, 'utf-8')
|
||||
}
|
||||
await writeFile(tmpFile, data)
|
||||
await rename(tmpFile, filePath)
|
||||
return { success: true, filePath }
|
||||
} catch (err) {
|
||||
@@ -116,3 +145,94 @@ export async function buildDirTree(
|
||||
}
|
||||
return children
|
||||
}
|
||||
|
||||
// v0.6.2: 多文件搜索 — 递归遍历目录,逐行匹配 md/markdown/txt 文件内容
|
||||
const SEARCH_MAX_MATCHES = 500
|
||||
const SEARCH_MAX_DEPTH = 10
|
||||
const SEARCH_LINE_PREVIEW_LEN = 200
|
||||
|
||||
function buildSearchMatcher(
|
||||
query: string,
|
||||
caseSensitive: boolean,
|
||||
useRegex: boolean,
|
||||
): { test: (line: string) => boolean } | null {
|
||||
if (useRegex) {
|
||||
try {
|
||||
const re = new RegExp(query, caseSensitive ? '' : 'i')
|
||||
return { test: (line: string) => re.test(line) }
|
||||
} catch {
|
||||
return null // 无效正则
|
||||
}
|
||||
}
|
||||
const needle = caseSensitive ? query : query.toLowerCase()
|
||||
return {
|
||||
test: (line: string) =>
|
||||
(caseSensitive ? line : line.toLowerCase()).includes(needle),
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchInDir(
|
||||
payload: SearchInDirPayload,
|
||||
): Promise<SearchInDirResult> {
|
||||
const { dirPath, query, caseSensitive = false, useRegex = false } = payload
|
||||
if (!query) {
|
||||
return { success: false, error: '搜索内容为空' }
|
||||
}
|
||||
const matcher = buildSearchMatcher(query, caseSensitive, useRegex)
|
||||
if (!matcher) {
|
||||
return { success: false, error: '无效的正则表达式' }
|
||||
}
|
||||
|
||||
const matches: SearchInDirResult['matches'] = []
|
||||
let totalFiles = 0
|
||||
let truncated = false
|
||||
|
||||
const walk = async (dir: string, depth: number): Promise<void> => {
|
||||
if (truncated || depth > SEARCH_MAX_DEPTH) return
|
||||
let entries
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (truncated) return
|
||||
const name = entry.name
|
||||
if (SKIP_DIRS.has(name) || name.startsWith('.')) continue
|
||||
const childPath = join(dir, name)
|
||||
if (entry.isDirectory()) {
|
||||
await walk(childPath, depth + 1)
|
||||
continue
|
||||
}
|
||||
if (!ALLOWED_EXTENSIONS_SET.has(extname(name).toLowerCase())) continue
|
||||
totalFiles++
|
||||
try {
|
||||
const fileStat = await stat(childPath)
|
||||
if (fileStat.size > MAX_FILE_SIZE) continue
|
||||
const content = await readFile(childPath, 'utf-8')
|
||||
const lines = content.split('\n')
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!matcher.test(lines[i])) continue
|
||||
matches?.push({
|
||||
filePath: childPath,
|
||||
line: i + 1,
|
||||
lineText: lines[i].slice(0, SEARCH_LINE_PREVIEW_LEN),
|
||||
})
|
||||
if ((matches?.length ?? 0) >= SEARCH_MAX_MATCHES) {
|
||||
truncated = true
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 单文件读取失败(编码/权限)跳过,不中断整体搜索
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await walk(dirPath, 0)
|
||||
return { success: true, matches, totalFiles, truncated }
|
||||
} catch (err) {
|
||||
return { success: false, error: (err as Error).message }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ export class FileWatcher {
|
||||
// L-02: 监听 error 事件,文件被删除时显式关闭并清理状态
|
||||
this.watcher.on('error', () => {
|
||||
const originalPath = this.currentPath
|
||||
// B12-fix: watcher 已失效,必然不再处于"自写"状态 —
|
||||
// 若 error 恰好发生在保存流程 start() 与 setSelfWriting(false) 之间,
|
||||
// 残留的 isSelfWriting=true 会永久吞掉轮询恢复后的外部修改通知
|
||||
this.isSelfWriting = false
|
||||
this.stop()
|
||||
if (originalPath) {
|
||||
this.pollTimer = setInterval(() => {
|
||||
|
||||
+3
-1
@@ -63,13 +63,15 @@ if (!lockOk) {
|
||||
mainWindow?.close()
|
||||
return
|
||||
}
|
||||
// B4-fix: 超时 12s — 需长于渲染进程 MeToast.confirm 的 10 秒安全超时,
|
||||
// 否则用户尚未确认就被强杀(丢失最后 500ms 防抖窗口内的编辑)
|
||||
state.closeTimeout = setTimeout(() => {
|
||||
state.closeTimeout = null
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.removeAllListeners('close')
|
||||
mainWindow.close()
|
||||
}
|
||||
}, 5000)
|
||||
}, 12000)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ipcMain, dialog, BrowserWindow, type IpcMainInvokeEvent } from 'electron'
|
||||
import { readFileContent, saveFileContent, buildDirTree } from './file-system'
|
||||
import { readFileContent, saveFileContent, buildDirTree, searchInDir } from './file-system'
|
||||
import { FileWatcher, SidebarWatcher } from './file-watcher'
|
||||
import { IPC_CHANNELS } from '../shared/ipc-channels'
|
||||
import type { SearchInDirPayload } from '../shared/types'
|
||||
import { stat, readFile, writeFile } from 'fs/promises'
|
||||
import { basename, isAbsolute } from 'path'
|
||||
|
||||
@@ -206,6 +207,23 @@ export function registerIpcHandlers(
|
||||
sidebarWatcher.stop()
|
||||
})
|
||||
|
||||
// v0.6.2: 多文件搜索 — 目录内递归搜索匹配行
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.DIR_SEARCH,
|
||||
async (_event: IpcMainInvokeEvent, payload: SearchInDirPayload) => {
|
||||
if (!payload || typeof payload !== 'object') {
|
||||
return { success: false, error: '无效的搜索参数' }
|
||||
}
|
||||
if (!validatePath(payload.dirPath)) {
|
||||
return { success: false, error: '无效的目录路径' }
|
||||
}
|
||||
if (typeof payload.query !== 'string') {
|
||||
return { success: false, error: '无效的搜索内容' }
|
||||
}
|
||||
return searchInDir(payload)
|
||||
},
|
||||
)
|
||||
|
||||
// 标签切换
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.TAB_SWITCHED,
|
||||
|
||||
@@ -37,6 +37,8 @@ const api: ElectronAPI = {
|
||||
openFolderDialog: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_OPEN_DIALOG),
|
||||
watchDir: (dirPath: string) => ipcRenderer.invoke(IPC_CHANNELS.DIR_WATCH, dirPath),
|
||||
unwatchDir: () => ipcRenderer.invoke(IPC_CHANNELS.DIR_UNWATCH),
|
||||
searchInDir: (payload: import('../shared/types').SearchInDirPayload) =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.DIR_SEARCH, payload),
|
||||
|
||||
// v0.6.0: 数据备份导出/导入
|
||||
exportData: (content: string) => ipcRenderer.invoke(IPC_CHANNELS.DATA_EXPORT, content),
|
||||
|
||||
+63
-9
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTabStore } from './stores/tabStore'
|
||||
import { flushSaveToDB } from './stores/tabStore'
|
||||
import { useEditorStore } from './stores/editorStore'
|
||||
import { useEditorStore, getMetonaEditor } from './stores/editorStore'
|
||||
import { useTheme } from './hooks/useTheme'
|
||||
import { useSettingsInit } from './hooks/useSettingsInit'
|
||||
import { useKeyboard } from './hooks/useKeyboard'
|
||||
@@ -11,8 +11,9 @@ import { useFileOperations } from './hooks/useFileOperations'
|
||||
import { useDragDrop } from './hooks/useDragDrop'
|
||||
import { useAutoSave } from './hooks/useAutoSave'
|
||||
import { useIpcListeners } from './hooks/useIpcListeners'
|
||||
import { MeToast } from './lib/toast'
|
||||
import { MeToast, showToast } from './lib/toast'
|
||||
import { closeDatabase } from './db/schema'
|
||||
import { recentFilesRepository } from './db/recentFilesRepository'
|
||||
import { Toolbar } from './components/Toolbar/Toolbar'
|
||||
import { TabBar } from './components/TabBar/TabBar'
|
||||
import { Editor } from './components/Editor/Editor'
|
||||
@@ -22,6 +23,7 @@ import { ModifiedBanner } from './components/ModifiedBanner/ModifiedBanner'
|
||||
import { DropOverlay } from './components/DropOverlay/DropOverlay'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { AboutDialog } from './components/AboutDialog'
|
||||
import { SearchPanel } from './components/SearchPanel'
|
||||
|
||||
export function App() {
|
||||
const tabs = useTabStore(s => s.tabs)
|
||||
@@ -35,7 +37,9 @@ export function App() {
|
||||
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
||||
const { themeMode, cycleTheme } = useTheme()
|
||||
const [showAbout, setShowAbout] = useState(false)
|
||||
const [showSearch, setShowSearch] = useState(false)
|
||||
const handleCloseAbout = useCallback(() => setShowAbout(false), [])
|
||||
const handleCloseSearch = useCallback(() => setShowSearch(false), [])
|
||||
|
||||
useSettingsInit()
|
||||
useEffect(() => {
|
||||
@@ -65,24 +69,69 @@ export function App() {
|
||||
}, [])
|
||||
|
||||
useUnsavedWarning(() => tabs.some(t => t.isModified), handleConfirmClose, handleBeforeForceClose)
|
||||
useKeyboard(handleOpenFile, handleSave, handleSaveAs)
|
||||
useKeyboard(handleOpenFile, handleSave, handleSaveAs, () => setShowSearch(true))
|
||||
useIpcListeners()
|
||||
|
||||
// v0.6.2: 多文件搜索 — 打开结果文件并定位到匹配行
|
||||
const handleSearchOpenResult = useCallback(
|
||||
async (filePath: string, line: number) => {
|
||||
const existing = tabs.find(t => t.filePath === filePath)
|
||||
if (existing) {
|
||||
useTabStore.getState().switchToTab(existing.id)
|
||||
} else if (window.electronAPI) {
|
||||
const result = await window.electronAPI.readFile(filePath)
|
||||
if (result.success && result.content !== undefined) {
|
||||
createTab(filePath, result.content)
|
||||
recentFilesRepository.add(filePath)
|
||||
} else {
|
||||
showToast(`无法打开 "${filePath}": ${result.error ?? '未知错误'}`, 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
setShowSearch(false)
|
||||
// 等待编辑器完成标签切换后再定位
|
||||
setTimeout(() => {
|
||||
const editor = getMetonaEditor()
|
||||
if (editor) {
|
||||
editor.scrollToLine(line)
|
||||
editor.setCursorPosition(line, 0)
|
||||
editor.focus()
|
||||
}
|
||||
}, 150)
|
||||
},
|
||||
[tabs, createTab],
|
||||
)
|
||||
|
||||
// B3-fix: 只依赖活动文件的路径 — 此前依赖 tabs 数组导致每次击键
|
||||
// (updateTabContent)都触发 tabSwitched IPC 并重启主进程文件 watcher
|
||||
const activeFilePath = useMemo(
|
||||
() => tabs.find(t => t.id === activeTabId)?.filePath ?? null,
|
||||
[tabs, activeTabId],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
const activeTab = tabs.find(t => t.id === activeTabId)
|
||||
window.electronAPI.tabSwitched(activeTab?.filePath ?? null)
|
||||
}, [activeTabId, tabs])
|
||||
window.electronAPI.tabSwitched(activeFilePath)
|
||||
}, [activeFilePath])
|
||||
|
||||
const handleReloadModified = useCallback(async () => {
|
||||
if (!externallyModified?.filePath || !window.electronAPI) return
|
||||
const tab = tabs.find(t => t.filePath === externallyModified.filePath)
|
||||
if (tab?.isModified) return
|
||||
if (!tab) return
|
||||
if (!tab) {
|
||||
setExternallyModified(null)
|
||||
return
|
||||
}
|
||||
// B6-fix: 标签有未保存修改时不再静默无反应,显式提示
|
||||
if (tab.isModified) {
|
||||
showToast('当前标签有未保存的修改,请先保存或放弃修改后再重新加载', 'warning')
|
||||
return
|
||||
}
|
||||
const result = await window.electronAPI.readFile(externallyModified.filePath)
|
||||
if (result.success && result.content !== undefined) {
|
||||
updateTabContent(tab.id, result.content)
|
||||
setModified(tab.id, false)
|
||||
} else {
|
||||
showToast(`重新加载失败: ${result.error ?? '未知错误'}`, 'error')
|
||||
}
|
||||
setExternallyModified(null)
|
||||
}, [externallyModified, tabs, updateTabContent, setModified, setExternallyModified])
|
||||
@@ -96,6 +145,7 @@ export function App() {
|
||||
themeMode={themeMode}
|
||||
onCycleTheme={cycleTheme}
|
||||
onShowAbout={() => setShowAbout(true)}
|
||||
onShowSearch={() => setShowSearch(true)}
|
||||
isAutoSaving={isAutoSaving}
|
||||
autoSaveEnabled={autoSaveEnabled}
|
||||
onToggleAutoSave={toggleAutoSave}
|
||||
@@ -106,6 +156,7 @@ export function App() {
|
||||
<TabBar />
|
||||
{externallyModified && (
|
||||
<ModifiedBanner
|
||||
filePath={externallyModified.filePath}
|
||||
onReload={handleReloadModified}
|
||||
onDismiss={() => setExternallyModified(null)}
|
||||
/>
|
||||
@@ -127,6 +178,9 @@ export function App() {
|
||||
</div>
|
||||
<DropOverlay />
|
||||
{showAbout && <AboutDialog onClose={handleCloseAbout} />}
|
||||
{showSearch && (
|
||||
<SearchPanel onClose={handleCloseSearch} onOpenResult={handleSearchOpenResult} />
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
|
||||
@@ -52,7 +52,7 @@ export const AboutDialog = React.memo(function AboutDialog({ onClose }: AboutDia
|
||||
<span>git.metona.cn/MetonaTeam/MarkLite</span>
|
||||
</a>
|
||||
<p>基于 Electron + React + TypeScript 构建</p>
|
||||
<p>MetonaEditor 0.4.1 · MetonaToast 0.5.0 · MetonaSqlark 0.4.4</p>
|
||||
<p>MetonaEditor 0.4.1 · MetonaToast 0.5.0 · MetonaSqlark 0.7.4</p>
|
||||
<p className="about-copyright">© 2026 thzxx</p>
|
||||
</div>
|
||||
<button className="about-close-btn" onClick={onClose}>
|
||||
|
||||
@@ -221,7 +221,7 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
|
||||
// silent: true — 不触发 onChange,避免重复更新 tabStore
|
||||
editorRef.current.setValue(activeTab.content, { silent: true })
|
||||
|
||||
// 恢复滚动位置
|
||||
// 恢复滚动位置 + 光标位置
|
||||
requestAnimationFrame(() => {
|
||||
const c = containerRef.current
|
||||
if (!c) return
|
||||
@@ -233,6 +233,16 @@ export const Editor = React.memo(function Editor({ themeMode, onAppSave }: Edito
|
||||
if (preview) {
|
||||
preview.scrollTop = activeTab.scrollTop
|
||||
}
|
||||
// B10-fix: 恢复光标位置(此前 selectionStart/End 已持久化但从未恢复)
|
||||
const editor = editorRef.current
|
||||
if (editor && activeTab.selectionStart > 0) {
|
||||
const before = activeTab.content.substring(
|
||||
0,
|
||||
Math.min(activeTab.selectionStart, activeTab.content.length),
|
||||
)
|
||||
const lines = before.split('\n')
|
||||
editor.setCursorPosition(lines.length, lines[lines.length - 1].length)
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTabId])
|
||||
|
||||
@@ -277,6 +277,25 @@ export function Download({ size = defaultProps.size }: IconProps) {
|
||||
)
|
||||
}
|
||||
|
||||
// ===== v0.6.2: 多文件搜索图标 =====
|
||||
export function SearchIcon({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function Upload({ size = defaultProps.size }: IconProps) {
|
||||
return (
|
||||
<svg
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import React from 'react'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
|
||||
interface ModifiedBannerProps {
|
||||
filePath: string
|
||||
onReload: () => void
|
||||
onDismiss: () => void
|
||||
}
|
||||
|
||||
export const ModifiedBanner = React.memo(function ModifiedBanner({
|
||||
filePath,
|
||||
onReload,
|
||||
onDismiss,
|
||||
}: ModifiedBannerProps) {
|
||||
return (
|
||||
<div id="modified-banner" role="alert" aria-live="assertive">
|
||||
<span>文件已被外部程序修改</span>
|
||||
<span>{getFileName(filePath)} 已被外部程序修改</span>
|
||||
<button className="banner-btn" onClick={onReload} aria-label="重新加载文件">
|
||||
重新加载
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { parseHeadings } from '../outlineUtils'
|
||||
|
||||
describe('parseHeadings', () => {
|
||||
it('should parse headings from markdown', () => {
|
||||
const headings = parseHeadings('# Title\n## Section\ncontent')
|
||||
expect(headings).toHaveLength(2)
|
||||
expect(headings[0]).toEqual({ level: 1, text: 'Title', pos: 0 })
|
||||
expect(headings[1].level).toBe(2)
|
||||
expect(headings[1].text).toBe('Section')
|
||||
})
|
||||
|
||||
it('should skip headings inside fenced code blocks', () => {
|
||||
const md = [
|
||||
'# Real Title',
|
||||
'```markdown',
|
||||
'# Fake Heading',
|
||||
'## Fake Sub',
|
||||
'```',
|
||||
'## After Code',
|
||||
].join('\n')
|
||||
const headings = parseHeadings(md)
|
||||
expect(headings.map(h => h.text)).toEqual(['Real Title', 'After Code'])
|
||||
})
|
||||
|
||||
it('should skip headings inside tilde code blocks', () => {
|
||||
const md = ['# A', '~~~', '### Inside', '~~~', '## B'].join('\n')
|
||||
const headings = parseHeadings(md)
|
||||
expect(headings.map(h => h.text)).toEqual(['A', 'B'])
|
||||
})
|
||||
|
||||
it('should keep position offsets (pos) correct', () => {
|
||||
const md = 'aaa\n## B'
|
||||
const headings = parseHeadings(md)
|
||||
expect(headings).toHaveLength(1)
|
||||
expect(headings[0].pos).toBe(4)
|
||||
})
|
||||
|
||||
it('should handle unclosed fence (rest treated as code)', () => {
|
||||
const md = ['# A', '```', '## Inside', '# Also Inside'].join('\n')
|
||||
const headings = parseHeadings(md)
|
||||
expect(headings.map(h => h.text)).toEqual(['A'])
|
||||
})
|
||||
|
||||
it('should return empty array for empty markdown', () => {
|
||||
expect(parseHeadings('')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -5,22 +5,39 @@ export interface Heading {
|
||||
pos: number
|
||||
}
|
||||
|
||||
const HEADING_RE = /^(#{1,6})\s+(.+)$/gm
|
||||
|
||||
/**
|
||||
* Parse headings from raw markdown content using regex.
|
||||
*
|
||||
* B9-fix: 逐行扫描并跟踪代码围栏(``` / ~~~)状态,
|
||||
* 跳过代码块内的 # 行(此前会被误判为标题,大纲点击导航到错误位置)。
|
||||
*/
|
||||
export function parseHeadings(markdown: string): Heading[] {
|
||||
const headings: Heading[] = []
|
||||
let match: RegExpExecArray | null
|
||||
const lines = markdown.split('\n')
|
||||
let pos = 0
|
||||
let fence: string | null = null // 当前围栏标记(``` 或 ~~~),null = 不在代码块内
|
||||
|
||||
// Reset regex state
|
||||
HEADING_RE.lastIndex = 0
|
||||
for (const line of lines) {
|
||||
const fenceMatch = /^(\s*)(`{3,}|~{3,})/.exec(line)
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[2][0]
|
||||
if (fence === null) {
|
||||
fence = marker
|
||||
} else if (marker === fence && fenceMatch[1].length < 4) {
|
||||
fence = null
|
||||
}
|
||||
pos += line.length + 1
|
||||
continue
|
||||
}
|
||||
|
||||
while ((match = HEADING_RE.exec(markdown)) !== null) {
|
||||
const level = match[1].length
|
||||
const text = match[2].trim()
|
||||
headings.push({ level, text, pos: match.index })
|
||||
if (fence === null) {
|
||||
const m = /^(#{1,6})\s+(.+)$/.exec(line)
|
||||
if (m) {
|
||||
headings.push({ level: m[1].length, text: m[2].trim(), pos })
|
||||
}
|
||||
}
|
||||
|
||||
pos += line.length + 1
|
||||
}
|
||||
|
||||
return headings
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { showToast } from '../../lib/toast'
|
||||
import { logError } from '../../lib/errorHandler'
|
||||
import type { SearchMatch } from '../../../shared/types'
|
||||
|
||||
interface SearchPanelProps {
|
||||
onClose: () => void
|
||||
onOpenResult: (filePath: string, line: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.6.2: 多文件搜索面板 — 在侧边栏已打开的文件夹内递归搜索
|
||||
* md/markdown/txt 文件内容,结果按 文件+行号 展示,点击打开并定位。
|
||||
*/
|
||||
export const SearchPanel = React.memo(function SearchPanel({ onClose, onOpenResult }: SearchPanelProps) {
|
||||
const rootPath = useSidebarStore(s => s.rootPath)
|
||||
const [query, setQuery] = useState('')
|
||||
const [caseSensitive, setCaseSensitive] = useState(false)
|
||||
const [useRegex, setUseRegex] = useState(false)
|
||||
const [searching, setSearching] = useState(false)
|
||||
const [matches, setMatches] = useState<SearchMatch[]>([])
|
||||
const [totalFiles, setTotalFiles] = useState(0)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus()
|
||||
}, [])
|
||||
|
||||
const runSearch = useCallback(
|
||||
async (q: string) => {
|
||||
if (!q.trim()) return
|
||||
if (!rootPath || !window.electronAPI) {
|
||||
showToast('请先在侧边栏打开一个文件夹', 'warning')
|
||||
return
|
||||
}
|
||||
setSearching(true)
|
||||
setSearched(true)
|
||||
try {
|
||||
const result = await window.electronAPI.searchInDir({
|
||||
dirPath: rootPath,
|
||||
query: q,
|
||||
caseSensitive,
|
||||
useRegex,
|
||||
})
|
||||
if (result.success) {
|
||||
setMatches(result.matches ?? [])
|
||||
setTotalFiles(result.totalFiles ?? 0)
|
||||
setTruncated(result.truncated ?? false)
|
||||
} else {
|
||||
setMatches([])
|
||||
showToast(result.error ?? '搜索失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('多文件搜索失败', error)
|
||||
showToast('搜索失败', 'error')
|
||||
} finally {
|
||||
setSearching(false)
|
||||
}
|
||||
},
|
||||
[rootPath, caseSensitive, useRegex],
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
runSearch(query)
|
||||
},
|
||||
[query, runSearch],
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="search-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="多文件搜索"
|
||||
onKeyDown={handleKeyDown}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="search-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="search-header">
|
||||
<form className="search-form" onSubmit={handleSubmit}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="search-input"
|
||||
type="text"
|
||||
placeholder={
|
||||
rootPath ? `在 ${getFileName(rootPath)} 中搜索...` : '请先在侧边栏打开文件夹'
|
||||
}
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
aria-label="搜索内容"
|
||||
/>
|
||||
<button className="search-run-btn" type="submit" disabled={searching || !query.trim()}>
|
||||
{searching ? '搜索中...' : '搜索'}
|
||||
</button>
|
||||
</form>
|
||||
<div className="search-options">
|
||||
<label className="search-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={caseSensitive}
|
||||
onChange={e => setCaseSensitive(e.target.checked)}
|
||||
/>
|
||||
区分大小写
|
||||
</label>
|
||||
<label className="search-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useRegex}
|
||||
onChange={e => setUseRegex(e.target.checked)}
|
||||
/>
|
||||
正则表达式
|
||||
</label>
|
||||
<button className="search-close-btn" onClick={onClose} aria-label="关闭搜索">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="search-body">
|
||||
{!searched ? (
|
||||
<div className="search-empty">输入关键词开始搜索(Ctrl+Shift+F 再次打开)</div>
|
||||
) : searching ? (
|
||||
<div className="search-empty">搜索中...</div>
|
||||
) : matches.length === 0 ? (
|
||||
<div className="search-empty">
|
||||
无匹配结果{totalFiles > 0 ? `(已扫描 ${totalFiles} 个文件)` : ''}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="search-summary">
|
||||
{matches.length} 条匹配
|
||||
{truncated && '(结果过多,仅显示前 500 条)'} · 扫描 {totalFiles} 个文件
|
||||
</div>
|
||||
<div className="search-results" role="list" aria-label="搜索结果">
|
||||
{matches.map((m, i) => (
|
||||
<button
|
||||
key={`${m.filePath}:${m.line}:${i}`}
|
||||
className="search-result-item"
|
||||
onClick={() => onOpenResult(m.filePath, m.line)}
|
||||
title={m.filePath}
|
||||
>
|
||||
<span className="search-result-file">{getFileName(m.filePath)}</span>
|
||||
<span className="search-result-line">:{m.line}</span>
|
||||
<span className="search-result-text">{m.lineText}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
SearchPanel.displayName = 'SearchPanel'
|
||||
@@ -0,0 +1 @@
|
||||
export { SearchPanel } from './SearchPanel'
|
||||
@@ -3,6 +3,8 @@ import { useTabStore } from '../../stores/tabStore'
|
||||
import { useSidebarStore } from '../../stores/sidebarStore'
|
||||
import { getFileName } from '../../lib/fileUtils'
|
||||
import { recentFilesRepository } from '../../db/recentFilesRepository'
|
||||
import { logError } from '../../lib/errorHandler'
|
||||
import { showToast } from '../../lib/toast'
|
||||
import { FolderPlus, File } from '../Icons'
|
||||
import { FileTree } from '../FileTree'
|
||||
import { useSidebarResize } from '../../hooks/useSidebarResize'
|
||||
@@ -98,7 +100,13 @@ export const Sidebar = React.memo(function Sidebar() {
|
||||
if (result.success && result.content !== undefined) {
|
||||
createTab(path, result.content)
|
||||
recentFilesRepository.add(path)
|
||||
} else {
|
||||
// B13-fix: 读取失败(文件被删除/权限问题)显式提示
|
||||
showToast(`无法打开 "${path}": ${result.error ?? '未知错误'}`, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('文件树打开文件失败', error)
|
||||
showToast(`无法打开 "${path}"`, 'error')
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { FolderOpen, Save, Moon, Sun, Info, Download, Upload } from '../Icons'
|
||||
import { FolderOpen, Save, Moon, Sun, Info, Download, Upload, SearchIcon } from '../Icons'
|
||||
import { getMetonaEditor, useEditorStore } from '../../stores/editorStore'
|
||||
import { backupRepository } from '../../db/backupRepository'
|
||||
import { closeDatabase } from '../../db/schema'
|
||||
import { flushSaveToDB } from '../../stores/tabStore'
|
||||
import { showToast } from '../../lib/toast'
|
||||
import { logError } from '../../lib/errorHandler'
|
||||
import type { ThemeMode } from '../../types/settings'
|
||||
@@ -12,6 +14,7 @@ interface ToolbarProps {
|
||||
themeMode: ThemeMode
|
||||
onCycleTheme: () => void
|
||||
onShowAbout: () => void
|
||||
onShowSearch: () => void
|
||||
isAutoSaving: boolean
|
||||
autoSaveEnabled: boolean
|
||||
onToggleAutoSave: () => void
|
||||
@@ -33,6 +36,7 @@ export const Toolbar = React.memo(function Toolbar({
|
||||
themeMode,
|
||||
onCycleTheme,
|
||||
onShowAbout,
|
||||
onShowSearch,
|
||||
isAutoSaving,
|
||||
autoSaveEnabled,
|
||||
onToggleAutoSave,
|
||||
@@ -60,7 +64,7 @@ export const Toolbar = React.memo(function Toolbar({
|
||||
}
|
||||
}, [])
|
||||
|
||||
// v0.6.0: 数据备份导入(JSON 文件 → sqlark importTable)
|
||||
// v0.6.0: 数据备份导入(JSON 文件 → sqlark 事务导入)
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.importData()
|
||||
@@ -73,7 +77,15 @@ export const Toolbar = React.memo(function Toolbar({
|
||||
// 恢复后刷新页面 — 所有 store 从新数据库重新加载(loadFromDB 有 _loaded 守卫,
|
||||
// 且 settings/sidebar 也只在初始化时读取,直接重载页面最可靠)
|
||||
showToast('备份已恢复', 'success')
|
||||
setTimeout(() => window.location.reload(), 800)
|
||||
// B7-fix: 先 flush 快照并干净关闭数据库,确保导入数据落盘后再重载
|
||||
// (此前固定 800ms 后 reload,OPFS 写未完成会丢导入数据)
|
||||
try {
|
||||
await flushSaveToDB()
|
||||
await closeDatabase()
|
||||
} catch {
|
||||
/* flush/close 失败仍继续 reload,由自愈流程兜底 */
|
||||
}
|
||||
window.location.reload()
|
||||
} else {
|
||||
showToast('恢复备份失败', 'error')
|
||||
}
|
||||
@@ -148,6 +160,14 @@ export const Toolbar = React.memo(function Toolbar({
|
||||
>
|
||||
<Upload size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={onShowSearch}
|
||||
title="多文件搜索 (Ctrl+Shift+F)"
|
||||
aria-label="多文件搜索"
|
||||
>
|
||||
<SearchIcon size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="toolbar-btn"
|
||||
onClick={onCycleTheme}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'fake-indexeddb/auto'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { recentFilesRepository } from '../recentFilesRepository'
|
||||
|
||||
describe('recentFilesRepository (真实 AriaEngine + IndexedDB)', () => {
|
||||
// v0.6.2: sqlark 0.7.4 移除 IndexedDB 后端,jsdom 无 OPFS →
|
||||
// schema.ts 自动回退 AriaEngine memory 后端(见 DISK_ENGINE 探测)。
|
||||
describe('recentFilesRepository (真实 AriaEngine + memory 后端)', () => {
|
||||
it('should add and read recent files', async () => {
|
||||
await recentFilesRepository.add('/test/a.md')
|
||||
const files = await recentFilesRepository.getAll(10)
|
||||
|
||||
@@ -23,18 +23,19 @@ export const backupRepository = {
|
||||
async importAll(data: BackupData): Promise<boolean> {
|
||||
try {
|
||||
const db = await getDb()
|
||||
// 先清空现有四表,再按表导入(清空走事务保证原子性)
|
||||
// B8-fix: 清空 + 导入在单个事务内完成(此前 clear 事务与逐表 importTable
|
||||
// 分离,中途失败会留下半导入状态)
|
||||
await db.transaction(async trx => {
|
||||
for (const tableName of BACKUP_TABLES) {
|
||||
await trx.table(tableName).clear()
|
||||
}
|
||||
})
|
||||
for (const tableName of BACKUP_TABLES) {
|
||||
const rows = data[tableName]
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
await db.importTable(tableName, rows)
|
||||
for (const tableName of BACKUP_TABLES) {
|
||||
const rows = data[tableName]
|
||||
if (Array.isArray(rows) && rows.length > 0) {
|
||||
await trx.table(tableName).insertMany(rows as Record<string, unknown>[])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return true
|
||||
} catch (error) {
|
||||
logError('导入备份失败', error)
|
||||
|
||||
+82
-13
@@ -36,12 +36,20 @@ export type RecentFile = {
|
||||
lastOpened: number
|
||||
}
|
||||
|
||||
// v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离,旧数据已放弃
|
||||
// v0.6.1: 存储引擎回归 AriaEngine(LSM-Tree + WAL + MVCC,功能最强)。
|
||||
// sqlark 0.4.4 修复 SSTable 大 value 编码缺陷(v2 格式 "SSTC":
|
||||
// u32 长度字段 + UTF-8 字节精确估算 + 超大条目独立成块,兼容旧 v1 格式)。
|
||||
// v0.5.0: 库名更换为 MarkLiteV2,与旧 Dexie 库(MarkLite)彻底隔离
|
||||
// v0.6.2: sqlark 0.7.4 移除 IndexedDB 后端(v0.6.0 起),存储后端改用 KVStore
|
||||
// 引擎(内存索引 + 快照/日志,落盘于 OPFS 目录 DB_NAME)。
|
||||
// 不再考虑向下兼容:启动时直接删除旧 IndexedDB 库(见 deleteLegacyDatabases)。
|
||||
const DB_NAME = 'MarkLiteV2'
|
||||
|
||||
// v0.6.2: OPFS 可用性探测 — Electron/Chromium 提供 navigator.storage.getDirectory;
|
||||
// jsdom 等测试环境无 OPFS,回退 AriaEngine memory 后端(测试数据不持久化)。
|
||||
// kv 后端的 KVStore 底层 medium 同样基于 OPFS。
|
||||
const DISK_ENGINE: 'kv' | 'memory' =
|
||||
typeof navigator !== 'undefined' && typeof navigator.storage?.getDirectory === 'function'
|
||||
? 'kv'
|
||||
: 'memory'
|
||||
|
||||
/**
|
||||
* v0.6.0: 表结构定义 — 幂等建表(getTableNames 检查)。
|
||||
* v0.6.1: sqlark 0.4.2 已修复 IndexedDBEngine 版本管理(打开时自动自适应当前版本),
|
||||
@@ -97,16 +105,73 @@ let isClosing = false
|
||||
let initChain: Promise<unknown> = Promise.resolve()
|
||||
|
||||
const RESET_PENDING_KEY = 'marklite-db-reset-pending'
|
||||
// AriaEngine 的 IndexedDBBackend 内部库名 = `aria-${name}`(sqlark 源码约定)
|
||||
const DB_STORAGE_NAME = `aria-${DB_NAME}`
|
||||
|
||||
/** 删除损坏数据库(无活动连接时立即成功) */
|
||||
/**
|
||||
* 删除损坏数据库。
|
||||
* v0.6.2: sqlark 0.7.4 的 KVStore 后端(KVStoreEngine)底层 medium 为 OPFSBackend,
|
||||
* 以库名(DB_NAME)为目录名存储在 OPFS 根目录下,删除 = 递归移除该目录。
|
||||
*/
|
||||
function deleteStorageDatabase(): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
const req = indexedDB.deleteDatabase(DB_STORAGE_NAME)
|
||||
req.onsuccess = () => resolve()
|
||||
req.onerror = () => resolve()
|
||||
req.onblocked = () => resolve()
|
||||
try {
|
||||
if (!navigator.storage?.getDirectory) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
navigator.storage
|
||||
.getDirectory()
|
||||
.then(root => root.removeEntry(DB_NAME, { recursive: true }))
|
||||
.then(() => resolve())
|
||||
.catch(() => resolve())
|
||||
} catch {
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.6.2: 删除旧版本遗留的 IndexedDB 库 — 不考虑向下兼容。
|
||||
* 覆盖旧 sqlark(0.4.x,库名 aria-MarkLiteV2)与更早 Dexie(MarkLite)。
|
||||
* 枚举 IndexedDB 中所有含 MarkLite 的库删除(失败静默,不阻塞启动)。
|
||||
*/
|
||||
function deleteLegacyIndexedDB(): Promise<void> {
|
||||
return new Promise<void>(resolve => {
|
||||
const finish = () => resolve()
|
||||
try {
|
||||
if (typeof indexedDB === 'undefined' || typeof indexedDB.deleteDatabase !== 'function') {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
const candidates: string[] = ['aria-MarkLiteV2', 'MarkLiteV2', 'MarkLite']
|
||||
const deleteOne = (name: string) =>
|
||||
new Promise<void>(r => {
|
||||
try {
|
||||
const req = indexedDB.deleteDatabase(name)
|
||||
req.onsuccess = () => r()
|
||||
req.onerror = () => r()
|
||||
req.onblocked = () => r()
|
||||
} catch {
|
||||
r()
|
||||
}
|
||||
})
|
||||
// 优先枚举全部 IDB 库(Chromium 支持),找不到 API 则回退固定名单
|
||||
if (typeof indexedDB.databases === 'function') {
|
||||
indexedDB
|
||||
.databases()
|
||||
.then(dbs => {
|
||||
const names = dbs
|
||||
.map(d => d.name ?? '')
|
||||
.filter(n => n.includes('MarkLite') && !candidates.includes(n))
|
||||
return Promise.all([...candidates, ...names].map(deleteOne))
|
||||
})
|
||||
.then(() => finish())
|
||||
.catch(() => Promise.all(candidates.map(deleteOne)).then(() => finish()))
|
||||
} else {
|
||||
Promise.all(candidates.map(deleteOne)).then(() => finish())
|
||||
}
|
||||
} catch {
|
||||
finish()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -128,8 +193,10 @@ async function resetCorruptDatabaseIfNeeded(): Promise<void> {
|
||||
async function createDatabase(): Promise<MetonaSqlark> {
|
||||
const db = await create({
|
||||
name: DB_NAME,
|
||||
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离(sqlark 0.4.4 修复大 value 编码)
|
||||
diskEngine: 'indexeddb', // 底层存储后端(indexeddb | opfs | memory)
|
||||
mode: 'aria', // AriaEngine: LSM-Tree + WAL + MVCC 快照隔离
|
||||
// v0.6.2: sqlark 0.7.4 DiskEngine 移除 indexeddb,选用 kv(自研 KVStore 后端:
|
||||
// 内存索引 + 快照/日志,OPFS 落盘)。无 OPFS 环境(jsdom 测试)回退 memory。
|
||||
diskEngine: DISK_ENGINE, // 底层存储后端(opfs | kv | memory)
|
||||
version: 0, // AriaEngine 忽略版本号(0 表示无 schema 迁移门槛)
|
||||
onError: (err: Error) => logError('数据库错误', err),
|
||||
})
|
||||
@@ -149,6 +216,8 @@ async function initDb(): Promise<MetonaSqlark> {
|
||||
if (!healTriggered) {
|
||||
healTriggered = true
|
||||
await resetCorruptDatabaseIfNeeded()
|
||||
// v0.6.2: 清理旧版本 IndexedDB 库(不向下兼容,静默删除)
|
||||
await deleteLegacyIndexedDB()
|
||||
try {
|
||||
const db = await createDatabase()
|
||||
// 创建成功:清除重置标记(自愈完成)
|
||||
|
||||
@@ -4,7 +4,12 @@ import { DEFAULT_SETTINGS, type Settings } from '../types/settings'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
|
||||
export const settingsRepository = {
|
||||
async load(): Promise<Settings> {
|
||||
/**
|
||||
* B11-fix: 无记录时返回 null(首次启动)——
|
||||
* 此前永远返回完整 DEFAULT_SETTINGS,导致 useSettingsInit 中
|
||||
* "跟随系统主题偏好"分支成为死代码。
|
||||
*/
|
||||
async load(): Promise<Settings | null> {
|
||||
try {
|
||||
const db = await getDb()
|
||||
const rows = await db.table('settings').select().where({ id: 'default' }).execute()
|
||||
@@ -17,10 +22,11 @@ export const settingsRepository = {
|
||||
sidebarWidth: record.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth,
|
||||
}
|
||||
}
|
||||
return null
|
||||
} catch (error) {
|
||||
logError('加载设置失败', error)
|
||||
return null
|
||||
}
|
||||
return { ...DEFAULT_SETTINGS }
|
||||
},
|
||||
|
||||
// C-01: 先 load 再 merge 再 upsert,避免部分字段丢失
|
||||
@@ -28,7 +34,7 @@ export const settingsRepository = {
|
||||
async save(partial: Partial<Settings>): Promise<void> {
|
||||
try {
|
||||
const db = await getDb()
|
||||
const current: Settings = await this.load()
|
||||
const current: Settings = (await this.load()) ?? DEFAULT_SETTINGS
|
||||
const merged: SettingsRecord = { id: 'default', ...current, ...partial }
|
||||
const tbl = db.table('settings') as Table<SettingsRecord>
|
||||
const rows = await tbl.select().where({ id: 'default' }).execute()
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { useTabStore } from '../stores/tabStore'
|
||||
import { useAutoSaveStore } from '../stores/autoSaveStore'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
import { showToast } from '../lib/toast'
|
||||
|
||||
/** Debounce delay for auto-save (ms) */
|
||||
const AUTO_SAVE_DELAY = 2000
|
||||
@@ -79,14 +80,24 @@ export function useAutoSave(): {
|
||||
isSavingRef.current = true
|
||||
if (mountedRef.current) setIsAutoSaving(true)
|
||||
|
||||
// B1-fix: 记录保存时的内容快照 — 保存期间用户继续输入的话,
|
||||
// 完成后仅当内容仍是快照时才清除 isModified(否则新输入会被误清标记
|
||||
// 导致永不落盘、关闭时也无未保存提示)
|
||||
const savedContent = tabToSave.content
|
||||
|
||||
try {
|
||||
if (!window.electronAPI) return
|
||||
const result = await window.electronAPI.saveFile({
|
||||
filePath: tabToSave.filePath,
|
||||
content: tabToSave.content,
|
||||
content: savedContent,
|
||||
})
|
||||
if (result.success && mountedRef.current) {
|
||||
currentState.setModified(tabToSave.id, false)
|
||||
const latest = useTabStore.getState().tabs.find(t => t.id === tabIdToSave)
|
||||
if (latest && latest.content === savedContent) {
|
||||
currentState.setModified(tabToSave.id, false)
|
||||
}
|
||||
} else if (mountedRef.current && !result.success && !result.canceled) {
|
||||
showToast(`自动保存失败: ${result.error ?? '未知错误'}`, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('自动保存失败', error)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MeToast, showToast } from '../lib/toast'
|
||||
export function useFileOperations() {
|
||||
const createTab = useTabStore(s => s.createTab)
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const updateTabFilePath = useTabStore(s => s.updateTabFilePath)
|
||||
const setLoading = useEditorStore(s => s.setLoading)
|
||||
|
||||
// 防重入:onSave 回调 + 全局 Ctrl+S handler 可能在 300ms 内双重触发
|
||||
@@ -30,6 +31,8 @@ export function useFileOperations() {
|
||||
if (result.filePath) recentFilesRepository.add(result.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
loading.success('文件已打开')
|
||||
} else if (result && 'error' in result) {
|
||||
loading.error(`打开失败: ${result.error}`)
|
||||
} else {
|
||||
loading.dismiss()
|
||||
}
|
||||
@@ -48,11 +51,14 @@ export function useFileOperations() {
|
||||
try {
|
||||
const tab = getActiveTab()
|
||||
if (!tab || !window.electronAPI) return
|
||||
// B1-fix: 保存前记录内容快照 — 保存期间继续输入的话,完成后
|
||||
// 仅当内容未变化时才清除 isModified,避免新输入被误清标记
|
||||
const savedContent = tab.content
|
||||
// v0.6.0: promise 监听保存生命周期 — 自动 loading → success/error,返回原 Promise
|
||||
const result = await MeToast.promise(
|
||||
window.electronAPI.saveFile({
|
||||
filePath: tab.filePath,
|
||||
content: tab.content,
|
||||
content: savedContent,
|
||||
}),
|
||||
{
|
||||
loading: '保存中...',
|
||||
@@ -61,10 +67,15 @@ export function useFileOperations() {
|
||||
},
|
||||
)
|
||||
if (result.success) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
const latest = useTabStore.getState().tabs.find(t => t.id === tab.id)
|
||||
if (latest && latest.content === savedContent) {
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
}
|
||||
} else {
|
||||
// IPC 返回 success:false 不 reject,promise 的 error 文案不会触发,需显式提示
|
||||
showToast('保存失败', 'error')
|
||||
if (!result.canceled) {
|
||||
showToast(`保存失败: ${result.error ?? '未知错误'}`, 'error')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logError('保存文件失败', error)
|
||||
@@ -88,15 +99,20 @@ export function useFileOperations() {
|
||||
error: '另存为失败',
|
||||
},
|
||||
)
|
||||
if (result.success) {
|
||||
if (result.success && result.filePath) {
|
||||
// B2-fix: 另存为成功后把标签重新绑定到新路径(此前标签仍指向旧文件,
|
||||
// 之后 Ctrl+S/自动保存会写回旧文件)
|
||||
updateTabFilePath(tab.id, result.filePath)
|
||||
useTabStore.getState().setModified(tab.id, false)
|
||||
recentFilesRepository.add(result.filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
} else if (!result.canceled) {
|
||||
showToast('另存为失败', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('另存为失败', error)
|
||||
}
|
||||
}, [getActiveTab])
|
||||
}, [getActiveTab, updateTabFilePath])
|
||||
|
||||
const handleOpenRecent = useCallback(
|
||||
async (filePath: string): Promise<void> => {
|
||||
@@ -108,7 +124,13 @@ export function useFileOperations() {
|
||||
createTab(filePath, result.content)
|
||||
recentFilesRepository.add(filePath)
|
||||
setTimeout(() => useTabStore.getState().saveToDB(), 100)
|
||||
} else {
|
||||
// B13-fix: 打开失败(文件被删除/移动)显式提示,不再静默
|
||||
showToast(`无法打开 "${filePath}": ${result.error ?? '未知错误'}`, 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
logError('打开最近文件失败', error)
|
||||
showToast(`无法打开 "${filePath}"`, 'error')
|
||||
} finally {
|
||||
setLoading('file-open', false)
|
||||
}
|
||||
|
||||
@@ -7,20 +7,20 @@ import { useEditorStore } from '../stores/editorStore'
|
||||
* 使用 Zustand store 状态替代 DOM CustomEvent
|
||||
*/
|
||||
export function useFileWatch() {
|
||||
const getActiveTab = useTabStore(s => s.getActiveTab)
|
||||
const setExternallyModified = useEditorStore(s => s.setExternallyModified)
|
||||
|
||||
useEffect(() => {
|
||||
if (!window.electronAPI) return
|
||||
|
||||
// B5-fix: 任何打开的标签对应的文件被外部修改都提示(此前只检测活动标签,
|
||||
// 后台标签被外部改后切过去无感知,显示的还是内存旧内容)
|
||||
const unsubscribe = window.electronAPI.onExternalModification((filePath: string) => {
|
||||
const tab = getActiveTab()
|
||||
if (tab && tab.filePath === filePath) {
|
||||
// AR-03: 直接更新 store 状态,不再派发 CustomEvent
|
||||
const tabs = useTabStore.getState().tabs
|
||||
if (tabs.some(t => t.filePath === filePath)) {
|
||||
setExternallyModified({ filePath })
|
||||
}
|
||||
})
|
||||
|
||||
return unsubscribe
|
||||
}, [getActiveTab, setExternallyModified])
|
||||
}, [setExternallyModified])
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export function useKeyboard(
|
||||
handleOpenFile: () => void,
|
||||
handleSave: () => void,
|
||||
handleSaveAs: () => void,
|
||||
handleShowSearch: () => void,
|
||||
) {
|
||||
const handleKeydown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
@@ -32,6 +33,12 @@ export function useKeyboard(
|
||||
handleSaveAs()
|
||||
return
|
||||
}
|
||||
// v0.6.2: 多文件搜索
|
||||
if (isCtrl && e.shiftKey && (e.key === 'F' || e.key === 'f')) {
|
||||
e.preventDefault()
|
||||
handleShowSearch()
|
||||
return
|
||||
}
|
||||
|
||||
const tabState = useTabStore.getState()
|
||||
if (isCtrl && e.key === 't') {
|
||||
@@ -64,7 +71,7 @@ export function useKeyboard(
|
||||
return
|
||||
}
|
||||
},
|
||||
[handleOpenFile, handleSave, handleSaveAs],
|
||||
[handleOpenFile, handleSave, handleSaveAs, handleShowSearch],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEditorStore } from '../stores/editorStore'
|
||||
import { useSidebarStore } from '../stores/sidebarStore'
|
||||
import { settingsRepository } from '../db/settingsRepository'
|
||||
import { logError } from '../lib/errorHandler'
|
||||
import { DEFAULT_SETTINGS } from '../types/settings'
|
||||
|
||||
/**
|
||||
* AR-04: 统一设置加载 hook
|
||||
@@ -21,20 +22,21 @@ export function useSettingsInit() {
|
||||
settingsRepository
|
||||
.load()
|
||||
.then(settings => {
|
||||
// 主题:优先使用保存的设置,否则跟随系统偏好
|
||||
// B11-fix: 首次启动(settings 为 null)时主题跟随系统偏好;
|
||||
// 有保存记录则使用保存值
|
||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
const themeMode = settings.themeMode ?? (prefersDark ? 'dark' : 'light')
|
||||
const themeMode = settings?.themeMode ?? (prefersDark ? 'dark' : 'light')
|
||||
setThemeMode(themeMode)
|
||||
|
||||
// 视图模式
|
||||
setViewMode(settings.viewMode ?? 'editor')
|
||||
setViewMode(settings?.viewMode ?? 'editor')
|
||||
|
||||
// Sidebar 设置(直接分发,避免 sidebarStore 再次读取 IndexedDB)
|
||||
const sidebarStore = useSidebarStore.getState()
|
||||
if (!sidebarStore._loaded) {
|
||||
useSidebarStore.setState({
|
||||
isVisible: !settings.sidebarCollapsed,
|
||||
sidebarWidth: settings.sidebarWidth,
|
||||
isVisible: settings ? !settings.sidebarCollapsed : true,
|
||||
sidebarWidth: settings?.sidebarWidth ?? DEFAULT_SETTINGS.sidebarWidth,
|
||||
_loaded: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -249,6 +249,32 @@ describe('tabStore', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabFilePath (B2)', () => {
|
||||
it('should rebind tab to a new file path (save-as)', () => {
|
||||
const { createTab, updateTabFilePath } = useTabStore.getState()
|
||||
const tab = createTab('/old.md', '# Content')
|
||||
|
||||
updateTabFilePath(tab.id, '/new.md')
|
||||
const state = useTabStore.getState()
|
||||
const updated = state.tabs.find(t => t.id === tab.id)!
|
||||
|
||||
expect(updated.filePath).toBe('/new.md')
|
||||
expect(updated.content).toBe('# Content')
|
||||
})
|
||||
|
||||
it('should not affect other tabs', () => {
|
||||
const { createTab, updateTabFilePath } = useTabStore.getState()
|
||||
const tab1 = createTab('/a.md')
|
||||
const tab2 = createTab('/b.md')
|
||||
|
||||
updateTabFilePath(tab1.id, '/c.md')
|
||||
const state = useTabStore.getState()
|
||||
|
||||
expect(state.tabs.find(t => t.id === tab1.id)?.filePath).toBe('/c.md')
|
||||
expect(state.tabs.find(t => t.id === tab2.id)?.filePath).toBe('/b.md')
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateTabContent same-content guard (A3)', () => {
|
||||
it('should not mark as modified when content is unchanged', () => {
|
||||
const { createTab, updateTabContent } = useTabStore.getState()
|
||||
|
||||
@@ -39,6 +39,7 @@ interface TabState {
|
||||
moveTab: (fromId: string, toIndex: number) => void
|
||||
switchToTab: (tabId: string) => void
|
||||
updateTabContent: (tabId: string, content: string) => void
|
||||
updateTabFilePath: (tabId: string, filePath: string) => void
|
||||
setModified: (tabId: string, modified: boolean) => void
|
||||
getActiveTab: () => Tab | null
|
||||
updateTabScroll: (
|
||||
@@ -101,11 +102,46 @@ export const useTabStore = create<TabState>((set, get) => {
|
||||
selectionStart: s.selectionStart,
|
||||
selectionEnd: s.selectionEnd,
|
||||
}))
|
||||
// v0.6.2: 磁盘 mtime 比对 — 未修改的标签若磁盘内容比快照新
|
||||
// (应用关闭期间被外部修改),恢复时读取磁盘最新内容
|
||||
let refreshed = 0
|
||||
if (typeof window !== 'undefined' && window.electronAPI) {
|
||||
for (const tab of tabs) {
|
||||
if (!tab.filePath || tab.isModified) continue
|
||||
const snap = snapshots.find(s => s.id === tab.id)
|
||||
if (!snap || !snap.updatedAt) continue
|
||||
try {
|
||||
const stats = await window.electronAPI.getFileStats(tab.filePath)
|
||||
if (
|
||||
stats.success &&
|
||||
stats.mtime &&
|
||||
new Date(stats.mtime).getTime() > snap.updatedAt
|
||||
) {
|
||||
const read = await window.electronAPI.readFile(tab.filePath)
|
||||
if (read.success && read.content !== undefined) {
|
||||
tab.content = read.content
|
||||
refreshed++
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 单个文件校验失败不影响整体恢复
|
||||
}
|
||||
}
|
||||
}
|
||||
const activeTabId =
|
||||
savedActiveTabId && tabs.find(t => t.id === savedActiveTabId)
|
||||
? savedActiveTabId
|
||||
: tabs[tabs.length - 1].id
|
||||
set({ tabs, activeTabId, _loaded: true })
|
||||
if (refreshed > 0) {
|
||||
_debouncedSaveToDB?.()
|
||||
try {
|
||||
const { showToast } = await import('../lib/toast')
|
||||
showToast(`${refreshed} 个标签已同步磁盘最新内容`, 'info')
|
||||
} catch {
|
||||
/* 提示失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
} else {
|
||||
set({ _loaded: true })
|
||||
}
|
||||
@@ -255,6 +291,14 @@ export const useTabStore = create<TabState>((set, get) => {
|
||||
}))
|
||||
},
|
||||
|
||||
// B2-fix: 另存为成功后重新绑定标签的文件路径(并触发快照持久化)
|
||||
updateTabFilePath: (tabId: string, filePath: string) => {
|
||||
set(state => ({
|
||||
tabs: state.tabs.map(t => (t.id === tabId ? { ...t, filePath } : t)),
|
||||
}))
|
||||
_debouncedSaveToDB?.()
|
||||
},
|
||||
|
||||
getActiveTab: (): Tab | null => {
|
||||
const { tabs, activeTabId } = get()
|
||||
return tabs.find(t => t.id === activeTabId) ?? null
|
||||
|
||||
@@ -942,6 +942,188 @@ button:focus-visible,
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ===== v0.6.2: 多文件搜索面板 ===== */
|
||||
.search-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
z-index: 10001;
|
||||
padding-top: 8vh;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.search-panel {
|
||||
background: var(--bg);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
|
||||
width: 640px;
|
||||
max-width: 92vw;
|
||||
max-height: 76vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
animation: fadeIn 0.15s ease;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.search-run-btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-run-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.search-options {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.search-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.search-option input {
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.search-close-btn {
|
||||
margin-left: auto;
|
||||
padding: 4px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-close-btn:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.search-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.search-empty {
|
||||
padding: 32px 16px;
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.search-summary {
|
||||
padding: 8px 14px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.search-results {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
font-family: var(--font-ui);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.search-result-file {
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
color: var(--primary);
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.search-result-line {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-tertiary);
|
||||
min-width: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.search-result-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ===== ErrorBoundary ===== */
|
||||
.error-boundary-root {
|
||||
display: flex;
|
||||
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
ReloadFileResult,
|
||||
FileStatsResult,
|
||||
ReadDirTreeResult,
|
||||
SearchInDirPayload,
|
||||
SearchInDirResult,
|
||||
} from '../../shared/types'
|
||||
|
||||
export interface IpcInvokeMap {
|
||||
@@ -24,6 +26,7 @@ export interface IpcInvokeMap {
|
||||
'dir:openDialog': [void, string | null]
|
||||
'dir:watch': [string, void]
|
||||
'dir:unwatch': [void, void]
|
||||
'dir:search': [SearchInDirPayload, SearchInDirResult]
|
||||
'data:export': [
|
||||
string,
|
||||
{ success: boolean; canceled?: boolean; filePath?: string; error?: string },
|
||||
@@ -49,6 +52,7 @@ export interface ElectronAPI {
|
||||
openFolderDialog: () => Promise<string | null>
|
||||
watchDir: (dirPath: string) => Promise<void>
|
||||
unwatchDir: () => Promise<void>
|
||||
searchInDir: (payload: SearchInDirPayload) => Promise<SearchInDirResult>
|
||||
exportData: (
|
||||
content: string,
|
||||
) => Promise<{ success: boolean; canceled?: boolean; filePath?: string; error?: string }>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 共享常量 — 主进程和渲染进程共用
|
||||
export const APP_VERSION = 'v0.6.1'
|
||||
export const APP_VERSION = 'v0.6.2'
|
||||
export const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB
|
||||
export const ALLOWED_EXTENSIONS = ['.md', '.markdown', '.txt'] as const
|
||||
export const SKIP_DIRS = new Set([
|
||||
|
||||
@@ -15,6 +15,7 @@ export const IPC_CHANNELS = {
|
||||
DIR_OPEN_DIALOG: 'dir:openDialog',
|
||||
DIR_WATCH: 'dir:watch',
|
||||
DIR_UNWATCH: 'dir:unwatch',
|
||||
DIR_SEARCH: 'dir:search',
|
||||
|
||||
// v0.6.0: 数据备份导出/导入(JSON)
|
||||
DATA_EXPORT: 'data:export',
|
||||
|
||||
@@ -63,3 +63,27 @@ export interface OpenFileError {
|
||||
}
|
||||
|
||||
export type OpenFileResponse = OpenFileResult | OpenFileError | null
|
||||
|
||||
// v0.6.2: 多文件搜索
|
||||
export interface SearchInDirPayload {
|
||||
dirPath: string
|
||||
query: string
|
||||
caseSensitive?: boolean
|
||||
useRegex?: boolean
|
||||
}
|
||||
|
||||
export interface SearchMatch {
|
||||
filePath: string
|
||||
/** 1-based 行号 */
|
||||
line: number
|
||||
/** 匹配行内容(截断至 200 字符) */
|
||||
lineText: string
|
||||
}
|
||||
|
||||
export interface SearchInDirResult {
|
||||
success: boolean
|
||||
matches?: SearchMatch[]
|
||||
totalFiles?: number
|
||||
truncated?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user