- 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
239 lines
7.7 KiB
TypeScript
239 lines
7.7 KiB
TypeScript
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,
|
||
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)
|
||
|
||
export async function readFileContent(filePath: string): Promise<ReadFileResult> {
|
||
try {
|
||
const fileStat = await stat(filePath)
|
||
if (fileStat.size > MAX_FILE_SIZE) {
|
||
return {
|
||
success: false,
|
||
error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件`,
|
||
}
|
||
}
|
||
// L-01: 检测并剥离 BOM(UTF-8 FEFF / UTF-16 LE FFFE / UTF-16 BE FEFF)
|
||
const buffer = await readFile(filePath)
|
||
let content: string
|
||
if (buffer.length >= 2 && buffer[0] === 0xfe && buffer[1] === 0xff) {
|
||
// UTF-16 BE: swap bytes to LE 再解码
|
||
const swapped = Buffer.allocUnsafe(buffer.length)
|
||
buffer.copy(swapped)
|
||
swapped.swap16()
|
||
content = swapped.toString('utf-16le')
|
||
if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
|
||
} else if (buffer.length >= 2 && buffer[0] === 0xff && buffer[1] === 0xfe) {
|
||
// UTF-16 LE
|
||
content = buffer.toString('utf-16le')
|
||
if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
|
||
} else {
|
||
// UTF-8 (含 FEFF BOM 剥离)
|
||
content = buffer.toString('utf-8')
|
||
if (content.charCodeAt(0) === 0xfeff) content = content.slice(1)
|
||
}
|
||
return { success: true, content }
|
||
} catch (err) {
|
||
return { success: false, error: (err as Error).message }
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
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) {
|
||
// 清理残留临时文件
|
||
try {
|
||
await unlink(tmpFile)
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return { success: false, error: (err as Error).message }
|
||
}
|
||
}
|
||
|
||
// M-05: 递归深度限制 + 错误边界 + 符号链接循环检测
|
||
export async function buildDirTree(
|
||
dirPath: string,
|
||
depth = 0,
|
||
maxDepth = 10,
|
||
visited?: Set<string>,
|
||
): Promise<FileNode[]> {
|
||
if (depth > maxDepth) return []
|
||
|
||
if (!visited) visited = new Set<string>()
|
||
|
||
// 检测符号链接循环
|
||
let realPath: string
|
||
try {
|
||
const ls = await lstat(dirPath)
|
||
if (ls.isSymbolicLink()) return []
|
||
realPath = await realpath(dirPath)
|
||
} catch {
|
||
return []
|
||
}
|
||
if (visited.has(realPath)) return []
|
||
visited.add(realPath)
|
||
|
||
let entries
|
||
try {
|
||
entries = await readdir(dirPath, { withFileTypes: true })
|
||
} catch {
|
||
return []
|
||
}
|
||
|
||
entries.sort((a, b) => {
|
||
if (a.isDirectory() && !b.isDirectory()) return -1
|
||
if (!a.isDirectory() && b.isDirectory()) return 1
|
||
return a.name.localeCompare(b.name)
|
||
})
|
||
|
||
const children: FileNode[] = []
|
||
for (const entry of entries) {
|
||
if (SKIP_DIRS.has(entry.name)) continue
|
||
if (entry.name.startsWith('.')) continue
|
||
|
||
const childPath = join(dirPath, entry.name)
|
||
if (entry.isDirectory()) {
|
||
const subChildren = await buildDirTree(childPath, depth + 1, maxDepth, visited)
|
||
if (subChildren.length > 0) {
|
||
children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren })
|
||
}
|
||
} else {
|
||
const ext = extname(entry.name).toLowerCase()
|
||
if (ALLOWED_EXTENSIONS_SET.has(ext)) {
|
||
children.push({ name: entry.name, path: childPath, type: 'file' })
|
||
}
|
||
}
|
||
}
|
||
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 }
|
||
}
|
||
}
|