Преглед на файлове

feat(editor): 2s 轮询磁盘同步外部修改

用 editor-disk-sync 判定无本地未保存修改时自动载入磁盘内容,
关键操作前即时同步,autosave 写盘前检测外部变更并跳过覆盖;
WikiEditor 暴露 live markdown ref,file-sync 仅刷新文件树。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi преди 3 месеца
родител
ревизия
5170486613
променени са 5 файла, в които са добавени 213 реда и са изтрити 51 реда
  1. 32 7
      src/components/editor/wiki-editor.tsx
  2. 109 21
      src/components/layout/preview-panel.tsx
  3. 47 0
      src/lib/editor-disk-sync.spec.ts
  4. 22 0
      src/lib/editor-disk-sync.ts
  5. 3 23
      src/lib/project-file-sync.ts

+ 32 - 7
src/components/editor/wiki-editor.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from "react"
+import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, forwardRef } from "react"
 import { Editor, rootCtx, defaultValueCtx } from "@milkdown/kit/core"
 import { commonmark } from "@milkdown/kit/preset/commonmark"
 import { gfm } from "@milkdown/kit/preset/gfm"
@@ -41,14 +41,18 @@ interface FloatingToolbarPosition {
   left: number
 }
 
-function WritingTextarea({
+interface WritingTextareaHandle {
+  getLiveBodyMarkdown: () => string | null
+}
+
+const WritingTextarea = forwardRef<WritingTextareaHandle, WritingTextareaProps>(function WritingTextarea({
   content,
   onSave,
   autoFocus = false,
   onSelectionAction,
   highlightRequest,
   onHighlightHandled,
-}: WritingTextareaProps) {
+}, ref) {
   const initial = useMemo(() => splitChapterHeading(content), [content])
   const [heading, setHeading] = useState(initial.heading)
   const [value, setValue] = useState(initial.body)
@@ -57,6 +61,13 @@ function WritingTextarea({
   const [selection, setSelection] = useState<ChapterBodySelection | null>(null)
   const [toolbarPosition, setToolbarPosition] = useState<FloatingToolbarPosition | null>(null)
 
+  useImperativeHandle(ref, () => ({
+    getLiveBodyMarkdown: () => {
+      if (!textareaRef.current) return null
+      return heading ? `# ${heading}\n\n${value}` : value
+    },
+  }), [heading, value])
+
   useEffect(() => {
     const { heading: h, body: b } = splitChapterHeading(content)
     if (document.activeElement === textareaRef.current) {
@@ -304,7 +315,7 @@ function WritingTextarea({
       />
     </div>
   )
-}
+})
 
 function WikiEditorInner({ content, onSave }: WikiEditorInnerProps) {
   // Milkdown fires `markdownUpdated` once on initial parse before any
@@ -351,6 +362,10 @@ interface WikiEditorProps {
   onHighlightHandled?: () => void
 }
 
+export interface WikiEditorHandle {
+  getCurrentMarkdown: () => string | null
+}
+
 function wrapBareMathBlocks(text: string): string {
   return text.replace(
     /(?<!\$\$\s*)(\\begin\{[^}]+\}[\s\S]*?\\end\{[^}]+\})(?!\s*\$\$)/g,
@@ -358,7 +373,7 @@ function wrapBareMathBlocks(text: string): string {
   )
 }
 
-export function WikiEditor({
+export const WikiEditor = forwardRef<WikiEditorHandle, WikiEditorProps>(function WikiEditor({
   content,
   onSave,
   defaultMode = "read",
@@ -366,7 +381,8 @@ export function WikiEditor({
   onSelectionAction,
   highlightRequest,
   onHighlightHandled,
-}: WikiEditorProps) {
+}, ref) {
+  const writingTextareaRef = useRef<WritingTextareaHandle>(null)
   // Default to read mode (ReactMarkdown render). Edit mode swaps
   // in Milkdown WYSIWYG. We default to read because:
   //   1. Milkdown's commonmark/gfm preset has no wikilink schema,
@@ -398,6 +414,14 @@ export function WikiEditor({
     [onSave, rawBlock],
   )
 
+  useImperativeHandle(ref, () => ({
+    getCurrentMarkdown: () => {
+      const liveBody = writingTextareaRef.current?.getLiveBodyMarkdown()
+      if (liveBody == null) return null
+      return rawBlock + liveBody
+    },
+  }), [rawBlock])
+
   useEffect(() => {
     setMode(defaultMode)
   }, [defaultMode])
@@ -446,6 +470,7 @@ export function WikiEditor({
             `}</style>
             <div className="mx-auto w-full max-w-4xl flex-col px-8 py-10">
               <WritingTextarea
+                ref={writingTextareaRef}
                 content={body}
                 onSave={handleSave}
                 autoFocus={immersiveWriting}
@@ -466,7 +491,7 @@ export function WikiEditor({
       )}
     </div>
   )
-}
+})
 
 function getTextareaSelectionToolbarPosition(
   textarea: HTMLTextAreaElement,

+ 109 - 21
src/components/layout/preview-panel.tsx

@@ -8,7 +8,7 @@ import { useReviewStore } from "@/stores/review-store"
 import { deleteFile, fileExists, readFile, writeFile, writeFileAtomic, listDirectory } from "@/commands/fs"
 import { normalizePath } from "@/lib/path-utils"
 import { getFileCategory, isBinary } from "@/lib/file-types"
-import { WikiEditor } from "@/components/editor/wiki-editor"
+import { WikiEditor, type WikiEditorHandle } from "@/components/editor/wiki-editor"
 import { WikiReader } from "@/components/editor/wiki-reader"
 import { FilePreview } from "@/components/editor/file-preview"
 import { formatChapterWriting } from "@/lib/chapter-formatting"
@@ -51,6 +51,7 @@ import {
   type ChapterBodySelection,
   type ChapterSelectionAction,
 } from "@/lib/chapter-selection"
+import { shouldApplyDiskToEditor } from "@/lib/editor-disk-sync"
 
 const SnapshotViewer = lazy(async () => {
   const mod = await import("@/components/novel/snapshot-viewer")
@@ -197,6 +198,7 @@ export function PreviewPanel() {
   const outlineTasks = useOutlineGenerationStore((s) => s.tasks)
   const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
   const saveGenerationRef = useRef(0)
+  const wikiEditorRef = useRef<WikiEditorHandle>(null)
   const [isSavingFinal, setIsSavingFinal] = useState(false)
   const [saveStatus, setSaveStatus] = useState<string>("")
   const [showSnapshot, setShowSnapshot] = useState(false)
@@ -250,10 +252,49 @@ export function PreviewPanel() {
   }, [fileContent])
 
   const rememberLoadedChapter = useCallback((path: string, markdown: string) => {
+    const key = normalizePath(path)
     lastLoadedRef.current = markdown
-    lastLoadedByPathRef.current.set(path, markdown)
+    lastLoadedByPathRef.current.set(key, markdown)
   }, [])
 
+  const applyDiskSyncIfSafe = useCallback(async (path: string): Promise<boolean> => {
+    const normalizedPath = normalizePath(path)
+    if (getFileCategory(normalizedPath) !== "markdown") return false
+
+    let diskContent: string
+    try {
+      diskContent = await readFile(normalizedPath)
+    } catch {
+      return false
+    }
+
+    const editorContent = wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current
+    const lastLoaded = lastLoadedByPathRef.current.get(normalizedPath) ?? lastLoadedRef.current
+    const hasPendingSave = saveTimerRef.current != null
+
+    if (!shouldApplyDiskToEditor({
+      lastLoaded,
+      editorContent,
+      diskContent,
+      hasPendingSave,
+    })) {
+      return false
+    }
+
+    rememberLoadedChapter(normalizedPath, diskContent)
+    fileContentRef.current = diskContent
+    if (selectedFileRef.current && normalizePath(selectedFileRef.current) === normalizedPath) {
+      setFileContent(diskContent)
+    }
+    return true
+  }, [rememberLoadedChapter, setFileContent])
+
+  const syncDiskBeforeAction = useCallback(async () => {
+    const path = selectedFileRef.current
+    if (!path) return
+    await applyDiskSyncIfSafe(path)
+  }, [applyDiskSyncIfSafe])
+
   useEffect(() => {
     setChapterDeAiSkillId(undefined)
     deAiSkillMemoryWarningRef.current = ""
@@ -331,7 +372,8 @@ export function PreviewPanel() {
       clearTimeout(saveTimerRef.current)
       saveTimerRef.current = null
     }
-    const lastLoadedForPath = lastLoadedByPathRef.current.get(path) ?? ""
+    const normalizedPath = normalizePath(path)
+    const lastLoadedForPath = lastLoadedByPathRef.current.get(normalizedPath) ?? ""
     const resolvedMarkdown = resolveChapterFlushMarkdown(path, markdown, lastLoadedByPathRef.current)
     if (!shouldSyncChapterOnLeave(path, markdown, lastLoadedForPath)) return
     try {
@@ -392,7 +434,7 @@ export function PreviewPanel() {
       .then((content) => {
         console.log("[PreviewPanel][debug] readFile success", { selectedFile, contentLength: content?.length, cancelled, storeSelectedFile: useWikiStore.getState().selectedFile })
         if (cancelled || useWikiStore.getState().selectedFile !== selectedFile) return
-        rememberLoadedChapter(selectedFile, content)
+        rememberLoadedChapter(normalizePath(selectedFile), content)
         setFileContent(content)
         setSaveStatus("")
         setLoadedFilePath(selectedFile)
@@ -401,7 +443,7 @@ export function PreviewPanel() {
         console.log("[PreviewPanel][debug] readFile error", { selectedFile, err, cancelled, storeSelectedFile: useWikiStore.getState().selectedFile })
         if (cancelled || useWikiStore.getState().selectedFile !== selectedFile) return
         lastLoadedRef.current = ""
-        lastLoadedByPathRef.current.delete(selectedFile)
+        lastLoadedByPathRef.current.delete(normalizePath(selectedFile))
         setFileContent(`Error loading file: ${err}`)
         setSaveStatus("")
         setLoadedFilePath(selectedFile)
@@ -412,6 +454,21 @@ export function PreviewPanel() {
     }
   }, [selectedFile, rememberLoadedChapter, setFileContent, flushChapterBeforeLeave])
 
+  useEffect(() => {
+    if (!selectedFile) return
+    const category = getFileCategory(selectedFile)
+    if (category !== "markdown" || isBinary(category)) return
+
+    const normalizedPath = normalizePath(selectedFile)
+    const tick = () => {
+      if (normalizePath(selectedFileRef.current ?? "") !== normalizedPath) return
+      void applyDiskSyncIfSafe(normalizedPath)
+    }
+
+    const intervalId = setInterval(tick, 2000)
+    return () => clearInterval(intervalId)
+  }, [selectedFile, applyDiskSyncIfSafe])
+
   useEffect(() => {
     return () => {
       const currentFile = selectedFileRef.current
@@ -430,22 +487,43 @@ export function PreviewPanel() {
         : markdown
       setFileContent(markdown)
       fileContentRef.current = markdown
-      const lastLoadedForPath = lastLoadedByPathRef.current.get(pathAtSave) ?? lastLoadedRef.current
+      const normalizedPath = normalizePath(pathAtSave)
+      const lastLoadedForPath = lastLoadedByPathRef.current.get(normalizedPath) ?? lastLoadedRef.current
       if (persistedMarkdown === lastLoadedForPath) return
       if (saveTimerRef.current) clearTimeout(saveTimerRef.current)
       const generation = saveGenerationRef.current
       saveTimerRef.current = setTimeout(() => {
-        if (generation !== saveGenerationRef.current) return
-        if (pathAtSave !== selectedFileRef.current) return
-        writeFileAtomic(pathAtSave, persistedMarkdown)
-          .then(() => {
-            rememberLoadedChapter(pathAtSave, persistedMarkdown)
+        void (async () => {
+          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 = isChapterPath(normalizedPath) ? normalizeChapterWriting : (content: string) => content
+          if (normalize(diskContent) !== normalize(currentLastLoaded)) {
+            void applyDiskSyncIfSafe(normalizedPath)
+            return
+          }
+
+          try {
+            await writeFileAtomic(pathAtSave, persistedMarkdown)
+            rememberLoadedChapter(normalizedPath, persistedMarkdown)
             bumpDataVersion()
-          })
-          .catch((err) => console.error("保存失败:", err))
+          } catch (err) {
+            console.error("保存失败:", err)
+          }
+        })()
       }, 1000)
     },
-    [rememberLoadedChapter, setFileContent, bumpDataVersion]
+    [rememberLoadedChapter, setFileContent, bumpDataVersion, applyDiskSyncIfSafe]
   )
 
   const chapterFrontmatter = useMemo(() => {
@@ -662,6 +740,9 @@ export function PreviewPanel() {
   const handleSaveAsFinal = useCallback(async () => {
     if (!project || !selectedFile || !chapterFrontmatter) return
 
+    await syncDiskBeforeAction()
+    const currentContent = wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current
+
     let savePath = selectedFile
     const projectPath = project.path
     const updatePhase = (saving: boolean, phase: FinalChapterSavePhase | null, params?: Record<string, string | number>) => {
@@ -678,7 +759,7 @@ export function PreviewPanel() {
       try {
         const chapterNumber = chapterFrontmatter.chapterNumber as number | undefined
         const { reviewChapter } = await import("@/lib/novel/review-adapter")
-        const results = await reviewChapter(project.path, fileContent, chapterNumber)
+        const results = await reviewChapter(project.path, currentContent, chapterNumber)
         if (results.length > 0) {
           const reviewStore = useReviewStore.getState()
           reviewStore.addNovelReviewEntry({
@@ -712,7 +793,9 @@ export function PreviewPanel() {
         saveTimerRef.current = null
       }
 
-      const markdownToSave = fileContent.trim() ? fileContent : (lastLoadedByPathRef.current.get(selectedFile) ?? lastLoadedRef.current)
+      const markdownToSave = currentContent.trim()
+        ? currentContent
+        : (lastLoadedByPathRef.current.get(normalizePath(selectedFile)) ?? lastLoadedRef.current)
       if (!markdownToSave.trim()) {
         updatePhase(false, null)
         setSaveStatus("章节内容为空,无法保存为正式章节")
@@ -781,7 +864,7 @@ export function PreviewPanel() {
     } finally {
       setIsSavingFinal(false)
     }
-  }, [chapterFrontmatter, fileContent, project, selectedFile, setFileContent, setFinalChapterSave, t, syncChapterToCanonicalPath])
+  }, [chapterFrontmatter, project, selectedFile, setFileContent, setFinalChapterSave, syncChapterToCanonicalPath, syncDiskBeforeAction])
 
   const handleReingest = useCallback(async () => {
     if (!project || !selectedFile || !chapterFrontmatter) return
@@ -858,7 +941,9 @@ export function PreviewPanel() {
   }, [canIngestOutline, isOutlineIngesting, project, selectedFile])
 
   const runWholeChapterDeAi = useCallback(async (skillContent: string, skillName: string) => {
-    if (!fileContent.trim()) return
+    await syncDiskBeforeAction()
+    const source = wikiEditorRef.current?.getCurrentMarkdown() ?? fileContentRef.current
+    if (!source.trim()) return
     setDeAiProcessing(true)
     setDeAiSkillName(skillName)
     const state = useWikiStore.getState()
@@ -869,7 +954,6 @@ export function PreviewPanel() {
       return
     }
     setSaveStatus(formatDeAiStatus(`去AI味处理中,使用 Skill:${skillName}...`))
-    const source = fileContent
     let result = ""
     try {
       await streamChat(
@@ -896,7 +980,7 @@ export function PreviewPanel() {
       console.error("去AI味处理失败:", err)
       setDeAiProcessing(false)
     }
-  }, [fileContent, formatDeAiStatus])
+  }, [formatDeAiStatus, syncDiskBeforeAction])
 
   const handleDeAiApply = useCallback(() => {
     setDeAiPreviewOpen(false)
@@ -934,6 +1018,9 @@ export function PreviewPanel() {
     skillName?: string,
   ) => {
     if (!selection.text.trim()) return
+    if (action === "de-ai") {
+      await syncDiskBeforeAction()
+    }
     const state = useWikiStore.getState()
     const llmConfig = action === "de-ai"
       ? resolveNovelModel(state.llmConfig, state.novelConfig, "deAi")
@@ -985,7 +1072,7 @@ export function PreviewPanel() {
       console.error(`${actionLabel}失败:`, err)
       setSaveStatus(`${actionLabel}失败:${message}`)
     }
-  }, [formatDeAiStatus])
+  }, [formatDeAiStatus, syncDiskBeforeAction])
 
   const openDeAiSkillPicker = useCallback((selection: ChapterBodySelection | null, anchor?: HTMLElement | null) => {
     if (deAiProcessing) return
@@ -1467,6 +1554,7 @@ export function PreviewPanel() {
       <div className={getPreviewContentContainerClass(isSelectedChapter)}>
         {category === "markdown" ? (
           <WikiEditor
+            ref={wikiEditorRef}
             key={selectedFile}
             content={fileContent}
             onSave={handleSave}

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

@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest"
+import { hasUnsavedLocalEdits, shouldApplyDiskToEditor } from "@/lib/editor-disk-sync"
+
+const DISK_V1 = "disk version one"
+const DISK_V2 = "disk version two"
+const EDITOR_V1 = "editor version one"
+
+describe("editor-disk-sync", () => {
+  it("detects unsaved local edits", () => {
+    expect(hasUnsavedLocalEdits({
+      lastLoaded: DISK_V1,
+      editorContent: EDITOR_V1,
+    })).toBe(true)
+  })
+
+  it("treats pending autosave as unsaved local edits", () => {
+    expect(hasUnsavedLocalEdits({
+      lastLoaded: DISK_V1,
+      editorContent: DISK_V1,
+      hasPendingSave: true,
+    })).toBe(true)
+  })
+
+  it("applies disk when externally changed and editor matches last loaded", () => {
+    expect(shouldApplyDiskToEditor({
+      lastLoaded: DISK_V1,
+      editorContent: DISK_V1,
+      diskContent: DISK_V2,
+    })).toBe(true)
+  })
+
+  it("skips disk apply when editor has local edits", () => {
+    expect(shouldApplyDiskToEditor({
+      lastLoaded: DISK_V1,
+      editorContent: EDITOR_V1,
+      diskContent: DISK_V2,
+    })).toBe(false)
+  })
+
+  it("skips disk apply when disk unchanged", () => {
+    expect(shouldApplyDiskToEditor({
+      lastLoaded: DISK_V1,
+      editorContent: DISK_V1,
+      diskContent: DISK_V1,
+    })).toBe(false)
+  })
+})

+ 22 - 0
src/lib/editor-disk-sync.ts

@@ -0,0 +1,22 @@
+export function hasUnsavedLocalEdits(input: {
+  lastLoaded: string
+  editorContent: string
+  normalize?: (content: string) => string
+  hasPendingSave?: boolean
+}): boolean {
+  const normalize = input.normalize ?? ((content) => content)
+  return normalize(input.editorContent) !== normalize(input.lastLoaded) || Boolean(input.hasPendingSave)
+}
+
+/** True when disk changed on disk and it is safe to overwrite the editor from disk. */
+export function shouldApplyDiskToEditor(input: {
+  lastLoaded: string
+  editorContent: string
+  diskContent: string
+  normalize?: (content: string) => string
+  hasPendingSave?: boolean
+}): boolean {
+  if (hasUnsavedLocalEdits(input)) return false
+  const normalize = input.normalize ?? ((content) => content)
+  return normalize(input.diskContent) !== normalize(input.lastLoaded)
+}

+ 3 - 23
src/lib/project-file-sync.ts

@@ -1,5 +1,5 @@
 import { listen, type UnlistenFn } from "@tauri-apps/api/event"
-import { readFile, listDirectory } from "@/commands/fs"
+import { listDirectory } from "@/commands/fs"
 import {
   rescanProjectFiles,
   startProjectFileWatcher,
@@ -141,9 +141,8 @@ async function processFileChangeBatch(
   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 store = useWikiStore.getState()
   try {
     const tree = await listDirectory(pp)
     useWikiStore.getState().setFileTree(tree)
@@ -151,26 +150,7 @@ async function refreshAfterFileChanges(project: WikiProject, relativePaths: stri
     console.warn("[file-sync] failed to refresh file tree:", err)
   }
 
-  store.bumpDataVersion()
-
-  const selected = store.selectedFile ? normalizePath(store.selectedFile) : null
-  if (!selected) return
-
-  const selectedRel = selected.startsWith(`${pp}/`) ? selected.slice(pp.length + 1) : selected
-  if (!relativePaths.includes(selectedRel)) return
-
-  try {
-    const content = await readFile(selected)
-    const currentContent = useWikiStore.getState().fileContent
-    if (currentContent && currentContent !== content) {
-      console.warn("[file-sync] 检测到编辑器有未保存内容,跳过文件刷新:", selected)
-      return
-    }
-    useWikiStore.getState().setFileContent(content)
-  } catch {
-    useWikiStore.getState().setSelectedFile(null)
-    useWikiStore.getState().setFileContent("")
-  }
+  useWikiStore.getState().bumpDataVersion()
 }
 
 async function enqueueRawSourceChanges(project: WikiProject, tasks: FileChangeTask[]): Promise<void> {