Bladeren bron

fix(project): 修复快速切换项目时的 hydration 竞态

使用 normalizePath 比较项目路径,延迟 hydration 串行执行并在每步校验当前项目
刷新文件树时保留 display tree,Rust 目录扫描支持非 UTF-8 文件名
补全 dedup-runner 测试中的 LlmConfig 类型

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 maanden geleden
bovenliggende
commit
c831669abd
4 gewijzigde bestanden met toevoegingen van 42 en 21 verwijderingen
  1. 3 5
      src-tauri/src/commands/fs.rs
  2. 23 12
      src/App.tsx
  3. 1 1
      src/components/layout/app-layout.tsx
  4. 15 3
      src/lib/dedup-runner.spec.ts

+ 3 - 5
src-tauri/src/commands/fs.rs

@@ -1248,11 +1248,9 @@ fn build_tree(
         .map_err(|e| format!("Failed to read directory '{}': {}", dir.display(), e))?
         .filter_map(|entry| entry.ok())
         .filter(|entry| {
-            entry
-                .file_name()
-                .to_str()
-                .map(|n| entry_is_visible(n, include_hidden))
-                .unwrap_or(false)
+            let file_name = entry.file_name();
+            let name = file_name.to_string_lossy();
+            entry_is_visible(&name, include_hidden)
         })
         .collect();
 

+ 23 - 12
src/App.tsx

@@ -22,6 +22,7 @@ import type { WikiProject } from "@/types/wiki"
 import { applyTheme, watchSystemTheme } from "@/lib/theme-utils"
 import { applyUiFontFamily } from "@/lib/font-settings"
 import { applyVisualStyle } from "@/lib/visual-style-settings"
+import { normalizePath } from "@/lib/path-utils"
 
 function App() {
   const project = useWikiStore((s) => s.project)
@@ -39,7 +40,8 @@ function App() {
 
   function isCurrentProject(proj: WikiProject): boolean {
     const current = useWikiStore.getState().project
-    return current?.id === proj.id && current.path === proj.path
+    if (!current || current.id !== proj.id) return false
+    return normalizePath(current.path) === normalizePath(proj.path)
   }
 
   async function hydrateProjectSideStores(proj: WikiProject): Promise<void> {
@@ -98,9 +100,11 @@ function App() {
 
   async function hydrateProjectBackgroundServices(proj: WikiProject): Promise<void> {
     if (!isTauri()) return
+    if (!isCurrentProject(proj)) return
 
     try {
       const { restoreQueue } = await import("@/lib/ingest-queue")
+      if (!isCurrentProject(proj)) return
       await restoreQueue(proj.id, proj.path)
     } catch (err) {
       console.error("恢复摄取队列失败:", err)
@@ -134,6 +138,14 @@ function App() {
     }
   }
 
+  async function hydrateDeferredProjectState(proj: WikiProject): Promise<void> {
+    await hydrateProjectBackgroundServices(proj)
+    if (!isCurrentProject(proj)) return
+    await hydrateScheduledImportAfterOpen(proj)
+    if (!isCurrentProject(proj)) return
+    await hydrateProjectSideStores(proj)
+  }
+
   useEffect(() => {
     document.documentElement.style.fontSize = `${Math.round(uiFontSizeScale * 100)}%`
   }, [uiFontSizeScale])
@@ -352,14 +364,15 @@ function App() {
 
     // 自动打开最后阅读的章节和AI会话窗口
     try {
-      const lastChapterPath = await loadLastReadChapter()
-      if (!isCurrentProject(proj)) return
-      if (lastChapterPath) {
-        const normalizedPath = lastChapterPath.replace(/\\/g, "/")
-        if (normalizedPath.includes("/wiki/chapters/")) {
-          const exists = await fileExists(lastChapterPath)
-          if (exists && isCurrentProject(proj)) {
-            setSelectedFile(lastChapterPath)
+      if (isCurrentProject(proj)) {
+        const lastChapterPath = await loadLastReadChapter()
+        if (isCurrentProject(proj) && lastChapterPath) {
+          const normalizedPath = lastChapterPath.replace(/\\/g, "/")
+          if (normalizedPath.includes("/wiki/chapters/")) {
+            const exists = await fileExists(lastChapterPath)
+            if (exists && isCurrentProject(proj)) {
+              setSelectedFile(lastChapterPath)
+            }
           }
         }
       }
@@ -371,9 +384,7 @@ function App() {
     }
 
     // 文件树由 AppLayout 通过 refreshProjectFileTree 加载;重队列/定时导入/审查/聊天后置 hydration。
-    void hydrateProjectBackgroundServices(proj)
-    void hydrateScheduledImportAfterOpen(proj)
-    void hydrateProjectSideStores(proj)
+    void hydrateDeferredProjectState(proj)
   }
 
   async function handleSelectRecent(proj: WikiProject) {

+ 1 - 1
src/components/layout/app-layout.tsx

@@ -62,7 +62,7 @@ export function AppLayout({ onSwitchProject }: AppLayoutProps) {
     if (!project) return
     await refreshProjectFileTree(project.path, {
       projectId: project.id,
-      clearDisplayTreeFirst: true,
+      clearDisplayTreeFirst: false,
     })
   }, [project])
 

+ 15 - 3
src/lib/dedup-runner.spec.ts

@@ -1,4 +1,5 @@
 import { beforeEach, expect, test, vi } from "vitest"
+import type { LlmConfig } from "@/stores/wiki-store"
 import type { FileNode } from "@/types/wiki"
 
 vi.mock("@/commands/fs", () => ({
@@ -23,6 +24,17 @@ import {
 } from "./dedup-runner"
 import type { EntitySummary } from "./dedup"
 
+const testLlmConfig: LlmConfig = {
+  provider: "openai",
+  model: "gpt-4",
+  apiKey: "test",
+  ollamaUrl: "http://localhost:11434",
+  customEndpoint: "",
+  maxContextSize: 204800,
+  reasoning: { mode: "auto" },
+  localCliIsolation: false,
+}
+
 const mockedListDirectory = vi.mocked(listDirectory)
 const mockedReadFile = vi.mocked(readFile)
 const mockedFileExists = vi.mocked(fileExists)
@@ -187,7 +199,7 @@ test("runDuplicateDetection returns scannedPageCount and skips disk when summari
 
   const result = await runDuplicateDetection(
     "/Project",
-    { provider: "openai", model: "gpt-4", apiKey: "test" },
+    testLlmConfig,
     { summaries },
   )
 
@@ -211,7 +223,7 @@ test("runDuplicateDetection short-circuits when fewer than two summaries", async
 
   const result = await runDuplicateDetection(
     "/Project",
-    { provider: "openai", model: "gpt-4", apiKey: "test" },
+    testLlmConfig,
     { summaries },
   )
 
@@ -230,7 +242,7 @@ test("runDuplicateDetection invokes onProgress for loading and detecting", async
 
   await runDuplicateDetection(
     "/Project",
-    { provider: "openai", model: "gpt-4", apiKey: "test" },
+    testLlmConfig,
     {
       onProgress: (stage) => {
         stages.push(stage)