From dd78ff15a955fc8c66ed91802b5482e213995001 Mon Sep 17 00:00:00 2001 From: thzxx Date: Mon, 15 Jun 2026 15:02:38 +0800 Subject: [PATCH] =?UTF-8?q?v0.3.7:=20=E5=85=A8=E9=9D=A2=E4=BB=A3=E7=A0=81?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=BF=AE=E5=A4=8D=20-=2020=E9=A1=B9Bug/?= =?UTF-8?q?=E5=AE=89=E5=85=A8/=E7=A8=B3=E5=AE=9A=E6=80=A7=E6=94=B9?= =?UTF-8?q?=E8=BF=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- src/main/file-system.ts | 27 +++++++++++--- src/main/file-watcher.ts | 2 +- src/main/ipc-handlers.ts | 6 ++-- .../components/AboutDialog/AboutDialog.tsx | 3 +- .../ConfirmDialog/ConfirmDialog.tsx | 5 ++- src/renderer/components/Editor/Editor.tsx | 29 +++++++++++++-- .../ErrorBoundary/ErrorBoundary.tsx | 30 +++++++++------- src/renderer/components/FileTree/FileTree.tsx | 2 -- src/renderer/components/Sidebar/Sidebar.tsx | 4 +-- .../components/SourceEditor/SourceEditor.tsx | 36 +++++++++++++++++++ src/renderer/components/TabBar/TabBar.tsx | 1 + src/renderer/hooks/useAutoExpandDir.ts | 5 +-- src/renderer/hooks/useAutoSave.ts | 28 ++++++++++----- src/renderer/hooks/useDragDrop.ts | 4 ++- src/renderer/hooks/useToast.ts | 8 +++-- src/renderer/lib/constants.ts | 2 +- src/renderer/lib/markdown.ts | 11 ++++-- src/renderer/stores/sidebarStore.ts | 19 +--------- src/shared/constants.ts | 1 + 20 files changed, 158 insertions(+), 67 deletions(-) diff --git a/package.json b/package.json index ab44c59..9c89ee8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "marklite", - "version": "0.3.6", + "version": "0.3.7", "description": "Lightweight Markdown Editor for Windows", "main": "./dist/main/index.js", "scripts": { diff --git a/src/main/file-system.ts b/src/main/file-system.ts index fe542d5..ba0dfb1 100644 --- a/src/main/file-system.ts +++ b/src/main/file-system.ts @@ -1,4 +1,4 @@ -import { readFile, stat, writeFile, rename, readdir, unlink } from 'fs/promises' +import { readFile, stat, writeFile, rename, readdir, unlink, lstat, realpath } from 'fs/promises' import { join, extname, dirname, basename } from 'path' import type { ReadFileResult, SaveFileResult, FileNode } from '../shared/types' import { MAX_FILE_SIZE, ALLOWED_EXTENSIONS, SKIP_DIRS } from '../shared/constants' @@ -33,10 +33,29 @@ export async function saveFileContent(filePath: string, content: string): Promis } } -// M-05: 递归深度限制 + 错误边界 -export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): Promise { +// M-05: 递归深度限制 + 错误边界 + 符号链接循环检测 +export async function buildDirTree( + dirPath: string, + depth = 0, + maxDepth = 10, + visited?: Set +): Promise { if (depth > maxDepth) return [] + if (!visited) visited = new Set() + + // 检测符号链接循环 + 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 }) @@ -57,7 +76,7 @@ export async function buildDirTree(dirPath: string, depth = 0, maxDepth = 10): P const childPath = join(dirPath, entry.name) if (entry.isDirectory()) { - const subChildren = await buildDirTree(childPath, depth + 1, maxDepth) + const subChildren = await buildDirTree(childPath, depth + 1, maxDepth, visited) if (subChildren.length > 0) { children.push({ name: entry.name, path: childPath, type: 'dir', children: subChildren }) } diff --git a/src/main/file-watcher.ts b/src/main/file-watcher.ts index 50fa193..4bc3792 100644 --- a/src/main/file-watcher.ts +++ b/src/main/file-watcher.ts @@ -24,8 +24,8 @@ export class FileWatcher { }) // L-02: 监听 error 事件,文件被删除时显式关闭并清理状态 this.watcher.on('error', () => { - this.stop() const originalPath = this.currentPath + this.stop() if (originalPath) { const pollInterval = setInterval(() => { try { diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index a0e3265..9154461 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -66,10 +66,12 @@ export function registerIpcHandlers( const win = getMainWindow() try { if (data.filePath) { - fileWatcher.stop() + // Mark self-writing before stopping watcher to suppress any events + // that arrive between stop and start fileWatcher.setSelfWriting(true) + fileWatcher.stop() const result = await saveFileContent(data.filePath, data.content) - // start watcher while selfWriting is still true so any immediate 'change' + // Restart watcher while selfWriting is still true so any immediate 'change' // event from the restart is suppressed — prevents overwriting editor content fileWatcher.start(data.filePath) fileWatcher.setSelfWriting(false) diff --git a/src/renderer/components/AboutDialog/AboutDialog.tsx b/src/renderer/components/AboutDialog/AboutDialog.tsx index 5c0f686..93a212d 100644 --- a/src/renderer/components/AboutDialog/AboutDialog.tsx +++ b/src/renderer/components/AboutDialog/AboutDialog.tsx @@ -1,7 +1,6 @@ import React from 'react' import { AppIcon, Gitee } from '../Icons' - -const APP_VERSION = 'v0.3.6' +import { APP_VERSION } from '../../lib/constants' interface AboutDialogProps { onClose: () => void diff --git a/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx b/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx index 47612af..5d41575 100644 --- a/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx +++ b/src/renderer/components/ConfirmDialog/ConfirmDialog.tsx @@ -27,7 +27,10 @@ export const ConfirmDialog = React.memo(function ConfirmDialog({ // 打开时保存焦点并聚焦确认按钮;关闭时恢复焦点 useEffect(() => { if (!open) { - previousFocusRef.current?.focus() + // Only restore focus if the element is still in the DOM + if (previousFocusRef.current && previousFocusRef.current.isConnected) { + previousFocusRef.current.focus() + } return } diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index 9022f6b..795cec0 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -44,16 +44,39 @@ export const Editor = React.memo(function Editor({ darkMode }: EditorProps) { if (!activeTab) return if (activeTab.content !== currentContentRef.current) { currentContentRef.current = activeTab.content - setContent(activeTab.content) + // Queue setContent after Milkdown editor has finished async create() requestAnimationFrame(() => { - setScrollTop(activeTab.scrollTop) - setSelection() + setContent(activeTab.content) + requestAnimationFrame(() => { + setScrollTop(activeTab.scrollTop) + setSelection() + }) }) } // stable refs: setContent, setScrollTop, setSelection are useCallback([]) - never change // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTabId]) + // Save current tab state on unmount or tab switch + useEffect(() => { + return () => { + if (!activeTabId) return + // Snapshot the current editor state before the new tab replaces content + const scrollPos = getScrollTop() + const sel = getSelection() + // Use a microtask to ensure we save before React re-renders the new tab content + Promise.resolve().then(() => { + updateTabScroll(activeTabId, { + scrollTop: scrollPos, + selectionStart: sel.from, + selectionEnd: sel.to + }) + }) + } + // stable refs: getScrollTop, getSelection (useCallback([])), updateTabScroll (zustand) - never change + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTabId]) + // Register getView for OutlinePanel navigation useEffect(() => { setEditorViewGetter(getView) diff --git a/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx b/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx index 3d5724e..ef9f706 100644 --- a/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx +++ b/src/renderer/components/ErrorBoundary/ErrorBoundary.tsx @@ -35,6 +35,8 @@ export class ErrorBoundary extends Component { return this.props.fallback } + const isDev = (typeof import.meta !== 'undefined' && (import.meta as { env?: { DEV?: boolean } }).env?.DEV) ?? false + return (
{

应用遇到了错误

-
-            {this.state.error?.message}
-          
+ {isDev && ( +
+              {this.state.error?.message}
+            
+ )}