From 9f83dfae3fc6917e2ce75c9b53ff4f8289ac18d3 Mon Sep 17 00:00:00 2001 From: hhhjin Date: Wed, 8 Apr 2026 10:58:00 +0900 Subject: [PATCH 1/2] refactor: derive collection entries from tree state --- .../collection-view/collection-view.tsx | 10 +- packages/store/src/core.ts | 1 + .../src/workspace/collection/index.test.ts | 14 +- .../store/src/workspace/collection/index.ts | 151 +++++------------- .../src/workspace/lifecycle/actions.test.ts | 12 -- .../workspace/shared/action-test-helpers.ts | 3 - .../store/src/workspace/tree/actions.test.ts | 3 +- packages/store/src/workspace/tree/actions.ts | 1 - .../src/workspace/tree/entry-actions.test.ts | 30 ++-- .../store/src/workspace/tree/entry-actions.ts | 14 +- 10 files changed, 73 insertions(+), 166 deletions(-) diff --git a/apps/desktop/src/components/collection-view/collection-view.tsx b/apps/desktop/src/components/collection-view/collection-view.tsx index 0efc2ba8..f6304b6a 100644 --- a/apps/desktop/src/components/collection-view/collection-view.tsx +++ b/apps/desktop/src/components/collection-view/collection-view.tsx @@ -9,7 +9,7 @@ import { type MouseEvent, useCallback, useMemo, useRef } from "react" import { useShallow } from "zustand/shallow" import { useRenameNoteWithAI } from "@/components/common/explorer-agent/hooks/use-rename-note-with-ai" import { useResizablePanel } from "@/hooks/use-resizable-panel" -import { useStore } from "@/store" +import { computeCollectionEntries, useStore } from "@/store" import { isMac } from "@/utils/platform" import { useCollectionContextMenu } from "./hooks/use-collection-context-menu" import { useCollectionRename } from "./hooks/use-collection-rename" @@ -28,7 +28,7 @@ export function CollectionView() { isFileExplorerOpen, currentCollectionPath, setCurrentCollectionPath, - collectionEntries, + entries, tab, openTab, activeTabSaved, @@ -41,7 +41,7 @@ export function CollectionView() { isFileExplorerOpen: state.isFileExplorerOpen, currentCollectionPath: state.currentCollectionPath, setCurrentCollectionPath: state.setCurrentCollectionPath, - collectionEntries: state.collectionEntries, + entries: state.entries, tab: state.getActiveTab(), openTab: state.openTab, activeTabSaved: state.getIsSaved(), @@ -66,6 +66,10 @@ export function CollectionView() { const displayName = currentCollectionPath ? getFolderNameFromPath(currentCollectionPath) : undefined + const collectionEntries = useMemo( + () => computeCollectionEntries(currentCollectionPath, entries), + [currentCollectionPath, entries], + ) const { sortedEntries, diff --git a/packages/store/src/core.ts b/packages/store/src/core.ts index 631c4d6e..e56d5d6c 100644 --- a/packages/store/src/core.ts +++ b/packages/store/src/core.ts @@ -21,6 +21,7 @@ export type { } from "./ai-settings/credentials" export type { OllamaModels } from "./ai-settings/ollama-types" export type { BrowserStorageLike } from "./browser-storage" +export { computeCollectionEntries } from "./collection/helpers/collection-entries" export type { EditorSlice } from "./editor/editor-slice" export { prepareEditorSlice } from "./editor/editor-slice" export type { diff --git a/packages/store/src/workspace/collection/index.test.ts b/packages/store/src/workspace/collection/index.test.ts index 5a195438..0589b6ab 100644 --- a/packages/store/src/workspace/collection/index.test.ts +++ b/packages/store/src/workspace/collection/index.test.ts @@ -43,7 +43,6 @@ describe("workspace collection event handlers", () => { expect(store.getState().currentCollectionPath).toBeNull() expect(store.getState().lastCollectionPath).toBeNull() - expect(store.getState().collectionEntries).toEqual([]) }) it("onEntryRenamed rewrites descendant current and last paths", () => { @@ -55,10 +54,9 @@ describe("workspace collection event handlers", () => { }) store.getState().onEntryRenamed({ - oldPath: "/ws/old", - newPath: "/ws/new", + sourcePath: "/ws/old", + targetPath: "/ws/new", isDirectory: true, - newName: "new", }) expect(store.getState().currentCollectionPath).toBe("/ws/new/child") @@ -75,8 +73,7 @@ describe("workspace collection event handlers", () => { store.getState().onEntryMoved({ sourcePath: "/ws/src", - destinationDirPath: "/ws", - newPath: "/ws/dest", + targetPath: "/ws/dest", isDirectory: true, }) @@ -93,10 +90,9 @@ describe("workspace collection event handlers", () => { }) store.getState().onEntryRenamed({ - oldPath: "/ws/other", - newPath: "/ws/new-other", + sourcePath: "/ws/other", + targetPath: "/ws/new-other", isDirectory: true, - newName: "new-other", }) expect(store.getState().currentCollectionPath).toBe("/ws/keep/current") diff --git a/packages/store/src/workspace/collection/index.ts b/packages/store/src/workspace/collection/index.ts index 1d5f9062..7633eb26 100644 --- a/packages/store/src/workspace/collection/index.ts +++ b/packages/store/src/workspace/collection/index.ts @@ -1,39 +1,25 @@ import { isPathEqualOrDescendant } from "@mdit/utils/path-utils" import type { StoreApi } from "zustand" -import { computeCollectionEntries } from "../../collection/helpers/collection-entries" -import type { WorkspaceEntry } from "../workspace-state" - -type EntryCreatedInput = { - parentPath: string - entry: WorkspaceEntry - expandParent?: boolean - expandNewDirectory?: boolean -} type EntriesDeletedInput = { paths: string[] } type EntryRenamedInput = { - oldPath: string - newPath: string + sourcePath: string + targetPath: string isDirectory: boolean - newName: string - clearSyncedName?: boolean } type EntryMovedInput = { sourcePath: string - destinationDirPath: string - newPath: string + targetPath: string isDirectory: boolean - refreshContent?: boolean } export type WorkspaceCollectionState = { currentCollectionPath: string | null lastCollectionPath: string | null - collectionEntries: WorkspaceEntry[] } export type WorkspaceCollectionActions = { @@ -42,8 +28,6 @@ export type WorkspaceCollectionActions = { ) => void resetCollectionPath: () => void toggleCollectionView: () => void - refreshCollectionEntries: () => void - onEntryCreated: (input: EntryCreatedInput) => void onEntriesDeleted: (input: EntriesDeletedInput) => void onEntryRenamed: (input: EntryRenamedInput) => void onEntryMoved: (input: EntryMovedInput) => void @@ -52,24 +36,46 @@ export type WorkspaceCollectionActions = { export type WorkspaceCollectionSlice = WorkspaceCollectionState & WorkspaceCollectionActions -type CollectionStoreState = WorkspaceCollectionState & { - entries: WorkspaceEntry[] -} +type CollectionStoreState = WorkspaceCollectionState const replacePathPrefixIfDescendant = ( path: string | null, - oldPath: string, - newPath: string, + sourcePath: string, + targetPath: string, ): string | null => { - if (!path || !isPathEqualOrDescendant(path, oldPath)) { + if (!path || !isPathEqualOrDescendant(path, sourcePath)) { return path } - if (path === oldPath) { - return newPath + if (path === sourcePath) { + return targetPath + } + + return `${targetPath}${path.slice(sourcePath.length)}` +} + +const rebaseCollectionPaths = ( + state: WorkspaceCollectionState, + sourcePath: string, + targetPath: string, + isDirectory: boolean, +): WorkspaceCollectionState => { + if (!isDirectory) { + return state } - return `${newPath}${path.slice(oldPath.length)}` + return { + currentCollectionPath: replacePathPrefixIfDescendant( + state.currentCollectionPath, + sourcePath, + targetPath, + ), + lastCollectionPath: replacePathPrefixIfDescendant( + state.lastCollectionPath, + sourcePath, + targetPath, + ), + } } export const buildWorkspaceCollectionState = ( @@ -77,7 +83,6 @@ export const buildWorkspaceCollectionState = ( ): WorkspaceCollectionState => ({ currentCollectionPath: null, lastCollectionPath: null, - collectionEntries: [], ...overrides, }) @@ -108,7 +113,6 @@ export const createWorkspaceCollectionActions = < currentCollectionPath: nextPath, lastCollectionPath: nextPath !== null ? nextPath : state.lastCollectionPath, - collectionEntries: computeCollectionEntries(nextPath, get().entries), } }) }, @@ -122,37 +126,14 @@ export const createWorkspaceCollectionActions = < if (currentCollectionPath !== null) { setCollectionState({ currentCollectionPath: null, - collectionEntries: [], }) } else if (lastCollectionPath !== null) { setCollectionState({ currentCollectionPath: lastCollectionPath, - collectionEntries: computeCollectionEntries( - lastCollectionPath, - get().entries, - ), }) } }, - refreshCollectionEntries: () => { - setCollectionState((state) => ({ - collectionEntries: computeCollectionEntries( - state.currentCollectionPath, - get().entries, - ), - })) - }, - - onEntryCreated: (_input) => { - setCollectionState((state) => ({ - collectionEntries: computeCollectionEntries( - state.currentCollectionPath, - get().entries, - ), - })) - }, - onEntriesDeleted: ({ paths }) => { setCollectionState((state) => { const { currentCollectionPath, lastCollectionPath } = state @@ -177,68 +158,20 @@ export const createWorkspaceCollectionActions = < return { currentCollectionPath: nextCurrentCollectionPath, lastCollectionPath: nextLastCollectionPath, - collectionEntries: computeCollectionEntries( - nextCurrentCollectionPath, - get().entries, - ), } }) }, - onEntryRenamed: ({ oldPath, newPath, isDirectory }) => { - setCollectionState((state) => { - const nextCurrentCollectionPath = isDirectory - ? replacePathPrefixIfDescendant( - state.currentCollectionPath, - oldPath, - newPath, - ) - : state.currentCollectionPath - const nextLastCollectionPath = isDirectory - ? replacePathPrefixIfDescendant( - state.lastCollectionPath, - oldPath, - newPath, - ) - : state.lastCollectionPath - - return { - currentCollectionPath: nextCurrentCollectionPath, - lastCollectionPath: nextLastCollectionPath, - collectionEntries: computeCollectionEntries( - nextCurrentCollectionPath, - get().entries, - ), - } - }) + onEntryRenamed: ({ sourcePath, targetPath, isDirectory }) => { + setCollectionState((state) => + rebaseCollectionPaths(state, sourcePath, targetPath, isDirectory), + ) }, - onEntryMoved: ({ sourcePath, newPath, isDirectory }) => { - setCollectionState((state) => { - const nextCurrentCollectionPath = isDirectory - ? replacePathPrefixIfDescendant( - state.currentCollectionPath, - sourcePath, - newPath, - ) - : state.currentCollectionPath - const nextLastCollectionPath = isDirectory - ? replacePathPrefixIfDescendant( - state.lastCollectionPath, - sourcePath, - newPath, - ) - : state.lastCollectionPath - - return { - currentCollectionPath: nextCurrentCollectionPath, - lastCollectionPath: nextLastCollectionPath, - collectionEntries: computeCollectionEntries( - nextCurrentCollectionPath, - get().entries, - ), - } - }) + onEntryMoved: ({ sourcePath, targetPath, isDirectory }) => { + setCollectionState((state) => + rebaseCollectionPaths(state, sourcePath, targetPath, isDirectory), + ) }, } } diff --git a/packages/store/src/workspace/lifecycle/actions.test.ts b/packages/store/src/workspace/lifecycle/actions.test.ts index 94d4912c..44adb4c7 100644 --- a/packages/store/src/workspace/lifecycle/actions.test.ts +++ b/packages/store/src/workspace/lifecycle/actions.test.ts @@ -35,9 +35,6 @@ describe("lifecycle-actions", () => { unwatchFn: unwatch, currentCollectionPath: "/old/folder", lastCollectionPath: "/old/folder", - collectionEntries: [ - { path: "/old/folder/note.md", name: "note.md", isDirectory: false }, - ], }) deps.historyRepository.listWorkspacePaths.mockResolvedValue([ "/new", @@ -53,7 +50,6 @@ describe("lifecycle-actions", () => { expect(getState().getIndexingConfig).toHaveBeenCalledWith("/new") expect(getState().currentCollectionPath).toBeNull() expect(getState().lastCollectionPath).toBeNull() - expect(getState().collectionEntries).toEqual([]) expect(getState().workspacePath).toBe("/new") expect(getState().unwatchFn).toBeNull() }) @@ -127,9 +123,6 @@ describe("lifecycle-actions", () => { unwatchFn: unwatch, currentCollectionPath: "/old/folder", lastCollectionPath: "/old/folder", - collectionEntries: [ - { path: "/old/folder/note.md", name: "note.md", isDirectory: false }, - ], }) deps.historyRepository.listWorkspacePaths.mockResolvedValue(["/other"]) @@ -140,7 +133,6 @@ describe("lifecycle-actions", () => { expect(getState().resetIndexingState).toHaveBeenCalled() expect(getState().currentCollectionPath).toBeNull() expect(getState().lastCollectionPath).toBeNull() - expect(getState().collectionEntries).toEqual([]) expect(getState().workspacePath).toBeNull() expect(getState().unwatchFn).toBeNull() }) @@ -197,9 +189,6 @@ describe("lifecycle-actions", () => { unwatchFn: unwatch, currentCollectionPath: "/old/folder", lastCollectionPath: "/old/folder", - collectionEntries: [ - { path: "/old/folder/note.md", name: "note.md", isDirectory: false }, - ], }) deps.historyRepository.listWorkspacePaths.mockResolvedValue(["/new"]) deps.fileSystemRepository.isExistingDirectory.mockResolvedValue(true) @@ -211,7 +200,6 @@ describe("lifecycle-actions", () => { expect(getState().getIndexingConfig).toHaveBeenCalledWith("/new") expect(getState().currentCollectionPath).toBeNull() expect(getState().lastCollectionPath).toBeNull() - expect(getState().collectionEntries).toEqual([]) expect(getState().workspacePath).toBe("/new") expect(getState().unwatchFn).toBeNull() }) diff --git a/packages/store/src/workspace/shared/action-test-helpers.ts b/packages/store/src/workspace/shared/action-test-helpers.ts index 5714baf3..f79dc8aa 100644 --- a/packages/store/src/workspace/shared/action-test-helpers.ts +++ b/packages/store/src/workspace/shared/action-test-helpers.ts @@ -201,11 +201,8 @@ export function createActionTestContext() { ...state, currentCollectionPath: null, lastCollectionPath: null, - collectionEntries: [], } }), - refreshCollectionEntries: vi.fn(), - onEntryCreated: vi.fn(), onEntriesDeleted: vi.fn(), onEntryRenamed: vi.fn(), onEntryMoved: vi.fn(), diff --git a/packages/store/src/workspace/tree/actions.test.ts b/packages/store/src/workspace/tree/actions.test.ts index e408af43..622c5dc3 100644 --- a/packages/store/src/workspace/tree/actions.test.ts +++ b/packages/store/src/workspace/tree/actions.test.ts @@ -31,7 +31,7 @@ describe("workspace-tree-actions", () => { expect(actions.getEntryByPath("/ws/missing.md")).toBeNull() }) - it("updateEntries refreshes collection state directly", () => { + it("updateEntries replaces workspace entries", () => { const { context, getState, setState } = createActionTestContext() const actions = createTreeActions(context) setState({ workspacePath: "/ws" }) @@ -41,7 +41,6 @@ describe("workspace-tree-actions", () => { ]) expect(getState().entries).toHaveLength(1) - expect(getState().refreshCollectionEntries).toHaveBeenCalledTimes(1) }) it("readWorkspaceEntriesFromPath reads recursively, filters hidden entries, and sorts results", async () => { diff --git a/packages/store/src/workspace/tree/actions.ts b/packages/store/src/workspace/tree/actions.ts index 3e74dca0..a0eea68d 100644 --- a/packages/store/src/workspace/tree/actions.ts +++ b/packages/store/src/workspace/tree/actions.ts @@ -39,7 +39,6 @@ export const createTreeActions = ( if (options?.emitEvent === false) { return } - ctx.get().refreshCollectionEntries() }, refreshWorkspaceEntries: async () => { diff --git a/packages/store/src/workspace/tree/entry-actions.test.ts b/packages/store/src/workspace/tree/entry-actions.test.ts index 7405a94c..c47b2722 100644 --- a/packages/store/src/workspace/tree/entry-actions.test.ts +++ b/packages/store/src/workspace/tree/entry-actions.test.ts @@ -3,7 +3,7 @@ import { createActionTestContext } from "../shared/action-test-helpers" import { createTreeEntryActions } from "./entry-actions" describe("tree/entry-actions", () => { - it("entryCreated syncs collection state directly", async () => { + it("entryCreated updates entries without emitting a tree event", async () => { const { context, getState, setState } = createActionTestContext() setState({ workspacePath: "/ws", @@ -22,16 +22,16 @@ describe("tree/entry-actions", () => { expandParent: true, }) - expect(getState().onEntryCreated).toHaveBeenCalledWith({ - parentPath: "/ws", - entry: { - path: "/ws/a.md", - name: "a.md", - isDirectory: false, - }, - expandParent: true, - expandNewDirectory: false, - }) + expect(getState().updateEntries).toHaveBeenCalledWith( + [ + { + path: "/ws/a.md", + name: "a.md", + isDirectory: false, + }, + ], + { emitEvent: false }, + ) }) it("entriesDeleted syncs tabs and collection before persistence", async () => { @@ -89,10 +89,9 @@ describe("tree/entry-actions", () => { "/ws/renamed", ) expect(getState().onEntryRenamed).toHaveBeenCalledWith({ - oldPath: "/ws/folder", - newPath: "/ws/renamed", + sourcePath: "/ws/folder", + targetPath: "/ws/renamed", isDirectory: true, - newName: "renamed", }) }) @@ -126,8 +125,7 @@ describe("tree/entry-actions", () => { ) expect(getState().onEntryMoved).toHaveBeenCalledWith({ sourcePath: "/ws/folder", - destinationDirPath: "/ws/archive", - newPath: "/ws/archive/folder", + targetPath: "/ws/archive/folder", isDirectory: true, }) }) diff --git a/packages/store/src/workspace/tree/entry-actions.ts b/packages/store/src/workspace/tree/entry-actions.ts index c2554c8a..2e34ac47 100644 --- a/packages/store/src/workspace/tree/entry-actions.ts +++ b/packages/store/src/workspace/tree/entry-actions.ts @@ -75,12 +75,6 @@ export const createTreeEntryActions = ( : addEntryToState(entries, parentPath, entry) store.updateEntries(nextEntries, { emitEvent: false }) - store.onEntryCreated({ - parentPath, - entry, - expandParent, - expandNewDirectory, - }) if (!expandParent && !expandNewDirectory) { return @@ -160,10 +154,9 @@ export const createTreeEntryActions = ( newPath, ) store.onEntryRenamed({ - oldPath, - newPath, + sourcePath: oldPath, + targetPath: newPath, isDirectory, - newName, }) if (!isDirectory) { @@ -230,8 +223,7 @@ export const createTreeEntryActions = ( ) store.onEntryMoved({ sourcePath, - destinationDirPath, - newPath, + targetPath: newPath, isDirectory, }) From 91a501b6f3cd7b8e5b8635087570f6d93768fe7b Mon Sep 17 00:00:00 2001 From: hhhjin Date: Wed, 8 Apr 2026 11:07:44 +0900 Subject: [PATCH 2/2] perf: avoid redundant collection path updates --- .../store/src/workspace/collection/index.ts | 30 ++++++++++++------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/packages/store/src/workspace/collection/index.ts b/packages/store/src/workspace/collection/index.ts index 7633eb26..64bd6207 100644 --- a/packages/store/src/workspace/collection/index.ts +++ b/packages/store/src/workspace/collection/index.ts @@ -64,17 +64,27 @@ const rebaseCollectionPaths = ( return state } + const nextCurrentCollectionPath = replacePathPrefixIfDescendant( + state.currentCollectionPath, + sourcePath, + targetPath, + ) + const nextLastCollectionPath = replacePathPrefixIfDescendant( + state.lastCollectionPath, + sourcePath, + targetPath, + ) + + if ( + nextCurrentCollectionPath === state.currentCollectionPath && + nextLastCollectionPath === state.lastCollectionPath + ) { + return state + } + return { - currentCollectionPath: replacePathPrefixIfDescendant( - state.currentCollectionPath, - sourcePath, - targetPath, - ), - lastCollectionPath: replacePathPrefixIfDescendant( - state.lastCollectionPath, - sourcePath, - targetPath, - ), + currentCollectionPath: nextCurrentCollectionPath, + lastCollectionPath: nextLastCollectionPath, } }