diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index b3290ff8..852d8b07 100644 --- a/packages/store/src/index.ts +++ b/packages/store/src/index.ts @@ -28,6 +28,7 @@ import { import { registerCollectionIntegration } from "./integrations/register-collection-integration" import { registerGitSyncWorkspaceIntegration } from "./integrations/register-git-sync-workspace-integration" import { registerIndexingIntegration } from "./integrations/register-indexing-integration" +import { registerTabPathIntegration } from "./integrations/register-tab-path-integration" import { createStoreEventHub } from "./integrations/store-events" import type { TabSlice } from "./tab/tab-slice" import { prepareTabSlice, type TabSliceDependencies } from "./tab/tab-slice" @@ -95,6 +96,7 @@ export const createMditStore = ( registerCollectionIntegration(store, events) registerIndexingIntegration(store, events) registerGitSyncWorkspaceIntegration(store, events) + registerTabPathIntegration(store, events) return store } diff --git a/packages/store/src/integrations/integrations.test.ts b/packages/store/src/integrations/integrations.test.ts index f39aa9e6..d88638ad 100644 --- a/packages/store/src/integrations/integrations.test.ts +++ b/packages/store/src/integrations/integrations.test.ts @@ -2,9 +2,15 @@ import { describe, expect, it, vi } from "vitest" import { createStore } from "zustand/vanilla" import type { MditStore } from ".." import { prepareCollectionSlice } from "../collection/collection-slice" +import { + prepareTabSlice, + type TabSlice, + type TabSliceDependencies, +} from "../tab/tab-slice" import { registerCollectionIntegration } from "./register-collection-integration" import { registerGitSyncWorkspaceIntegration } from "./register-git-sync-workspace-integration" import { registerIndexingIntegration } from "./register-indexing-integration" +import { registerTabPathIntegration } from "./register-tab-path-integration" import { createStoreEventHub } from "./store-events" type EntryLike = { @@ -41,6 +47,34 @@ const createCollectionIntegrationStore = () => { })) } +type TabIntegrationState = TabSlice & { + workspacePath: string | null +} + +const createTabIntegrationStore = ( + overrides: Partial = {}, +) => { + const readTextFile = + overrides.readTextFile ?? vi.fn(async (path: string) => `content:${path}`) + const renameFile = overrides.renameFile ?? vi.fn(async () => undefined) + const saveSettings = overrides.saveSettings ?? vi.fn(async () => undefined) + const createSlice = prepareTabSlice({ + readTextFile, + renameFile, + saveSettings, + }) as any + + const store = createStore()((set, get, api) => ({ + workspacePath: "/ws", + ...createSlice(set, get, api), + })) + + return { + store, + readTextFile, + } +} + describe("store integrations", () => { it("refreshes collection entries when workspace entries are replaced", async () => { const events = createStoreEventHub() @@ -202,6 +236,103 @@ describe("store integrations", () => { expect(store.getState().collectionEntries).toEqual([]) }) + it("removes deleted paths from tab history", async () => { + const events = createStoreEventHub() + const { store } = createTabIntegrationStore() + + await store.getState().openTab("/ws/a.md") + await store.getState().openTab("/ws/folder/note.md") + await store.getState().openTab("/ws/keep.md") + + registerTabPathIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/tab-paths-removed", + workspacePath: "/ws", + paths: ["/ws/a.md", "/ws/folder"], + }) + + expect(store.getState().tabs.map((tab) => tab.path)).toEqual([ + "/ws/keep.md", + ]) + expect(store.getState().history.map((entry) => entry.path)).toEqual([ + "/ws/keep.md", + ]) + expect(store.getState().historyIndex).toBe(0) + }) + + it("renames open tabs and history paths when a tab path is renamed", async () => { + const events = createStoreEventHub() + const { store } = createTabIntegrationStore() + + await store.getState().openTab("/ws/old/note.md") + + registerTabPathIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/tab-path-renamed", + workspacePath: "/ws", + oldPath: "/ws/old", + newPath: "/ws/new", + clearSyncedName: true, + }) + + expect(store.getState().tabs.map((tab) => tab.path)).toEqual([ + "/ws/new/note.md", + ]) + expect(store.getState().history.map((entry) => entry.path)).toEqual([ + "/ws/new/note.md", + ]) + }) + + it("moves open tabs and history paths when a tab path is moved", async () => { + const events = createStoreEventHub() + const { store, readTextFile } = createTabIntegrationStore() + const readTextFileMock = vi.mocked(readTextFile) + + await store.getState().openTab("/ws/folder/note.md") + readTextFileMock.mockClear() + + registerTabPathIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/tab-path-moved", + workspacePath: "/ws", + sourcePath: "/ws/folder", + newPath: "/ws/archive/folder", + refreshContent: true, + }) + + expect(store.getState().tabs.map((tab) => tab.path)).toEqual([ + "/ws/archive/folder/note.md", + ]) + expect(store.getState().history.map((entry) => entry.path)).toEqual([ + "/ws/archive/folder/note.md", + ]) + expect(readTextFileMock).not.toHaveBeenCalled() + }) + + it("ignores tab path events for a different workspace", async () => { + const events = createStoreEventHub() + const { store } = createTabIntegrationStore() + + await store.getState().openTab("/other/a.md") + store.setState({ workspacePath: "/other" }) + + registerTabPathIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/tab-path-renamed", + workspacePath: "/ws", + oldPath: "/other/a.md", + newPath: "/other/b.md", + clearSyncedName: false, + }) + + expect(store.getState().tabs.map((tab) => tab.path)).toEqual([ + "/other/a.md", + ]) + expect(store.getState().history.map((entry) => entry.path)).toEqual([ + "/other/a.md", + ]) + }) + it("resets indexing state when workspace resets", async () => { const events = createStoreEventHub() const state = { diff --git a/packages/store/src/integrations/register-tab-path-integration.ts b/packages/store/src/integrations/register-tab-path-integration.ts new file mode 100644 index 00000000..6251a10e --- /dev/null +++ b/packages/store/src/integrations/register-tab-path-integration.ts @@ -0,0 +1,40 @@ +import type { MditStore } from ".." +import type { StoreEventHub } from "./store-events" + +export const registerTabPathIntegration = ( + store: MditStore, + events: StoreEventHub, +) => + events.subscribe(async (event) => { + const state = store.getState() + + if ( + state.workspacePath == null || + state.workspacePath !== event.workspacePath + ) { + return + } + + switch (event.type) { + case "workspace/tab-paths-removed": { + state.removePathsFromHistory(event.paths) + return + } + case "workspace/tab-path-renamed": { + await state.renameTab(event.oldPath, event.newPath, { + clearSyncedName: event.clearSyncedName, + }) + state.updateHistoryPath(event.oldPath, event.newPath) + return + } + case "workspace/tab-path-moved": { + await state.renameTab(event.sourcePath, event.newPath, { + refreshContent: event.refreshContent, + }) + state.updateHistoryPath(event.sourcePath, event.newPath) + return + } + default: + return + } + }) diff --git a/packages/store/src/integrations/store-events.ts b/packages/store/src/integrations/store-events.ts index 32e81846..f5b2279f 100644 --- a/packages/store/src/integrations/store-events.ts +++ b/packages/store/src/integrations/store-events.ts @@ -4,6 +4,25 @@ export type StoreEvent = | { type: "workspace/reset"; workspacePath: string | null } | { type: "workspace/loaded"; workspacePath: string } | { type: "workspace/entries-replaced"; workspacePath: string } + | { + type: "workspace/tab-paths-removed" + workspacePath: string + paths: string[] + } + | { + type: "workspace/tab-path-renamed" + workspacePath: string + oldPath: string + newPath: string + clearSyncedName: boolean + } + | { + type: "workspace/tab-path-moved" + workspacePath: string + sourcePath: string + newPath: string + refreshContent: boolean + } | { type: "workspace/entry-created" workspacePath: string diff --git a/packages/store/src/workspace/fs/structure-actions.test.ts b/packages/store/src/workspace/fs/structure-actions.test.ts index 60e050bf..872b74fb 100644 --- a/packages/store/src/workspace/fs/structure-actions.test.ts +++ b/packages/store/src/workspace/fs/structure-actions.test.ts @@ -215,11 +215,11 @@ describe("fs-structure-actions", () => { }) it("renameEntry updates tab path in edit mode without entryRenamed", async () => { - const { context, deps, ports, setState, getState } = + const { context, deps, events, ports, setState, getState } = createActionTestContext() const actions = createFsStructureActions(context) getState().entryRenamed = vi.fn().mockResolvedValue(undefined) - setState({ isEditMode: true }) + setState({ isEditMode: true, workspacePath: "/ws" }) const renamedPath = await actions.renameEntry( { @@ -235,13 +235,50 @@ describe("fs-structure-actions", () => { "/ws/old.md", "/ws/new.md", ) + expect(ports.tab.renameTab).not.toHaveBeenCalled() + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/tab-path-renamed", + workspacePath: "/ws", + oldPath: "/ws/old.md", + newPath: "/ws/new.md", + clearSyncedName: true, + }) + expect(getState().entryRenamed).not.toHaveBeenCalled() + }) + + it("renameEntry falls back to direct tab rename in edit mode without workspace", async () => { + const { context, deps, events, ports, setState, getState } = + createActionTestContext() + const actions = createFsStructureActions(context) + getState().entryRenamed = vi.fn().mockResolvedValue(undefined) + setState({ isEditMode: true, workspacePath: null }) + + const renamedPath = await actions.renameEntry( + { + path: "/tmp/old.md", + name: "old.md", + isDirectory: false, + }, + "new.md", + ) + + expect(renamedPath).toBe("/tmp/new.md") + expect(deps.fileSystemRepository.rename).toHaveBeenCalledWith( + "/tmp/old.md", + "/tmp/new.md", + ) expect(ports.tab.renameTab).toHaveBeenCalledWith( - "/ws/old.md", - "/ws/new.md", + "/tmp/old.md", + "/tmp/new.md", { clearSyncedName: true, }, ) + expect(ports.tab.updateHistoryPath).toHaveBeenCalledWith( + "/tmp/old.md", + "/tmp/new.md", + ) + expect(events.emit).not.toHaveBeenCalled() expect(getState().entryRenamed).not.toHaveBeenCalled() }) diff --git a/packages/store/src/workspace/fs/structure-actions.ts b/packages/store/src/workspace/fs/structure-actions.ts index b8893248..f646523a 100644 --- a/packages/store/src/workspace/fs/structure-actions.ts +++ b/packages/store/src/workspace/fs/structure-actions.ts @@ -234,7 +234,22 @@ export const createFsStructureActions = ( !entry.isDirectory && !options?.preserveActiveTabSyncedName if (ctx.get().isEditMode) { - await ctx.ports.tab.renameTab(entry.path, nextPath, { clearSyncedName }) + const { workspacePath } = ctx.get() + if (!workspacePath) { + await ctx.ports.tab.renameTab(entry.path, nextPath, { + clearSyncedName, + }) + ctx.ports.tab.updateHistoryPath(entry.path, nextPath) + return nextPath + } + + await ctx.runtime.events.emit({ + type: "workspace/tab-path-renamed", + workspacePath, + oldPath: entry.path, + newPath: nextPath, + clearSyncedName, + }) return nextPath } diff --git a/packages/store/src/workspace/tree/entry-actions.test.ts b/packages/store/src/workspace/tree/entry-actions.test.ts index 4c8a080d..3a70241b 100644 --- a/packages/store/src/workspace/tree/entry-actions.test.ts +++ b/packages/store/src/workspace/tree/entry-actions.test.ts @@ -53,20 +53,21 @@ describe("tree/entry-actions", () => { await actions.entriesDeleted({ paths: ["/ws/a.md", "/ws/folder"] }) expect(ports.tab.closeTab).not.toHaveBeenCalled() - expect(ports.tab.removePathsFromHistory).toHaveBeenCalledTimes(1) - expect(ports.tab.removePathsFromHistory).toHaveBeenCalledWith([ - "/ws/a.md", - "/ws/folder", - ]) - expect(events.emit).toHaveBeenCalledWith({ + expect(ports.tab.removePathsFromHistory).not.toHaveBeenCalled() + expect(events.emit).toHaveBeenNthCalledWith(1, { + type: "workspace/tab-paths-removed", + workspacePath: "/ws", + paths: ["/ws/a.md", "/ws/folder"], + }) + expect(events.emit).toHaveBeenNthCalledWith(2, { type: "workspace/entries-deleted", workspacePath: "/ws", paths: ["/ws/a.md", "/ws/folder"], }) }) - it("entriesDeleted delegates descendant tab cleanup to history removal", async () => { - const { context, ports, setState } = createActionTestContext() + it("entriesDeleted emits tab-path removal events for descendant cleanup", async () => { + const { context, events, ports, setState } = createActionTestContext() setState({ workspacePath: "/ws", entries: [{ path: "/ws/folder", name: "folder", isDirectory: true }], @@ -77,12 +78,45 @@ describe("tree/entry-actions", () => { await actions.entriesDeleted({ paths: ["/ws/folder"] }) expect(ports.tab.closeTab).not.toHaveBeenCalled() - expect(ports.tab.removePathsFromHistory).toHaveBeenCalledWith([ - "/ws/folder", - ]) + expect(ports.tab.removePathsFromHistory).not.toHaveBeenCalled() + expect(events.emit).toHaveBeenNthCalledWith(1, { + type: "workspace/tab-paths-removed", + workspacePath: "/ws", + paths: ["/ws/folder"], + }) + expect(events.emit).toHaveBeenNthCalledWith(2, { + type: "workspace/entries-deleted", + workspacePath: "/ws", + paths: ["/ws/folder"], + }) + }) + + it("entriesDeleted emits tab cleanup before directory persistence", async () => { + const { context, deps, events, setState } = createActionTestContext() + setState({ + workspacePath: "/ws", + entries: [{ path: "/ws/folder", name: "folder", isDirectory: true }], + expandedDirectories: ["/ws/folder"], + }) + deps.settingsRepository.persistExpandedDirectories.mockRejectedValueOnce( + new Error("persist failed"), + ) + + const actions = createTreeEntryActions(context) + + await expect( + actions.entriesDeleted({ paths: ["/ws/folder"] }), + ).rejects.toThrow("persist failed") + + expect(events.emit).toHaveBeenCalledTimes(1) + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/tab-paths-removed", + workspacePath: "/ws", + paths: ["/ws/folder"], + }) }) - it("entryRenamed updates tab/history via ports and emits workspace rename events", async () => { + it("entryRenamed emits tab/history sync events and workspace rename events", async () => { const { context, events, ports, setState } = createActionTestContext() setState({ workspacePath: "/ws", @@ -100,16 +134,16 @@ describe("tree/entry-actions", () => { newName: "renamed", }) - expect(ports.tab.renameTab).toHaveBeenCalledWith( - "/ws/folder", - "/ws/renamed", - { clearSyncedName: false }, - ) - expect(ports.tab.updateHistoryPath).toHaveBeenCalledWith( - "/ws/folder", - "/ws/renamed", - ) - expect(events.emit).toHaveBeenCalledWith({ + expect(ports.tab.renameTab).not.toHaveBeenCalled() + expect(ports.tab.updateHistoryPath).not.toHaveBeenCalled() + expect(events.emit).toHaveBeenNthCalledWith(1, { + type: "workspace/tab-path-renamed", + workspacePath: "/ws", + oldPath: "/ws/folder", + newPath: "/ws/renamed", + clearSyncedName: false, + }) + expect(events.emit).toHaveBeenNthCalledWith(2, { type: "workspace/entry-renamed", workspacePath: "/ws", oldPath: "/ws/folder", @@ -119,7 +153,7 @@ describe("tree/entry-actions", () => { }) }) - it("entryMoved emits workspace move events", async () => { + it("entryMoved emits tab/history sync events and workspace move events", async () => { const { context, events, ports, setState } = createActionTestContext() setState({ workspacePath: "/ws", @@ -135,12 +169,15 @@ describe("tree/entry-actions", () => { isDirectory: true, }) - expect(ports.tab.renameTab).toHaveBeenCalledWith( - "/ws/folder", - "/ws/archive/folder", - { refreshContent: false }, - ) - expect(events.emit).toHaveBeenCalledWith({ + expect(ports.tab.renameTab).not.toHaveBeenCalled() + expect(events.emit).toHaveBeenNthCalledWith(1, { + type: "workspace/tab-path-moved", + workspacePath: "/ws", + sourcePath: "/ws/folder", + newPath: "/ws/archive/folder", + refreshContent: false, + }) + expect(events.emit).toHaveBeenNthCalledWith(2, { type: "workspace/entry-moved", workspacePath: "/ws", sourcePath: "/ws/folder", @@ -150,6 +187,66 @@ describe("tree/entry-actions", () => { }) }) + it("entryMoved preserves refreshContent in tab move events", async () => { + const { context, events, 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, + refreshContent: true, + }) + + expect(events.emit).toHaveBeenNthCalledWith(1, { + type: "workspace/tab-path-moved", + workspacePath: "/ws", + sourcePath: "/ws/folder", + newPath: "/ws/archive/folder", + refreshContent: true, + }) + }) + + it("entryRenamed forwards clearSyncedName in tab rename events", async () => { + const { context, events, setState } = createActionTestContext() + setState({ + workspacePath: "/ws", + entries: [{ path: "/ws/old.md", name: "old.md", isDirectory: false }], + }) + + const actions = createTreeEntryActions(context) + + await actions.entryRenamed({ + oldPath: "/ws/old.md", + newPath: "/ws/new.md", + isDirectory: false, + newName: "new.md", + clearSyncedName: true, + }) + + expect(events.emit).toHaveBeenNthCalledWith(1, { + type: "workspace/tab-path-renamed", + workspacePath: "/ws", + oldPath: "/ws/old.md", + newPath: "/ws/new.md", + clearSyncedName: true, + }) + expect(events.emit).toHaveBeenNthCalledWith(2, { + type: "workspace/entry-renamed", + workspacePath: "/ws", + oldPath: "/ws/old.md", + newPath: "/ws/new.md", + isDirectory: false, + newName: "new.md", + }) + }) + 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 cd6529da..ffd518d1 100644 --- a/packages/store/src/workspace/tree/entry-actions.ts +++ b/packages/store/src/workspace/tree/entry-actions.ts @@ -101,12 +101,16 @@ export const createTreeEntryActions = ( ctx.get() if (!workspacePath) throw new Error("Workspace path is not set") - ctx.ports.tab.removePathsFromHistory(paths) - ctx.get().updateEntries(removeEntriesFromState(entries, paths), { emitEvent: false, }) + await ctx.runtime.events.emit({ + type: "workspace/tab-paths-removed", + workspacePath, + paths, + }) + const nextExpanded = removeExpandedDirectories(expandedDirectories, paths) await persistExpandedDirectoriesIfChanged( ctx, @@ -141,15 +145,20 @@ export const createTreeEntryActions = ( ctx.get() if (!workspacePath) throw new Error("Workspace path is not set") - await ctx.ports.tab.renameTab(oldPath, newPath, { clearSyncedName }) - ctx.ports.tab.updateHistoryPath(oldPath, newPath) - ctx .get() .updateEntries(updateEntryInState(entries, oldPath, newPath, newName), { emitEvent: false, }) + await ctx.runtime.events.emit({ + type: "workspace/tab-path-renamed", + workspacePath, + oldPath, + newPath, + clearSyncedName, + }) + if (!isDirectory) { await ctx.runtime.events.emit({ type: "workspace/entry-renamed", @@ -207,11 +216,6 @@ export const createTreeEntryActions = ( ctx.get() if (!workspacePath) throw new Error("Workspace path is not set") - await ctx.ports.tab.renameTab(sourcePath, newPath, { - refreshContent, - }) - ctx.ports.tab.updateHistoryPath(sourcePath, newPath) - ctx .get() .updateEntries( @@ -225,6 +229,14 @@ export const createTreeEntryActions = ( { emitEvent: false }, ) + await ctx.runtime.events.emit({ + type: "workspace/tab-path-moved", + workspacePath, + sourcePath, + newPath, + refreshContent, + }) + if (isDirectory) { const nextExpanded = renameExpandedDirectories( expandedDirectories,