Jelajahi Sumber

fix(project): 修复切换项目后仍打开其他书章节

lastReadChapter 改为按 projectId 隔离,并在恢复时校验章节路径归属,避免跨项目编辑器残留。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 2 bulan lalu
induk
melakukan
998a351f22

+ 1 - 0
src-tauri/src/commands/backup.rs

@@ -161,6 +161,7 @@ const UI_PREFERENCE_KEYS: &[&str] = &[
     "uiFontFamily",
     "maxHistoryMessages",
     "lastReadChapter",
+    "projectLastReadChapters",
 ];
 
 fn is_sensitive_key(key: &str) -> bool {

+ 13 - 10
src/App.tsx

@@ -24,7 +24,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"
+import { isChapterPathInProject, normalizePath } from "@/lib/path-utils"
 import { countChapterBodyWords } from "@/lib/chapter-word-count"
 import { flattenMdFiles } from "@/lib/novel/chapter-utils"
 import { runUserMemoryMaintenance } from "@/lib/user-memory/maintenance"
@@ -431,6 +431,7 @@ function App() {
       useWikiStore.getState().setRevisionFeedbackWindowConfig(projectRevisionFeedbackWindowConfig)
     }
     setSelectedFile(null)
+    useWikiStore.getState().setFileContent("")
     setActiveView("wiki")
     useWikiStore.getState().setScheduledImportConfig({
       enabled: false,
@@ -441,17 +442,18 @@ function App() {
     useWikiStore.getState().bumpDataVersion()
     await saveLastProject(proj)
 
-    // 自动打开最后阅读的章节和AI会话窗口
+    // 自动打开最后阅读的章节和AI会话窗口(必须属于当前项目,避免跨书残留)
     try {
       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)
-            }
+        const lastChapterPath = await loadLastReadChapter(proj.id)
+        if (
+          isCurrentProject(proj) &&
+          lastChapterPath &&
+          isChapterPathInProject(lastChapterPath, proj.path)
+        ) {
+          const exists = await fileExists(lastChapterPath)
+          if (exists && isCurrentProject(proj)) {
+            setSelectedFile(lastChapterPath)
           }
         }
       }
@@ -505,6 +507,7 @@ function App() {
     setProject(null)
     setFileTree([])
     setSelectedFile(null)
+    useWikiStore.getState().setFileContent("")
   }
 
   if (loading) {

+ 11 - 3
src/components/layout/icon-sidebar.tsx

@@ -25,6 +25,7 @@ import logoImg from "@/assets/QM-LOGO.png"
 import type { WikiState } from "@/stores/wiki-store"
 import { saveTheme } from "@/lib/project-store"
 import { applyTheme, type ThemeMode } from "@/lib/theme-utils"
+import { isChapterPathInProject } from "@/lib/path-utils"
 import {
   isSidebarNavItemId,
   reorderSidebarNavOrder,
@@ -124,6 +125,7 @@ export function IconSidebar({ onToggleSidebar, onOpenSidebar, onSwitchProject }:
   const activeView = useWikiStore((s) => s.activeView)
   const setActiveView = useWikiStore((s) => s.setActiveView)
   const setSearchPanelOpen = useWikiStore((s) => s.setSearchPanelOpen)
+  const project = useWikiStore((s) => s.project)
   const selectedFile = useWikiStore((s) => s.selectedFile)
   const setSelectedFile = useWikiStore((s) => s.setSelectedFile)
   const theme = useWikiStore((s) => s.theme)
@@ -198,9 +200,15 @@ export function IconSidebar({ onToggleSidebar, onOpenSidebar, onSwitchProject }:
     const normalizedSelectedFile = selectedFile?.replace(/\\/g, "/") ?? ""
 
     // 离开wiki视图时,保存当前章节路径到sessionStorage,以便切回时恢复
-    if (activeView === "wiki" && view !== "wiki" && normalizedSelectedFile.includes("/wiki/chapters/")) {
+    if (
+      activeView === "wiki" &&
+      view !== "wiki" &&
+      project &&
+      selectedFile &&
+      isChapterPathInProject(selectedFile, project.path)
+    ) {
       try {
-        sessionStorage.setItem("lk-last-chapter-path", selectedFile!)
+        sessionStorage.setItem("lk-last-chapter-path", selectedFile)
       } catch { /* ignore quota errors */ }
     }
 
@@ -227,7 +235,7 @@ export function IconSidebar({ onToggleSidebar, onOpenSidebar, onSwitchProject }:
     // 切换回wiki时,如果selectedFile为空或已被清空,尝试从sessionStorage恢复章节
     if (view === "wiki" && (!normalizedSelectedFile || needsRestoreChapter)) {
       const savedPath = sessionStorage.getItem("lk-last-chapter-path")
-      if (savedPath) {
+      if (savedPath && project && isChapterPathInProject(savedPath, project.path)) {
         setSelectedFile(savedPath)
       }
     }

+ 2 - 5
src/components/layout/knowledge-tree.tsx

@@ -1292,15 +1292,12 @@ export function KnowledgeTree({
     setSelectedFile(pagePath)
     // 保存最后阅读的章节路径,用于启动时自动打开(仅章节保存,大纲不保存)
     if (pagePath.replace(/\\/g, "/").includes("/wiki/chapters/")) {
-      saveLastReadChapter(pagePath).catch(() => {})
-    }
-    // 同时保存到sessionStorage,用于视图切换时恢复章节定位
-    if (pagePath.replace(/\\/g, "/").includes("/wiki/chapters/")) {
+      saveLastReadChapter(pagePath, project?.id).catch(() => {})
       try {
         sessionStorage.setItem("lk-last-chapter-path", pagePath)
       } catch { /* ignore quota errors */ }
     }
-  }, [renamingPath, setSelectedFile])
+  }, [project?.id, renamingPath, setSelectedFile])
 
   const toggleFolder = useCallback((folderPath: string) => {
     setCollapsedFolders((previous) => ({ ...previous, [folderPath]: !previous[folderPath] }))

+ 33 - 0
src/lib/path-utils.spec.ts

@@ -0,0 +1,33 @@
+import { describe, expect, it } from "vitest"
+import {
+  isChapterPathInProject,
+  isPathInside,
+  normalizeComparablePath,
+} from "./path-utils"
+
+describe("path ownership helpers", () => {
+  it("normalizes trailing slashes for comparison", () => {
+    expect(normalizeComparablePath("C:\\Books\\A\\")).toBe("C:/Books/A")
+  })
+
+  it("detects descendant paths without prefix false positives", () => {
+    expect(isPathInside("/books/a/wiki/chapters/x.md", "/books/a")).toBe(true)
+    expect(isPathInside("/books/ab/wiki/chapters/x.md", "/books/a")).toBe(false)
+  })
+
+  it("accepts only chapter markdown under the current project", () => {
+    const project = "D:/novels/有钱以后"
+    expect(
+      isChapterPathInProject("D:\\novels\\有钱以后\\wiki\\chapters\\第001章.md", project),
+    ).toBe(true)
+    expect(
+      isChapterPathInProject("D:/novels/其他书/wiki/chapters/第212章.md", project),
+    ).toBe(false)
+    expect(
+      isChapterPathInProject("D:/novels/有钱以后/wiki/outlines/大纲.md", project),
+    ).toBe(false)
+    expect(
+      isChapterPathInProject("D:/novels/有钱以后/wiki/chapters/notes.txt", project),
+    ).toBe(false)
+  })
+})

+ 24 - 0
src/lib/path-utils.ts

@@ -6,6 +6,30 @@ export function normalizePath(p: string): string {
   return p.replace(/\\/g, "/")
 }
 
+/** Strip trailing slashes after normalize (except bare root forms). */
+export function normalizeComparablePath(p: string): string {
+  return normalizePath(p).replace(/\/+$/, "")
+}
+
+/** True when `path` is `parent` or a descendant under `parent/`. */
+export function isPathInside(path: string, parent: string): boolean {
+  const normalizedPath = normalizeComparablePath(path)
+  const normalizedParent = normalizeComparablePath(parent)
+  if (!normalizedPath || !normalizedParent) return false
+  return (
+    normalizedPath === normalizedParent ||
+    normalizedPath.startsWith(`${normalizedParent}/`)
+  )
+}
+
+/** True when `chapterPath` is a chapter markdown file under the project's wiki/chapters. */
+export function isChapterPathInProject(chapterPath: string, projectPath: string): boolean {
+  const chaptersRoot = `${normalizeComparablePath(projectPath)}/wiki/chapters`
+  const normalizedChapter = normalizeComparablePath(chapterPath)
+  if (!isPathInside(normalizedChapter, chaptersRoot)) return false
+  return normalizedChapter.toLowerCase().endsWith(".md")
+}
+
 /**
  * Join path segments with forward slashes.
  */

+ 30 - 0
src/lib/project-store.spec.ts

@@ -15,7 +15,9 @@ vi.mock("@/lib/web-store", () => ({
 
 import {
   loadAiOutlineModel,
+  loadLastReadChapter,
   saveAiOutlineModel,
+  saveLastReadChapter,
 } from "@/lib/project-store"
 
 describe("AI outline model persistence", () => {
@@ -72,3 +74,31 @@ describe("AI outline model persistence", () => {
   })
 
 })
+
+describe("last read chapter persistence", () => {
+  beforeEach(() => {
+    storeMocks.values.clear()
+    storeMocks.get.mockReset()
+    storeMocks.set.mockReset()
+    storeMocks.get.mockImplementation(async (key: string) => storeMocks.values.get(key))
+    storeMocks.set.mockImplementation(async (key: string, value: unknown) => {
+      storeMocks.values.set(key, value)
+    })
+  })
+
+  it("stores last-read chapters per project id", async () => {
+    await saveLastReadChapter("/books/a/wiki/chapters/1.md", "project-a")
+    await saveLastReadChapter("/books/b/wiki/chapters/2.md", "project-b")
+
+    await expect(loadLastReadChapter("project-a")).resolves.toBe("/books/a/wiki/chapters/1.md")
+    await expect(loadLastReadChapter("project-b")).resolves.toBe("/books/b/wiki/chapters/2.md")
+  })
+
+  it("falls back to legacy global key when project entry is missing", async () => {
+    storeMocks.values.set("lastReadChapter", "/books/legacy/wiki/chapters/9.md")
+
+    await expect(loadLastReadChapter("unknown-project")).resolves.toBe(
+      "/books/legacy/wiki/chapters/9.md",
+    )
+  })
+})

+ 24 - 2
src/lib/project-store.ts

@@ -752,14 +752,36 @@ function normalizeRerankConfig(
 }
 
 const LAST_READ_CHAPTER_KEY = "lastReadChapter"
+const PROJECT_LAST_READ_CHAPTER_KEY = "projectLastReadChapters"
 
-export async function saveLastReadChapter(chapterPath: string): Promise<void> {
+export async function saveLastReadChapter(
+  chapterPath: string,
+  projectId?: string,
+): Promise<void> {
   const store = await getStore()
+  if (projectId) {
+    const existing =
+      (await store.get<Record<string, string>>(PROJECT_LAST_READ_CHAPTER_KEY)) ?? {}
+    await store.set(PROJECT_LAST_READ_CHAPTER_KEY, {
+      ...existing,
+      [projectId]: chapterPath,
+    })
+  }
+  // Keep legacy global key as a best-effort fallback for older backups/readers.
   await store.set(LAST_READ_CHAPTER_KEY, chapterPath)
 }
 
-export async function loadLastReadChapter(): Promise<string | null> {
+export async function loadLastReadChapter(projectId?: string): Promise<string | null> {
   const store = await getStore()
+  if (projectId) {
+    const projectChapters =
+      await store.get<Record<string, string>>(PROJECT_LAST_READ_CHAPTER_KEY)
+    const projectPath = projectChapters?.[projectId]
+    if (typeof projectPath === "string" && projectPath) {
+      return projectPath
+    }
+  }
+  // Legacy global key: callers must verify the path belongs to the opened project.
   const path = await store.get<string>(LAST_READ_CHAPTER_KEY)
   return path ?? null
 }

+ 22 - 1
src/lib/reset-project-state.spec.ts

@@ -4,7 +4,7 @@ vi.mock("@/lib/ingest-queue", () => ({
   pauseQueue: vi.fn().mockResolvedValue(undefined),
 }))
 
-import { resetProjectStores } from "./reset-project-state"
+import { resetProjectState, resetProjectStores } from "./reset-project-state"
 import { useActivityStore } from "@/stores/activity-store"
 import { useChatStore } from "@/stores/chat-store"
 import { useOutlineChatStore } from "@/stores/outline-chat-store"
@@ -51,3 +51,24 @@ describe("resetProjectStores", () => {
     expect(useActivityStore.getState().items).toEqual([])
   })
 })
+
+describe("resetProjectState", () => {
+  it("clears the session chapter restore key used by sidebar view switching", async () => {
+    const storage = new Map<string, string>()
+    vi.stubGlobal("sessionStorage", {
+      getItem: (key: string) => storage.get(key) ?? null,
+      setItem: (key: string, value: string) => {
+        storage.set(key, value)
+      },
+      removeItem: (key: string) => {
+        storage.delete(key)
+      },
+    })
+    sessionStorage.setItem("lk-last-chapter-path", "/books/other/wiki/chapters/212.md")
+
+    await resetProjectState()
+
+    expect(sessionStorage.getItem("lk-last-chapter-path")).toBeNull()
+    vi.unstubAllGlobals()
+  })
+})

+ 8 - 0
src/lib/reset-project-state.ts

@@ -53,6 +53,14 @@ export function resetProjectStores(): void {
 export async function resetProjectState(): Promise<void> {
   resetProjectStores()
 
+  // View-switch restore key is process-global; clear so the next project cannot
+  // reopen another book's chapter via icon-sidebar session restore.
+  try {
+    sessionStorage.removeItem("lk-last-chapter-path")
+  } catch {
+    /* ignore quota / unavailable storage */
+  }
+
   const [dedupQueueMod, foreshadowingCleanupQueueMod, graphMod, fileSyncMod, scheduledImportMod] =
     await Promise.allSettled([
       import("@/lib/dedup-queue"),