From 323e009b30e7dfb439147e4faafa062d81acf1dc Mon Sep 17 00:00:00 2001 From: hhhjin Date: Tue, 7 Apr 2026 21:59:09 +0900 Subject: [PATCH] refactor: move collection reactions into store integration --- .../store/src/collection/collection-slice.ts | 65 +++--- packages/store/src/index.ts | 2 + .../src/integrations/integrations.test.ts | 197 ++++++++++++++++++ .../register-collection-integration.ts | 70 +++++++ .../store/src/integrations/store-events.ts | 32 +++ .../workspace/shared/action-test-helpers.ts | 5 - .../store/src/workspace/tree/actions.test.ts | 10 +- packages/store/src/workspace/tree/actions.ts | 19 +- .../src/workspace/tree/entry-actions.test.ts | 78 ++++++- .../store/src/workspace/tree/entry-actions.ts | 37 +++- .../store/src/workspace/workspace-ports.ts | 14 +- 11 files changed, 465 insertions(+), 64 deletions(-) create mode 100644 packages/store/src/integrations/register-collection-integration.ts diff --git a/packages/store/src/collection/collection-slice.ts b/packages/store/src/collection/collection-slice.ts index e608bcc1..bb1823ef 100644 --- a/packages/store/src/collection/collection-slice.ts +++ b/packages/store/src/collection/collection-slice.ts @@ -107,7 +107,14 @@ export const prepareCollectionSlice = })) }, - onEntryCreated: () => {}, + onEntryCreated: () => { + set((state) => ({ + collectionEntries: computeCollectionEntries( + state.currentCollectionPath, + get().entries, + ), + })) + }, onEntriesDeleted: ({ paths }) => { set((state) => { @@ -142,21 +149,21 @@ export const prepareCollectionSlice = }, onEntryRenamed: ({ oldPath, newPath, isDirectory }) => { - if (!isDirectory) { - return - } - set((state) => { - const nextCurrentCollectionPath = replacePathPrefixIfDescendant( - state.currentCollectionPath, - oldPath, - newPath, - ) - const nextLastCollectionPath = replacePathPrefixIfDescendant( - state.lastCollectionPath, - oldPath, - newPath, - ) + const nextCurrentCollectionPath = isDirectory + ? replacePathPrefixIfDescendant( + state.currentCollectionPath, + oldPath, + newPath, + ) + : state.currentCollectionPath + const nextLastCollectionPath = isDirectory + ? replacePathPrefixIfDescendant( + state.lastCollectionPath, + oldPath, + newPath, + ) + : state.lastCollectionPath return { currentCollectionPath: nextCurrentCollectionPath, @@ -170,21 +177,21 @@ export const prepareCollectionSlice = }, onEntryMoved: ({ sourcePath, newPath, isDirectory }) => { - if (!isDirectory) { - return - } - set((state) => { - const nextCurrentCollectionPath = replacePathPrefixIfDescendant( - state.currentCollectionPath, - sourcePath, - newPath, - ) - const nextLastCollectionPath = replacePathPrefixIfDescendant( - state.lastCollectionPath, - sourcePath, - newPath, - ) + const nextCurrentCollectionPath = isDirectory + ? replacePathPrefixIfDescendant( + state.currentCollectionPath, + sourcePath, + newPath, + ) + : state.currentCollectionPath + const nextLastCollectionPath = isDirectory + ? replacePathPrefixIfDescendant( + state.lastCollectionPath, + sourcePath, + newPath, + ) + : state.lastCollectionPath return { currentCollectionPath: nextCurrentCollectionPath, diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index b28f75e4..b3290ff8 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -25,6 +25,7 @@ import { type IndexingSliceDependencies, prepareIndexingSlice, } from "./indexing/indexing-slice" +import { registerCollectionIntegration } from "./integrations/register-collection-integration" import { registerGitSyncWorkspaceIntegration } from "./integrations/register-git-sync-workspace-integration" import { registerIndexingIntegration } from "./integrations/register-indexing-integration" import { createStoreEventHub } from "./integrations/store-events" @@ -91,6 +92,7 @@ export const createMditStore = ( ...createUISlice(...args), })) + registerCollectionIntegration(store, events) registerIndexingIntegration(store, events) registerGitSyncWorkspaceIntegration(store, events) diff --git a/packages/store/src/integrations/integrations.test.ts b/packages/store/src/integrations/integrations.test.ts index 517eaeea..f39aa9e6 100644 --- a/packages/store/src/integrations/integrations.test.ts +++ b/packages/store/src/integrations/integrations.test.ts @@ -1,10 +1,207 @@ import { describe, expect, it, vi } from "vitest" +import { createStore } from "zustand/vanilla" import type { MditStore } from ".." +import { prepareCollectionSlice } from "../collection/collection-slice" +import { registerCollectionIntegration } from "./register-collection-integration" import { registerGitSyncWorkspaceIntegration } from "./register-git-sync-workspace-integration" import { registerIndexingIntegration } from "./register-indexing-integration" import { createStoreEventHub } from "./store-events" +type EntryLike = { + path: string + name: string + isDirectory: boolean + children?: EntryLike[] +} + +const makeFile = (path: string, name: string): EntryLike => ({ + path, + name, + isDirectory: false, +}) + +const makeDir = ( + path: string, + name: string, + children: EntryLike[] = [], +): EntryLike => ({ + path, + name, + isDirectory: true, + children, +}) + +const createCollectionIntegrationStore = () => { + const createSlice = prepareCollectionSlice() + + return createStore()((set, get, api) => ({ + workspacePath: "/ws", + entries: [], + ...createSlice(set, get, api), + })) +} + describe("store integrations", () => { + it("refreshes collection entries when workspace entries are replaced", async () => { + const events = createStoreEventHub() + const store = createCollectionIntegrationStore() + store.setState({ + currentCollectionPath: "/ws/folder", + entries: [ + makeDir("/ws/folder", "folder", [ + makeFile("/ws/folder/note.md", "note.md"), + ]), + ], + }) + + registerCollectionIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/entries-replaced", + workspacePath: "/ws", + }) + + expect(store.getState().collectionEntries).toEqual([ + makeFile("/ws/folder/note.md", "note.md"), + ]) + }) + + it("refreshes collection entries when workspace creates an entry", async () => { + const events = createStoreEventHub() + const store = createCollectionIntegrationStore() + store.setState({ + currentCollectionPath: "/ws/folder", + entries: [ + makeDir("/ws/folder", "folder", [ + makeFile("/ws/folder/new.md", "new.md"), + ]), + ], + }) + + registerCollectionIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/entry-created", + workspacePath: "/ws", + parentPath: "/ws/folder", + entry: makeFile("/ws/folder/new.md", "new.md"), + }) + + expect(store.getState().collectionEntries).toEqual([ + makeFile("/ws/folder/new.md", "new.md"), + ]) + }) + + it("clears collection paths when deleted entries remove the active collection", async () => { + const events = createStoreEventHub() + const store = createCollectionIntegrationStore() + store.setState({ + currentCollectionPath: "/ws/folder/child", + lastCollectionPath: "/ws/folder/other", + entries: [makeDir("/ws/another", "another")], + }) + + registerCollectionIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/entries-deleted", + workspacePath: "/ws", + paths: ["/ws/folder"], + }) + + expect(store.getState().currentCollectionPath).toBeNull() + expect(store.getState().lastCollectionPath).toBeNull() + expect(store.getState().collectionEntries).toEqual([]) + }) + + it("rewrites collection paths when directories are renamed", async () => { + const events = createStoreEventHub() + const store = createCollectionIntegrationStore() + store.setState({ + currentCollectionPath: "/ws/old/child", + lastCollectionPath: "/ws/old/other", + entries: [makeDir("/ws/new", "new")], + }) + + registerCollectionIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/entry-renamed", + workspacePath: "/ws", + oldPath: "/ws/old", + newPath: "/ws/new", + isDirectory: true, + newName: "new", + }) + + expect(store.getState().currentCollectionPath).toBe("/ws/new/child") + expect(store.getState().lastCollectionPath).toBe("/ws/new/other") + }) + + it("refreshes collection entries when files are renamed", async () => { + const events = createStoreEventHub() + const store = createCollectionIntegrationStore() + store.setState({ + currentCollectionPath: "/ws/folder", + entries: [ + makeDir("/ws/folder", "folder", [ + makeFile("/ws/folder/new.md", "new.md"), + ]), + ], + }) + + registerCollectionIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/entry-renamed", + workspacePath: "/ws", + oldPath: "/ws/folder/old.md", + newPath: "/ws/folder/new.md", + isDirectory: false, + newName: "new.md", + }) + + expect(store.getState().collectionEntries).toEqual([ + makeFile("/ws/folder/new.md", "new.md"), + ]) + }) + + it("rewrites collection paths when directories are moved", async () => { + const events = createStoreEventHub() + const store = createCollectionIntegrationStore() + store.setState({ + currentCollectionPath: "/ws/src/child", + lastCollectionPath: "/ws/src/other", + entries: [makeDir("/ws/dest", "dest")], + }) + + registerCollectionIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/entry-moved", + workspacePath: "/ws", + sourcePath: "/ws/src", + destinationDirPath: "/ws", + newPath: "/ws/dest", + isDirectory: true, + }) + + expect(store.getState().currentCollectionPath).toBe("/ws/dest/child") + expect(store.getState().lastCollectionPath).toBe("/ws/dest/other") + }) + + it("ignores collection events for a different workspace", async () => { + const events = createStoreEventHub() + const store = createCollectionIntegrationStore() + store.setState({ + workspacePath: "/other", + currentCollectionPath: "/other/folder", + entries: [makeDir("/other/folder", "folder")], + }) + + registerCollectionIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/entries-replaced", + workspacePath: "/ws", + }) + + expect(store.getState().collectionEntries).toEqual([]) + }) + it("resets indexing state when workspace resets", async () => { const events = createStoreEventHub() const state = { diff --git a/packages/store/src/integrations/register-collection-integration.ts b/packages/store/src/integrations/register-collection-integration.ts new file mode 100644 index 00000000..42b89b55 --- /dev/null +++ b/packages/store/src/integrations/register-collection-integration.ts @@ -0,0 +1,70 @@ +import type { MditStore } from ".." +import type { StoreEventHub } from "./store-events" + +export const registerCollectionIntegration = ( + store: MditStore, + events: StoreEventHub, +) => + events.subscribe((event) => { + const state = store.getState() + + switch (event.type) { + case "workspace/entries-replaced": { + if (state.workspacePath !== event.workspacePath) { + return + } + + state.refreshCollectionEntries() + return + } + case "workspace/entry-created": { + if (state.workspacePath !== event.workspacePath) { + return + } + + state.onEntryCreated({ + parentPath: event.parentPath, + entry: event.entry, + expandParent: event.expandParent, + expandNewDirectory: event.expandNewDirectory, + }) + return + } + case "workspace/entries-deleted": { + if (state.workspacePath !== event.workspacePath) { + return + } + + state.onEntriesDeleted({ paths: event.paths }) + return + } + case "workspace/entry-renamed": { + if (state.workspacePath !== event.workspacePath) { + return + } + + state.onEntryRenamed({ + oldPath: event.oldPath, + newPath: event.newPath, + isDirectory: event.isDirectory, + newName: event.newName, + }) + return + } + case "workspace/entry-moved": { + if (state.workspacePath !== event.workspacePath) { + return + } + + state.onEntryMoved({ + sourcePath: event.sourcePath, + destinationDirPath: event.destinationDirPath, + newPath: event.newPath, + isDirectory: event.isDirectory, + }) + return + } + default: + return + } + }) diff --git a/packages/store/src/integrations/store-events.ts b/packages/store/src/integrations/store-events.ts index 8ff57668..32e81846 100644 --- a/packages/store/src/integrations/store-events.ts +++ b/packages/store/src/integrations/store-events.ts @@ -1,6 +1,38 @@ +import type { WorkspaceEntry } from "../workspace/workspace-state" + export type StoreEvent = | { type: "workspace/reset"; workspacePath: string | null } | { type: "workspace/loaded"; workspacePath: string } + | { type: "workspace/entries-replaced"; workspacePath: string } + | { + type: "workspace/entry-created" + workspacePath: string + parentPath: string + entry: WorkspaceEntry + expandParent?: boolean + expandNewDirectory?: boolean + } + | { + type: "workspace/entries-deleted" + workspacePath: string + paths: string[] + } + | { + type: "workspace/entry-renamed" + workspacePath: string + oldPath: string + newPath: string + isDirectory: boolean + newName: string + } + | { + type: "workspace/entry-moved" + workspacePath: string + sourcePath: string + destinationDirPath: string + newPath: string + isDirectory: boolean + } | { type: "git-sync/pulled-changes"; workspacePath: string } export type StoreEventListener = (event: StoreEvent) => void | Promise diff --git a/packages/store/src/workspace/shared/action-test-helpers.ts b/packages/store/src/workspace/shared/action-test-helpers.ts index 6e9400e7..a450f88c 100644 --- a/packages/store/src/workspace/shared/action-test-helpers.ts +++ b/packages/store/src/workspace/shared/action-test-helpers.ts @@ -128,11 +128,6 @@ export function createActionTestContext() { }), }, collection: { - refreshCollectionEntries: vi.fn(), - onEntryCreated: vi.fn(), - onEntriesDeleted: vi.fn(), - onEntryRenamed: vi.fn(), - onEntryMoved: vi.fn(), resetCollectionPath: vi.fn(), getCurrentCollectionPath: vi.fn(() => state.currentCollectionPath), }, diff --git a/packages/store/src/workspace/tree/actions.test.ts b/packages/store/src/workspace/tree/actions.test.ts index 30fae98a..a4c169ec 100644 --- a/packages/store/src/workspace/tree/actions.test.ts +++ b/packages/store/src/workspace/tree/actions.test.ts @@ -31,16 +31,20 @@ describe("workspace-tree-actions", () => { expect(actions.getEntryByPath("/ws/missing.md")).toBeNull() }) - it("updateEntries refreshes collection entries through ports", () => { - const { context, ports, getState } = createActionTestContext() + it("updateEntries emits workspace entries replaced events", () => { + const { context, events, getState, setState } = createActionTestContext() const actions = createTreeActions(context) + setState({ workspacePath: "/ws" }) actions.updateEntries([ { path: "/ws/a.md", name: "a.md", isDirectory: false }, ]) expect(getState().entries).toHaveLength(1) - expect(ports.collection.refreshCollectionEntries).toHaveBeenCalledTimes(1) + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/entries-replaced", + workspacePath: "/ws", + }) }) 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 95dfe1dc..55625783 100644 --- a/packages/store/src/workspace/tree/actions.ts +++ b/packages/store/src/workspace/tree/actions.ts @@ -11,6 +11,9 @@ export type WorkspaceTreeActions = { entriesOrAction: | WorkspaceEntry[] | ((entries: WorkspaceEntry[]) => WorkspaceEntry[]), + options?: { + emitEvent?: boolean + }, ) => void refreshWorkspaceEntries: () => Promise } @@ -27,13 +30,25 @@ export const createTreeActions = ( ctx.set({ isEditMode }) }, - updateEntries: (entriesOrAction) => { + updateEntries: (entriesOrAction, options) => { const entries = typeof entriesOrAction === "function" ? entriesOrAction(ctx.get().entries) : entriesOrAction ctx.set({ entries }) - ctx.ports.collection.refreshCollectionEntries() + const workspacePath = ctx.get().workspacePath + if (!workspacePath || options?.emitEvent === false) { + return + } + + void ctx.runtime.events + .emit({ + type: "workspace/entries-replaced", + workspacePath, + }) + .catch((error) => { + console.error("Failed to emit workspace entries replaced event:", error) + }) }, 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 3dd99904..4c8a080d 100644 --- a/packages/store/src/workspace/tree/entry-actions.test.ts +++ b/packages/store/src/workspace/tree/entry-actions.test.ts @@ -3,8 +3,41 @@ import { createActionTestContext } from "../shared/action-test-helpers" import { createTreeEntryActions } from "./entry-actions" describe("tree/entry-actions", () => { + it("entryCreated emits workspace entry created events", async () => { + const { context, events, setState } = createActionTestContext() + setState({ + workspacePath: "/ws", + entries: [], + }) + + const actions = createTreeEntryActions(context) + + await actions.entryCreated({ + parentPath: "/ws", + entry: { + path: "/ws/a.md", + name: "a.md", + isDirectory: false, + }, + expandParent: true, + }) + + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/entry-created", + workspacePath: "/ws", + parentPath: "/ws", + entry: { + path: "/ws/a.md", + name: "a.md", + isDirectory: false, + }, + expandParent: true, + expandNewDirectory: false, + }) + }) + it("entriesDeleted removes history paths in one batch", async () => { - const { context, ports, setState } = createActionTestContext() + const { context, events, ports, setState } = createActionTestContext() setState({ workspacePath: "/ws", entries: [ @@ -25,7 +58,9 @@ describe("tree/entry-actions", () => { "/ws/a.md", "/ws/folder", ]) - expect(ports.collection.onEntriesDeleted).toHaveBeenCalledWith({ + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/entries-deleted", + workspacePath: "/ws", paths: ["/ws/a.md", "/ws/folder"], }) }) @@ -47,8 +82,8 @@ describe("tree/entry-actions", () => { ]) }) - it("entryRenamed updates tab/history via ports and notifies collection", async () => { - const { context, ports, setState } = createActionTestContext() + it("entryRenamed updates tab/history via ports and emits workspace rename events", async () => { + const { context, events, ports, setState } = createActionTestContext() setState({ workspacePath: "/ws", entries: [{ path: "/ws/folder", name: "folder", isDirectory: true }], @@ -74,7 +109,9 @@ describe("tree/entry-actions", () => { "/ws/folder", "/ws/renamed", ) - expect(ports.collection.onEntryRenamed).toHaveBeenCalledWith({ + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/entry-renamed", + workspacePath: "/ws", oldPath: "/ws/folder", newPath: "/ws/renamed", isDirectory: true, @@ -82,6 +119,37 @@ describe("tree/entry-actions", () => { }) }) + it("entryMoved emits workspace move events", async () => { + const { context, events, ports, setState } = createActionTestContext() + setState({ + workspacePath: "/ws", + entries: [{ path: "/ws/folder", name: "folder", isDirectory: true }], + }) + + const actions = createTreeEntryActions(context) + + await actions.entryMoved({ + sourcePath: "/ws/folder", + destinationDirPath: "/ws/archive", + newPath: "/ws/archive/folder", + isDirectory: true, + }) + + expect(ports.tab.renameTab).toHaveBeenCalledWith( + "/ws/folder", + "/ws/archive/folder", + { refreshContent: false }, + ) + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/entry-moved", + workspacePath: "/ws", + sourcePath: "/ws/folder", + destinationDirPath: "/ws/archive", + newPath: "/ws/archive/folder", + isDirectory: true, + }) + }) + it("updateEntryModifiedDate updates createdAt and modifiedAt", async () => { const { context, deps, getState, setState } = createActionTestContext() const actions = createTreeEntryActions(context) diff --git a/packages/store/src/workspace/tree/entry-actions.ts b/packages/store/src/workspace/tree/entry-actions.ts index 188ab128..cd6529da 100644 --- a/packages/store/src/workspace/tree/entry-actions.ts +++ b/packages/store/src/workspace/tree/entry-actions.ts @@ -69,8 +69,10 @@ export const createTreeEntryActions = ( ? sortWorkspaceEntries([...entries, entry]) : addEntryToState(entries, parentPath, entry) - ctx.get().updateEntries(nextEntries) - ctx.ports.collection.onEntryCreated({ + ctx.get().updateEntries(nextEntries, { emitEvent: false }) + await ctx.runtime.events.emit({ + type: "workspace/entry-created", + workspacePath, parentPath, entry, expandParent, @@ -101,7 +103,9 @@ export const createTreeEntryActions = ( ctx.ports.tab.removePathsFromHistory(paths) - ctx.get().updateEntries(removeEntriesFromState(entries, paths)) + ctx.get().updateEntries(removeEntriesFromState(entries, paths), { + emitEvent: false, + }) const nextExpanded = removeExpandedDirectories(expandedDirectories, paths) await persistExpandedDirectoriesIfChanged( @@ -119,7 +123,11 @@ export const createTreeEntryActions = ( nextPinned, ) - ctx.ports.collection.onEntriesDeleted({ paths }) + await ctx.runtime.events.emit({ + type: "workspace/entries-deleted", + workspacePath, + paths, + }) }, entryRenamed: async ({ @@ -138,9 +146,19 @@ export const createTreeEntryActions = ( ctx .get() - .updateEntries(updateEntryInState(entries, oldPath, newPath, newName)) + .updateEntries(updateEntryInState(entries, oldPath, newPath, newName), { + emitEvent: false, + }) if (!isDirectory) { + await ctx.runtime.events.emit({ + type: "workspace/entry-renamed", + workspacePath, + oldPath, + newPath, + isDirectory, + newName, + }) return } @@ -168,7 +186,9 @@ export const createTreeEntryActions = ( nextPinned, ) - ctx.ports.collection.onEntryRenamed({ + await ctx.runtime.events.emit({ + type: "workspace/entry-renamed", + workspacePath, oldPath, newPath, isDirectory, @@ -202,6 +222,7 @@ export const createTreeEntryActions = ( workspacePath, newPath, ), + { emitEvent: false }, ) if (isDirectory) { @@ -230,7 +251,9 @@ export const createTreeEntryActions = ( ) } - ctx.ports.collection.onEntryMoved({ + await ctx.runtime.events.emit({ + type: "workspace/entry-moved", + workspacePath, sourcePath, destinationDirPath, newPath, diff --git a/packages/store/src/workspace/workspace-ports.ts b/packages/store/src/workspace/workspace-ports.ts index aa16dc58..7d51635f 100644 --- a/packages/store/src/workspace/workspace-ports.ts +++ b/packages/store/src/workspace/workspace-ports.ts @@ -19,13 +19,7 @@ export type WorkspacePorts = { > collection: Pick< CollectionSlice, - | "refreshCollectionEntries" - | "onEntryCreated" - | "onEntriesDeleted" - | "onEntryRenamed" - | "onEntryMoved" - | "resetCollectionPath" - | "getCurrentCollectionPath" + "resetCollectionPath" | "getCurrentCollectionPath" > } @@ -50,12 +44,6 @@ export const createWorkspacePorts = ( getActiveTabPath: () => get().getActiveTabPath(), }, collection: { - refreshCollectionEntries: (...args) => - get().refreshCollectionEntries(...args), - onEntryCreated: (...args) => get().onEntryCreated(...args), - onEntriesDeleted: (...args) => get().onEntriesDeleted(...args), - onEntryRenamed: (...args) => get().onEntryRenamed(...args), - onEntryMoved: (...args) => get().onEntryMoved(...args), resetCollectionPath: (...args) => get().resetCollectionPath(...args), getCurrentCollectionPath: () => get().getCurrentCollectionPath(), },