Files
MarkLite/src/main/file-system.ts
T
thzxx 073978d3ca 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
2026-08-15 21:18:05 +08:00

239 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: 检测并剥离 BOMUTF-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 BEBOM + 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 LEBOM + 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 }
}
}