Просмотр исходного кода

fix(editor): 修复磁盘同步在编辑后长期失效

清除 autosave timer ref 泄漏,章节路径统一 normalize 比较;
恢复 file watcher 触发同步,focus/visibility 即时刷新,
外部变更时 remount WikiEditor 以更新 Milkdown。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 месяцев назад
Родитель
Сommit
f583bbf7fb

+ 47 - 22
src/components/layout/preview-panel.tsx

@@ -52,6 +52,7 @@ import {
   type ChapterSelectionAction,
   type ChapterSelectionAction,
 } from "@/lib/chapter-selection"
 } from "@/lib/chapter-selection"
 import { shouldApplyDiskToEditor } from "@/lib/editor-disk-sync"
 import { shouldApplyDiskToEditor } from "@/lib/editor-disk-sync"
+import { registerEditorDiskSyncHandler } from "@/lib/editor-disk-sync-session"
 
 
 const SnapshotViewer = lazy(async () => {
 const SnapshotViewer = lazy(async () => {
   const mod = await import("@/components/novel/snapshot-viewer")
   const mod = await import("@/components/novel/snapshot-viewer")
@@ -128,6 +129,10 @@ function normalizeChapterWriting(markdown: string): string {
   return formatWritingBodyWithIndent(syncChapterFrontmatterTitle(markdown))
   return formatWritingBodyWithIndent(syncChapterFrontmatterTitle(markdown))
 }
 }
 
 
+function getDiskSyncNormalize(path: string): (content: string) => string {
+  return isChapterPath(path) ? normalizeChapterWriting : (content) => content
+}
+
 function updateChapterHeading(markdown: string, nextTitle: string): string {
 function updateChapterHeading(markdown: string, nextTitle: string): string {
   const { rawBlock, body } = parseFrontmatter(markdown)
   const { rawBlock, body } = parseFrontmatter(markdown)
   const normalizedTitle = nextTitle.trim()
   const normalizedTitle = nextTitle.trim()
@@ -228,6 +233,7 @@ export function PreviewPanel() {
   const [chapterToolbarCompact, setChapterToolbarCompact] = useState(true)
   const [chapterToolbarCompact, setChapterToolbarCompact] = useState(true)
   const [chapterToolbarMoreOpen, setChapterToolbarMoreOpen] = useState(false)
   const [chapterToolbarMoreOpen, setChapterToolbarMoreOpen] = useState(false)
   const [loadedFilePath, setLoadedFilePath] = useState<string | null>(null)
   const [loadedFilePath, setLoadedFilePath] = useState<string | null>(null)
+  const [diskSyncEpoch, setDiskSyncEpoch] = useState(0)
   // Snapshot of what was most recently loaded from disk. Milkdown re-emits
   // Snapshot of what was most recently loaded from disk. Milkdown re-emits
   // `markdownUpdated` on initial parse (before the user types anything),
   // `markdownUpdated` on initial parse (before the user types anything),
   // which used to trigger an auto-save that could write back a placeholder
   // which used to trigger an auto-save that could write back a placeholder
@@ -271,12 +277,14 @@ export function PreviewPanel() {
     const editorContent = wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current
     const editorContent = wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current
     const lastLoaded = lastLoadedByPathRef.current.get(normalizedPath) ?? lastLoadedRef.current
     const lastLoaded = lastLoadedByPathRef.current.get(normalizedPath) ?? lastLoadedRef.current
     const hasPendingSave = saveTimerRef.current != null
     const hasPendingSave = saveTimerRef.current != null
+    const normalize = getDiskSyncNormalize(normalizedPath)
 
 
     if (!shouldApplyDiskToEditor({
     if (!shouldApplyDiskToEditor({
       lastLoaded,
       lastLoaded,
       editorContent,
       editorContent,
       diskContent,
       diskContent,
       hasPendingSave,
       hasPendingSave,
+      normalize,
     })) {
     })) {
       return false
       return false
     }
     }
@@ -285,6 +293,7 @@ export function PreviewPanel() {
     fileContentRef.current = diskContent
     fileContentRef.current = diskContent
     if (selectedFileRef.current && normalizePath(selectedFileRef.current) === normalizedPath) {
     if (selectedFileRef.current && normalizePath(selectedFileRef.current) === normalizedPath) {
       setFileContent(diskContent)
       setFileContent(diskContent)
+      setDiskSyncEpoch((epoch) => epoch + 1)
     }
     }
     return true
     return true
   }, [rememberLoadedChapter, setFileContent])
   }, [rememberLoadedChapter, setFileContent])
@@ -295,6 +304,11 @@ export function PreviewPanel() {
     await applyDiskSyncIfSafe(path)
     await applyDiskSyncIfSafe(path)
   }, [applyDiskSyncIfSafe])
   }, [applyDiskSyncIfSafe])
 
 
+  useEffect(() => {
+    registerEditorDiskSyncHandler(applyDiskSyncIfSafe)
+    return () => registerEditorDiskSyncHandler(null)
+  }, [applyDiskSyncIfSafe])
+
   useEffect(() => {
   useEffect(() => {
     setChapterDeAiSkillId(undefined)
     setChapterDeAiSkillId(undefined)
     deAiSkillMemoryWarningRef.current = ""
     deAiSkillMemoryWarningRef.current = ""
@@ -460,13 +474,22 @@ export function PreviewPanel() {
     if (category !== "markdown" || isBinary(category)) return
     if (category !== "markdown" || isBinary(category)) return
 
 
     const normalizedPath = normalizePath(selectedFile)
     const normalizedPath = normalizePath(selectedFile)
-    const tick = () => {
+    const syncNow = () => {
       if (normalizePath(selectedFileRef.current ?? "") !== normalizedPath) return
       if (normalizePath(selectedFileRef.current ?? "") !== normalizedPath) return
       void applyDiskSyncIfSafe(normalizedPath)
       void applyDiskSyncIfSafe(normalizedPath)
     }
     }
 
 
-    const intervalId = setInterval(tick, 2000)
-    return () => clearInterval(intervalId)
+    const intervalId = setInterval(syncNow, 2000)
+    const onVisibilityChange = () => {
+      if (document.visibilityState === "visible") syncNow()
+    }
+    window.addEventListener("focus", syncNow)
+    document.addEventListener("visibilitychange", onVisibilityChange)
+    return () => {
+      clearInterval(intervalId)
+      window.removeEventListener("focus", syncNow)
+      document.removeEventListener("visibilitychange", onVisibilityChange)
+    }
   }, [selectedFile, applyDiskSyncIfSafe])
   }, [selectedFile, applyDiskSyncIfSafe])
 
 
   useEffect(() => {
   useEffect(() => {
@@ -494,31 +517,33 @@ export function PreviewPanel() {
       const generation = saveGenerationRef.current
       const generation = saveGenerationRef.current
       saveTimerRef.current = setTimeout(() => {
       saveTimerRef.current = setTimeout(() => {
         void (async () => {
         void (async () => {
-          if (generation !== saveGenerationRef.current) return
-          if (pathAtSave !== selectedFileRef.current) return
-          if (normalizePath(pathAtSave) !== normalizedPath) return
-
-          let diskContent: string
           try {
           try {
-            diskContent = await readFile(normalizedPath)
-          } catch (err) {
-            console.error("保存前读取磁盘失败:", err)
-            return
-          }
+            if (generation !== saveGenerationRef.current) return
+            if (pathAtSave !== selectedFileRef.current) return
+            if (normalizePath(pathAtSave) !== normalizedPath) return
+
+            let diskContent: string
+            try {
+              diskContent = await readFile(normalizedPath)
+            } catch (err) {
+              console.error("保存前读取磁盘失败:", err)
+              return
+            }
+
+            const currentLastLoaded = lastLoadedByPathRef.current.get(normalizedPath) ?? lastLoadedRef.current
+            const normalize = getDiskSyncNormalize(normalizedPath)
+            if (normalize(diskContent) !== normalize(currentLastLoaded)) {
+              await applyDiskSyncIfSafe(normalizedPath)
+              return
+            }
 
 
-          const currentLastLoaded = lastLoadedByPathRef.current.get(normalizedPath) ?? lastLoadedRef.current
-          const normalize = isChapterPath(normalizedPath) ? normalizeChapterWriting : (content: string) => content
-          if (normalize(diskContent) !== normalize(currentLastLoaded)) {
-            void applyDiskSyncIfSafe(normalizedPath)
-            return
-          }
-
-          try {
             await writeFileAtomic(pathAtSave, persistedMarkdown)
             await writeFileAtomic(pathAtSave, persistedMarkdown)
             rememberLoadedChapter(normalizedPath, persistedMarkdown)
             rememberLoadedChapter(normalizedPath, persistedMarkdown)
             bumpDataVersion()
             bumpDataVersion()
           } catch (err) {
           } catch (err) {
             console.error("保存失败:", err)
             console.error("保存失败:", err)
+          } finally {
+            saveTimerRef.current = null
           }
           }
         })()
         })()
       }, 1000)
       }, 1000)
@@ -1555,7 +1580,7 @@ export function PreviewPanel() {
         {category === "markdown" ? (
         {category === "markdown" ? (
           <WikiEditor
           <WikiEditor
             ref={wikiEditorRef}
             ref={wikiEditorRef}
-            key={selectedFile}
+            key={`${selectedFile}:${diskSyncEpoch}`}
             content={fileContent}
             content={fileContent}
             onSave={handleSave}
             onSave={handleSave}
             defaultMode={inferEditorMode(selectedFile)}
             defaultMode={inferEditorMode(selectedFile)}

+ 12 - 0
src/lib/editor-disk-sync-session.ts

@@ -0,0 +1,12 @@
+type DiskSyncHandler = (path: string) => Promise<boolean>
+
+let activeHandler: DiskSyncHandler | null = null
+
+export function registerEditorDiskSyncHandler(handler: DiskSyncHandler | null): void {
+  activeHandler = handler
+}
+
+export async function requestEditorDiskSyncIfSafe(path: string): Promise<boolean> {
+  if (!activeHandler) return false
+  return activeHandler(path)
+}

+ 15 - 0
src/lib/editor-disk-sync.spec.ts

@@ -44,4 +44,19 @@ describe("editor-disk-sync", () => {
       diskContent: DISK_V1,
       diskContent: DISK_V1,
     })).toBe(false)
     })).toBe(false)
   })
   })
+
+  it("ignores formatting-only drift when normalize matches", () => {
+    const normalize = (content: string) => content.trim()
+    expect(hasUnsavedLocalEdits({
+      lastLoaded: "hello",
+      editorContent: " hello ",
+      normalize,
+    })).toBe(false)
+    expect(shouldApplyDiskToEditor({
+      lastLoaded: "hello",
+      editorContent: " hello ",
+      diskContent: "world",
+      normalize,
+    })).toBe(true)
+  })
 })
 })

+ 12 - 1
src/lib/project-file-sync.ts

@@ -20,6 +20,7 @@ import {
   isIngestableSourcePath,
   isIngestableSourcePath,
 } from "@/lib/source-lifecycle"
 } from "@/lib/source-lifecycle"
 import { isPathAllowedBySourceWatch, normalizeSourceWatchConfig } from "@/lib/source-watch-config"
 import { isPathAllowedBySourceWatch, normalizeSourceWatchConfig } from "@/lib/source-watch-config"
+import { requestEditorDiskSyncIfSafe } from "@/lib/editor-disk-sync-session"
 
 
 let unlistenQueue: UnlistenFn | null = null
 let unlistenQueue: UnlistenFn | null = null
 let unlistenChanged: UnlistenFn | null = null
 let unlistenChanged: UnlistenFn | null = null
@@ -141,7 +142,7 @@ async function processFileChangeBatch(
   await refreshAfterFileChanges(project, paths)
   await refreshAfterFileChanges(project, paths)
 }
 }
 
 
-async function refreshAfterFileChanges(project: WikiProject, _relativePaths: string[]): Promise<void> {
+async function refreshAfterFileChanges(project: WikiProject, relativePaths: string[]): Promise<void> {
   const pp = normalizePath(project.path)
   const pp = normalizePath(project.path)
   try {
   try {
     const tree = await listDirectory(pp)
     const tree = await listDirectory(pp)
@@ -151,6 +152,16 @@ async function refreshAfterFileChanges(project: WikiProject, _relativePaths: str
   }
   }
 
 
   useWikiStore.getState().bumpDataVersion()
   useWikiStore.getState().bumpDataVersion()
+
+  const selected = useWikiStore.getState().selectedFile
+    ? normalizePath(useWikiStore.getState().selectedFile!)
+    : null
+  if (!selected) return
+
+  const selectedRel = selected.startsWith(`${pp}/`) ? selected.slice(pp.length + 1) : selected
+  if (!relativePaths.includes(selectedRel)) return
+
+  await requestEditorDiskSyncIfSafe(selected)
 }
 }
 
 
 async function enqueueRawSourceChanges(project: WikiProject, tasks: FileChangeTask[]): Promise<void> {
 async function enqueueRawSourceChanges(project: WikiProject, tasks: FileChangeTask[]): Promise<void> {