diff --git a/src/main/file-system.ts b/src/main/file-system.ts index 9148729..caf5c80 100644 --- a/src/main/file-system.ts +++ b/src/main/file-system.ts @@ -1,6 +1,5 @@ -import { readFile, stat, writeFile, readdir, rename } from 'fs/promises' -import { join, extname, basename } from 'path' -import { tmpdir } from 'os' +import { readFile, stat, writeFile, rename, readdir, unlink } from 'fs/promises' +import { join, extname, dirname, basename } from 'path' import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types' const MAX_FILE_SIZE = 20 * 1024 * 1024 // 20MB @@ -10,7 +9,6 @@ const SKIP_DIRS = new Set([ '.next', '.nuxt', '__pycache__', '.DS_Store' ]) -// L-09: 使用 shared 类型 export async function readFileContent(filePath: string): Promise { try { const fileStat = await stat(filePath) @@ -18,7 +16,6 @@ export async function readFileContent(filePath: string): Promise return { success: false, error: `文件过大(${(fileStat.size / 1024 / 1024).toFixed(1)} MB),暂不支持超过 20MB 的文件` } } let content = await readFile(filePath, 'utf-8') - // 剥离 UTF-8 BOM if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) return { success: true, content } } catch (err) { @@ -26,20 +23,21 @@ export async function readFileContent(filePath: string): Promise } } -// M-06: 原子写入(write-to-temp-then-rename) +// C-02: 临时文件写到与目标相同目录(避免跨盘 rename 失败) export async function saveFileContent(filePath: string, content: string): Promise { + const tmpFile = join(dirname(filePath), `.marklite-tmp-${Date.now()}-${basename(filePath)}`) try { - const dir = require('path').dirname(filePath) - const tmpFile = join(tmpdir(), `marklite-${Date.now()}-${basename(filePath)}`) await writeFile(tmpFile, content, 'utf-8') 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: 添加递归深度限制 + 每个 entry 错误边界 +// M-05: 递归深度限制 + 错误边界 export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): Promise { if (depth > maxDepth) return [] @@ -47,7 +45,7 @@ export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): P try { entries = await readdir(dirPath, { withFileTypes: true }) } catch { - return [] // L-10: 权限错误时返回空数组而非抛异常 + return [] } entries.sort((a, b) => { diff --git a/src/main/file-watcher.ts b/src/main/file-watcher.ts index 1c752b1..392d843 100644 --- a/src/main/file-watcher.ts +++ b/src/main/file-watcher.ts @@ -22,10 +22,13 @@ export class FileWatcher { } } }) - // M-04: 监听 error 事件,文件被删除时清理状态 + // L-02: 监听 error 事件,文件被删除时显式关闭并清理状态 this.watcher.on('error', () => { + if (this.watcher) { + this.watcher.close() + this.watcher = null + } this.currentPath = null - this.watcher = null }) } catch { // silently ignore @@ -74,9 +77,12 @@ export class SidebarWatcher { }, 300) } }) - // M-04: 监听 error 事件 + // L-02: 监听 error 事件,显式关闭 this.watcher.on('error', () => { - this.watcher = null + if (this.watcher) { + this.watcher.close() + this.watcher = null + } this.watchPath = null }) } catch { diff --git a/src/main/index.ts b/src/main/index.ts index fa8f9ba..ec94e21 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -30,7 +30,6 @@ function openFileInTab(filePath: string): void { }) } -// 单实例锁 const lockOk = setupSingleInstanceLock((filePath) => { if (mainWindow && !mainWindow.isDestroyed()) { if (mainWindow.isMinimized()) mainWindow.restore() @@ -72,11 +71,15 @@ if (!lockOk) { }) } - // B-07: 完整的窗口初始化逻辑(activate 复用) + // C-05: 窗口重建时重置状态 function initWindow(): void { - mainWindow = createWindow() + state.isClosing = false + if (state.closeTimeout) { + clearTimeout(state.closeTimeout) + state.closeTimeout = null + } - registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state) + mainWindow = createWindow() if (process.env.ELECTRON_RENDERER_URL) { mainWindow.loadURL(process.env.ELECTRON_RENDERER_URL) @@ -112,9 +115,11 @@ if (!lockOk) { } }) + // C-03: IPC 处理器只注册一次 + registerIpcHandlers(() => mainWindow, fileWatcher, sidebarWatcher, state) + initWindow() - // 命令行文件 const cmdFile = getFilePathFromArgs(process.argv) if (cmdFile) { state.pendingFilePath = cmdFile @@ -125,7 +130,7 @@ if (!lockOk) { if (process.platform !== 'darwin') app.quit() }) - // B-07: macOS activate 完整重建窗口 + // C-03: activate 只重建窗口,不重复注册 IPC app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { initWindow() diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index fae158c..57e3b3d 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -19,18 +19,25 @@ export function Sidebar() { const isVisible = useSidebarStore(s => s.isVisible) const expandDirs = useSidebarStore(s => s.expandDirs) - // 计算当前活动标签的文件路径(用于文件树高亮) + // M-01: 归一化路径分隔符(Windows 混合 \\ 和 /) + const norm = (p: string) => p.replace(/\\/g, '/') const activeFilePath = tabs.find(t => t.id === activeTabId)?.filePath ?? null // 自动展开到活动文件所在的目录 useEffect(() => { if (!activeFilePath || !rootPath) return - if (!activeFilePath.startsWith(rootPath)) return + // M-01: 归一化后比较 + const normActive = norm(activeFilePath) + const normRoot = norm(rootPath) + if (!normActive.startsWith(normRoot)) return // 提取文件的所有父目录路径 const dirsToExpand: string[] = [] let dir = activeFilePath.replace(/[/\\][^/\\]+$/, '') // 移除文件名 - while (dir && dir.length >= rootPath.length && dir !== rootPath) { + // M-02: 添加 prev 防护无限循环 + let prev = '' + while (dir && dir.length >= rootPath.length && dir !== rootPath && dir !== prev) { + prev = dir dirsToExpand.push(dir) dir = dir.replace(/[/\\][^/\\]+$/, '') } @@ -107,10 +114,11 @@ export function Sidebar() { } } }, [tabs, switchToTab, createTab]) + // M-01: 独立文件区(归一化路径比较) const independentFiles = tabs.filter(t => { if (!t.filePath) return false if (!rootPath) return true - return !t.filePath.startsWith(rootPath) + return !norm(t.filePath).startsWith(norm(rootPath)) }) if (!isVisible) return null diff --git a/src/renderer/components/TabBar/TabBar.tsx b/src/renderer/components/TabBar/TabBar.tsx index 75b3765..4bf2375 100644 --- a/src/renderer/components/TabBar/TabBar.tsx +++ b/src/renderer/components/TabBar/TabBar.tsx @@ -33,11 +33,15 @@ export function TabBar() { closeTab(tabId) }, [tabs, closeTab]) - // 右键菜单 + // C-06: 右键菜单(带边界修正) const handleContextMenu = useCallback((e: React.MouseEvent, tabId: string) => { e.preventDefault() e.stopPropagation() - setMenu({ visible: true, x: e.clientX, y: e.clientY, tabId }) + const MENU_WIDTH = 170 + const MENU_HEIGHT = 140 + const x = Math.min(e.clientX, window.innerWidth - MENU_WIDTH) + const y = Math.min(e.clientY, window.innerHeight - MENU_HEIGHT) + setMenu({ visible: true, x: Math.max(0, x), y: Math.max(0, y), tabId }) }, []) // 点击空白处关闭菜单 @@ -147,9 +151,12 @@ export function TabBar() {
关闭
-
- 关闭其他标签 -
+ {/* M-03: 单标签时隐藏"关闭其他标签" */} + {tabs.length > 1 && ( +
+ 关闭其他标签 +
+ )} {hasRightTabs && (
关闭右侧标签 diff --git a/src/renderer/db/settingsRepository.ts b/src/renderer/db/settingsRepository.ts index 51dbcea..c135e7c 100644 --- a/src/renderer/db/settingsRepository.ts +++ b/src/renderer/db/settingsRepository.ts @@ -20,9 +20,10 @@ export const settingsRepository = { return { ...DEFAULT_SETTINGS } }, - // M-09: 使用 put 直接合并,避免读写竞争 - async save(settings: Partial): Promise { - const record: Partial = { id: 'default', ...settings } - await db.settings.put(record as SettingsRecord) + // C-01: 先 load 再 merge 再 put,避免部分字段丢失 + async save(partial: Partial): Promise { + const current = await this.load() + const merged: SettingsRecord = { id: 'default', ...current, ...partial } + await db.settings.put(merged) } } diff --git a/src/renderer/hooks/useFileWatch.ts b/src/renderer/hooks/useFileWatch.ts index b2445ea..093ea66 100644 --- a/src/renderer/hooks/useFileWatch.ts +++ b/src/renderer/hooks/useFileWatch.ts @@ -1,9 +1,5 @@ import { useEffect } from 'react' import { useTabStore } from '../stores/tabStore' -import { useSearchStore } from '../stores/searchStore' -import { useEditorStore } from '../stores/editorStore' -import { findMatches } from '../lib/searchEngine' -import type { SearchMatch } from '../types/search' export function useFileWatch() { const getActiveTab = useTabStore(s => s.getActiveTab) @@ -14,7 +10,6 @@ export function useFileWatch() { window.electronAPI.onExternalModification((filePath: string) => { const tab = getActiveTab() if (tab && tab.filePath === filePath) { - // 显示修改横幅(通过状态管理触发 UI 更新) window.dispatchEvent(new CustomEvent('file-externally-modified', { detail: filePath })) } }) @@ -24,40 +19,3 @@ export function useFileWatch() { } }, [getActiveTab]) } - -// 搜索 Hook -export function useSearch() { - const store = useSearchStore() - - const doSearch = (content: string) => { - if (!store.searchText) { - store.setMatches([]) - return - } - const matches = findMatches(content, store.searchText, store.options) - store.setMatches(matches) - if (matches.length > 0) { - // 找到离光标最近的匹配 - store.setCurrentIndex(0) - } - } - - const replaceCurrent = (content: string): string | null => { - if (store.matches.length === 0 || store.currentIndex < 0) return null - const m = store.matches[store.currentIndex] - return content.substring(0, m.start) + store.replaceText + content.substring(m.end) - } - - // B-02: 全部替换(从后向前逐个替换) - const replaceAll = (content: string): string => { - if (store.matches.length === 0) return content - let result = content - for (let i = store.matches.length - 1; i >= 0; i--) { - const m = store.matches[i] - result = result.substring(0, m.start) + store.replaceText + result.substring(m.end) - } - return result - } - - return { ...store, doSearch, replaceCurrent, replaceAll } -} diff --git a/src/renderer/lib/markdown.ts b/src/renderer/lib/markdown.ts index f80ec09..924f2ec 100644 --- a/src/renderer/lib/markdown.ts +++ b/src/renderer/lib/markdown.ts @@ -23,6 +23,8 @@ function rehypeFixImages(filePath: string | null): Plugin<[], Root> { if (child.type === 'element' && child.tagName === 'img') { const src = child.properties?.src as string | undefined if (src && !src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('file://')) { + // C-04: 拒绝路径遍历攻击 + if (src.includes('..')) return // 相对路径转绝对路径 child.properties = { ...child.properties, diff --git a/src/renderer/stores/tabStore.ts b/src/renderer/stores/tabStore.ts index 8db67d3..799ed39 100644 --- a/src/renderer/stores/tabStore.ts +++ b/src/renderer/stores/tabStore.ts @@ -69,8 +69,15 @@ export const useTabStore = create((set, get) => ({ if (newTabs.length === 0) { newActiveId = null } else { - const newIndex = Math.min(index, newTabs.length - 1) - newActiveId = newTabs[newIndex].id + // M-05: 优先从 MRU 栈取最近使用的标签 + const mruCandidate = newMru.find(id => newTabs.some(t => t.id === id)) + if (mruCandidate) { + newActiveId = mruCandidate + newMru.splice(newMru.indexOf(mruCandidate), 1) + } else { + const newIndex = Math.min(index, newTabs.length - 1) + newActiveId = newTabs[newIndex].id + } } } diff --git a/src/renderer/styles/global.css b/src/renderer/styles/global.css index e100eba..9a72ac1 100644 --- a/src/renderer/styles/global.css +++ b/src/renderer/styles/global.css @@ -269,7 +269,8 @@ html, body { } .tab-item:hover .tab-close, -.tab-item.active .tab-close { opacity: 1; } +.tab-item.active .tab-close, +.tab-item.modified .tab-close { opacity: 1; } .tab-close:hover { background: var(--border); @@ -371,6 +372,11 @@ html, body { color: #000; } +:root.dark .banner-btn:hover { + background: #665500; + color: #ffd54f; +} + /* Status Bar */ #statusbar { height: var(--statusbar-height);