From 74587d1e1c37fb1e9d57cd6841a3cc06f2a08643 Mon Sep 17 00:00:00 2001 From: hhhjin Date: Tue, 7 Apr 2026 22:59:21 +0900 Subject: [PATCH] refactor: move workspace tab lifecycle into integrations --- packages/store/src/index.ts | 2 + .../src/integrations/integrations.test.ts | 138 ++++++++++++++ .../register-collection-integration.ts | 4 + .../register-tab-lifecycle-integration.ts | 68 +++++++ .../store/src/integrations/store-events.ts | 22 +++ .../workspace/fs/structure-actions.test.ts | 28 ++- .../src/workspace/fs/structure-actions.ts | 6 +- .../src/workspace/lifecycle/actions.test.ts | 94 +++++----- .../store/src/workspace/lifecycle/actions.ts | 6 +- .../store/src/workspace/lifecycle/domain.ts | 32 ++-- .../workspace/shared/action-test-helpers.ts | 11 -- .../src/workspace/tree/entry-actions.test.ts | 15 +- .../store/src/workspace/watch/actions.test.ts | 38 ++-- .../store/src/workspace/watch/batch-apply.ts | 6 +- .../store/src/workspace/workspace-ports.ts | 33 +--- todos/store-followup-prs.md | 176 ------------------ 16 files changed, 361 insertions(+), 318 deletions(-) create mode 100644 packages/store/src/integrations/register-tab-lifecycle-integration.ts delete mode 100644 todos/store-followup-prs.md diff --git a/packages/store/src/index.ts b/packages/store/src/index.ts index 852d8b07..3baae4c7 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 { registerTabLifecycleIntegration } from "./integrations/register-tab-lifecycle-integration" import { registerTabPathIntegration } from "./integrations/register-tab-path-integration" import { createStoreEventHub } from "./integrations/store-events" import type { TabSlice } from "./tab/tab-slice" @@ -96,6 +97,7 @@ export const createMditStore = ( registerCollectionIntegration(store, events) registerIndexingIntegration(store, events) registerGitSyncWorkspaceIntegration(store, events) + registerTabLifecycleIntegration(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 d88638ad..05d5744b 100644 --- a/packages/store/src/integrations/integrations.test.ts +++ b/packages/store/src/integrations/integrations.test.ts @@ -10,6 +10,7 @@ import { import { registerCollectionIntegration } from "./register-collection-integration" import { registerGitSyncWorkspaceIntegration } from "./register-git-sync-workspace-integration" import { registerIndexingIntegration } from "./register-indexing-integration" +import { registerTabLifecycleIntegration } from "./register-tab-lifecycle-integration" import { registerTabPathIntegration } from "./register-tab-path-integration" import { createStoreEventHub } from "./store-events" @@ -236,6 +237,26 @@ describe("store integrations", () => { expect(store.getState().collectionEntries).toEqual([]) }) + it("resets collection state when workspace resets", async () => { + const events = createStoreEventHub() + const store = createCollectionIntegrationStore() + store.setState({ + currentCollectionPath: "/ws/folder", + lastCollectionPath: "/ws/folder", + collectionEntries: [makeFile("/ws/folder/note.md", "note.md")], + }) + + registerCollectionIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/reset", + workspacePath: null, + }) + + expect(store.getState().currentCollectionPath).toBeNull() + expect(store.getState().lastCollectionPath).toBeNull() + expect(store.getState().collectionEntries).toEqual([]) + }) + it("removes deleted paths from tab history", async () => { const events = createStoreEventHub() const { store } = createTabIntegrationStore() @@ -333,6 +354,123 @@ describe("store integrations", () => { ]) }) + it("closes tabs and clears history when workspace tab reset is requested", async () => { + const events = createStoreEventHub() + const { store } = createTabIntegrationStore() + + await store.getState().openTab("/ws/a.md") + await store.getState().openTab("/ws/b.md") + + registerTabLifecycleIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/tab-reset-requested", + workspacePath: "/ws", + clearHistoryWhenNoActiveTab: false, + }) + + expect(store.getState().tabs).toEqual([]) + expect(store.getState().activeTabId).toBeNull() + expect(store.getState().history).toEqual([]) + expect(store.getState().historyIndex).toBe(-1) + }) + + it("clears history without open tabs when explicitly requested", async () => { + const events = createStoreEventHub() + const { store } = createTabIntegrationStore() + + store.setState({ + history: [{ path: "/ws/a.md", selection: null }], + historyIndex: 0, + }) + + registerTabLifecycleIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/tab-reset-requested", + workspacePath: "/ws", + clearHistoryWhenNoActiveTab: true, + }) + + expect(store.getState().history).toEqual([]) + expect(store.getState().historyIndex).toBe(-1) + }) + + it("opens a created note when requested", async () => { + const events = createStoreEventHub() + const { store } = createTabIntegrationStore() + + registerTabLifecycleIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/note-created", + workspacePath: "/ws", + path: "/ws/new.md", + }) + + expect(store.getState().tabs.map((tab) => tab.path)).toEqual(["/ws/new.md"]) + expect(store.getState().history.map((entry) => entry.path)).toEqual([ + "/ws/new.md", + ]) + }) + + it("restores opened file history when requested", async () => { + const events = createStoreEventHub() + const { store } = createTabIntegrationStore() + + registerTabLifecycleIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/opened-files-restore-requested", + workspacePath: "/ws", + paths: ["/ws/a.md", "/ws/b.md"], + }) + + expect(store.getState().tabs.map((tab) => tab.path)).toEqual([ + "/ws/a.md", + "/ws/b.md", + ]) + expect(store.getState().history.map((entry) => entry.path)).toEqual([ + "/ws/a.md", + "/ws/b.md", + ]) + }) + + it("logs restore failures without throwing", async () => { + const events = createStoreEventHub() + const readError = new Error("read failed") + const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => {}) + const { store } = createTabIntegrationStore({ + readTextFile: vi.fn().mockRejectedValue(readError), + }) + + registerTabLifecycleIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/opened-files-restore-requested", + workspacePath: "/ws", + paths: ["/ws/a.md"], + }) + + expect(debugSpy).toHaveBeenCalledWith( + "Failed to hydrate opened file history", + ) + debugSpy.mockRestore() + }) + + it("refreshes open tab content when external updates are requested", async () => { + const events = createStoreEventHub() + const { store } = createTabIntegrationStore() + + await store.getState().openTab("/ws/a.md") + + registerTabLifecycleIntegration(store as unknown as MditStore, events) + await events.emit({ + type: "workspace/tab-content-refresh-requested", + workspacePath: "/ws", + path: "/ws/a.md", + content: "fresh-content", + preserveSelection: true, + }) + + expect(store.getState().tabs[0]?.content).toBe("fresh-content") + }) + 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 index 42b89b55..fea227a6 100644 --- a/packages/store/src/integrations/register-collection-integration.ts +++ b/packages/store/src/integrations/register-collection-integration.ts @@ -9,6 +9,10 @@ export const registerCollectionIntegration = ( const state = store.getState() switch (event.type) { + case "workspace/reset": { + state.resetCollectionPath() + return + } case "workspace/entries-replaced": { if (state.workspacePath !== event.workspacePath) { return diff --git a/packages/store/src/integrations/register-tab-lifecycle-integration.ts b/packages/store/src/integrations/register-tab-lifecycle-integration.ts new file mode 100644 index 00000000..11d0fa06 --- /dev/null +++ b/packages/store/src/integrations/register-tab-lifecycle-integration.ts @@ -0,0 +1,68 @@ +import type { MditStore } from ".." +import type { StoreEventHub } from "./store-events" + +const closeWorkspaceTabs = ( + store: MditStore, + clearHistoryWhenNoActiveTab: boolean, +) => { + const state = store.getState() + const openTabSnapshots = state.getOpenTabSnapshots() + const activeTabPath = state.getActiveTabPath() + + if (activeTabPath || openTabSnapshots.length > 0) { + state.closeAllTabs() + } + + if (openTabSnapshots.length > 0 || clearHistoryWhenNoActiveTab) { + state.clearHistory() + } +} + +export const registerTabLifecycleIntegration = ( + store: MditStore, + events: StoreEventHub, +) => + events.subscribe(async (event) => { + const state = store.getState() + + switch (event.type) { + case "workspace/tab-reset-requested": { + closeWorkspaceTabs(store, event.clearHistoryWhenNoActiveTab) + return + } + case "workspace/note-created": { + if ( + event.workspacePath !== null && + state.workspacePath !== event.workspacePath + ) { + return + } + + await state.openTab(event.path) + return + } + case "workspace/opened-files-restore-requested": { + if (state.workspacePath !== event.workspacePath) { + return + } + + const hydrated = await state.hydrateFromOpenedFiles(event.paths) + if (!hydrated) { + console.debug("Failed to hydrate opened file history") + } + return + } + case "workspace/tab-content-refresh-requested": { + if (state.workspacePath !== event.workspacePath) { + return + } + + state.refreshTabFromExternalContent(event.path, event.content, { + preserveSelection: event.preserveSelection, + }) + return + } + default: + return + } + }) diff --git a/packages/store/src/integrations/store-events.ts b/packages/store/src/integrations/store-events.ts index f5b2279f..98b2ad16 100644 --- a/packages/store/src/integrations/store-events.ts +++ b/packages/store/src/integrations/store-events.ts @@ -3,7 +3,24 @@ import type { WorkspaceEntry } from "../workspace/workspace-state" export type StoreEvent = | { type: "workspace/reset"; workspacePath: string | null } | { type: "workspace/loaded"; workspacePath: string } + | { + type: "workspace/tab-reset-requested" + workspacePath: string | null + clearHistoryWhenNoActiveTab: boolean + } | { type: "workspace/entries-replaced"; workspacePath: string } + | { + type: "workspace/opened-files-restore-requested" + workspacePath: string + paths: string[] + } + | { + type: "workspace/tab-content-refresh-requested" + workspacePath: string + path: string + content: string + preserveSelection: boolean + } | { type: "workspace/tab-paths-removed" workspacePath: string @@ -31,6 +48,11 @@ export type StoreEvent = expandParent?: boolean expandNewDirectory?: boolean } + | { + type: "workspace/note-created" + workspacePath: string | null + path: string + } | { type: "workspace/entries-deleted" 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 e1870d5f..dd5f1455 100644 --- a/packages/store/src/workspace/fs/structure-actions.test.ts +++ b/packages/store/src/workspace/fs/structure-actions.test.ts @@ -47,7 +47,7 @@ describe("fs-structure-actions", () => { }) it("createAndOpenNote delegates tab opening to createNote", async () => { - const { context, getState, ports, setState } = createActionTestContext() + const { context, getState, setState } = createActionTestContext() const actions = createFsStructureActions(context) const createNote = vi.fn().mockResolvedValue("/ws/Untitled.md") @@ -59,7 +59,6 @@ describe("fs-structure-actions", () => { await actions.createAndOpenNote() expect(createNote).toHaveBeenCalledWith("/ws", { openTab: true }) - expect(ports.tab.openTab).not.toHaveBeenCalled() expect(getState().createNote).toBe(createNote) }) @@ -88,6 +87,24 @@ describe("fs-structure-actions", () => { expect(createNote).toHaveBeenCalledWith("/ws/folder", { openTab: true }) }) + it("createNote emits note-created when tab opening is requested", async () => { + const { context, events, getState, setState } = createActionTestContext() + const actions = createFsStructureActions(context) + getState().entryCreated = vi.fn().mockResolvedValue(undefined) + setState({ workspacePath: "/ws" }) + + const createdPath = await actions.createNote("/ws", { openTab: true }) + + expect(createdPath).toBe("/ws/Untitled.md") + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/note-created", + workspacePath: "/ws", + path: "/ws/Untitled.md", + }) + expect(getState().selectedEntryPaths).toEqual(new Set(["/ws/Untitled.md"])) + expect(getState().selectionAnchorPath).toBe("/ws/Untitled.md") + }) + it("renameEntry sanitizes separators from newName", async () => { const { context, deps, getState, setState } = createActionTestContext() const actions = createFsStructureActions(context) @@ -218,7 +235,7 @@ describe("fs-structure-actions", () => { }) it("renameEntry updates tab path in edit mode without entryRenamed", async () => { - const { context, deps, events, ports, setState, getState } = + const { context, deps, events, setState, getState } = createActionTestContext() const actions = createFsStructureActions(context) getState().entryRenamed = vi.fn().mockResolvedValue(undefined) @@ -238,7 +255,6 @@ 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", @@ -250,7 +266,7 @@ describe("fs-structure-actions", () => { }) it("renameEntry rejects in edit mode without workspace", async () => { - const { context, deps, events, ports, setState, getState } = + const { context, deps, events, setState, getState } = createActionTestContext() const actions = createFsStructureActions(context) getState().entryRenamed = vi.fn().mockResolvedValue(undefined) @@ -268,8 +284,6 @@ describe("fs-structure-actions", () => { ).rejects.toThrow("Workspace path is not set") expect(deps.fileSystemRepository.rename).not.toHaveBeenCalled() - expect(ports.tab.renameTab).not.toHaveBeenCalled() - expect(ports.tab.updateHistoryPath).not.toHaveBeenCalled() 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 925bee35..42f501f1 100644 --- a/packages/store/src/workspace/fs/structure-actions.ts +++ b/packages/store/src/workspace/fs/structure-actions.ts @@ -141,7 +141,11 @@ export const createFsStructureActions = ( }) if (options?.openTab) { - await ctx.ports.tab.openTab(filePath) + await ctx.runtime.events.emit({ + type: "workspace/note-created", + workspacePath: ctx.get().workspacePath, + path: filePath, + }) ctx.get().setEntrySelection(createSingleEntrySelection(filePath)) } diff --git a/packages/store/src/workspace/lifecycle/actions.test.ts b/packages/store/src/workspace/lifecycle/actions.test.ts index 4750e0eb..74448afa 100644 --- a/packages/store/src/workspace/lifecycle/actions.test.ts +++ b/packages/store/src/workspace/lifecycle/actions.test.ts @@ -77,7 +77,7 @@ describe("lifecycle-actions", () => { }) it("setWorkspace closes all open tabs when tabs are open", async () => { - const { context, deps, ports, setState } = createActionTestContext() + const { context, deps, events, ports, setState } = createActionTestContext() const actions = createLifecycleActions(context) setState({ workspacePath: "/old", @@ -90,12 +90,15 @@ describe("lifecycle-actions", () => { await actions.setWorkspace("/new") - expect(ports.tab.closeAllTabs).toHaveBeenCalledTimes(1) - expect(ports.tab.clearHistory).toHaveBeenCalledTimes(1) + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/tab-reset-requested", + workspacePath: "/old", + clearHistoryWhenNoActiveTab: true, + }) }) it("setWorkspace aborts when any open tab is unsaved", async () => { - const { context, deps, ports, setState } = createActionTestContext() + const { context, deps, events, ports, setState } = createActionTestContext() const actions = createLifecycleActions(context) setState({ workspacePath: "/old", @@ -107,8 +110,11 @@ describe("lifecycle-actions", () => { await actions.setWorkspace("/new") - expect(ports.tab.closeAllTabs).not.toHaveBeenCalled() - expect(ports.tab.clearHistory).not.toHaveBeenCalled() + expect(events.emit).not.toHaveBeenCalledWith({ + type: "workspace/tab-reset-requested", + workspacePath: "/old", + clearHistoryWhenNoActiveTab: true, + }) expect(deps.historyRepository.touchWorkspace).not.toHaveBeenCalled() expect(deps.toast.error).toHaveBeenCalledWith( "Save open notes before switching workspaces.", @@ -140,7 +146,7 @@ describe("lifecycle-actions", () => { }) it("clearWorkspace aborts when any open tab is unsaved", async () => { - const { context, deps, ports, setState, getState } = + const { context, deps, events, ports, setState, getState } = createActionTestContext() const actions = createLifecycleActions(context) setState({ @@ -154,7 +160,11 @@ describe("lifecycle-actions", () => { await actions.clearWorkspace() expect(deps.fileSystemRepository.moveToTrash).not.toHaveBeenCalled() - expect(ports.tab.closeAllTabs).not.toHaveBeenCalled() + expect(events.emit).not.toHaveBeenCalledWith({ + type: "workspace/tab-reset-requested", + workspacePath: "/old", + clearHistoryWhenNoActiveTab: false, + }) expect(deps.historyRepository.removeWorkspace).not.toHaveBeenCalled() expect(deps.toast.error).toHaveBeenCalledWith( "Save open notes before clearing the workspace.", @@ -284,7 +294,7 @@ describe("lifecycle-actions", () => { }) it("loadWorkspace uses provided recent paths and restores tabs when requested", async () => { - const { context, deps, ports, getState } = createActionTestContext() + const { context, deps, events, getState } = createActionTestContext() const actions = createLifecycleActions(context) deps.fileSystemRepository.readDir.mockImplementation( @@ -307,7 +317,11 @@ describe("lifecycle-actions", () => { expect(getState().workspacePath).toBe("/ws") expect(getState().recentWorkspacePaths).toEqual(["/ws", "/other"]) - expect(ports.tab.hydrateFromOpenedFiles).toHaveBeenCalledWith(["/ws/a.md"]) + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/opened-files-restore-requested", + workspacePath: "/ws", + paths: ["/ws/a.md"], + }) }) it("setWorkspace removes missing path and refreshes workspace list", async () => { @@ -343,8 +357,7 @@ describe("lifecycle-actions", () => { }) it("removeWorkspaceFromHistory keeps current workspace open", async () => { - const { context, deps, ports, setState, getState } = - createActionTestContext() + const { context, deps, setState, getState } = createActionTestContext() const actions = createLifecycleActions(context) setState({ workspacePath: "/current", @@ -359,8 +372,6 @@ describe("lifecycle-actions", () => { ) expect(getState().workspacePath).toBe("/current") expect(getState().recentWorkspacePaths).toEqual(["/other"]) - expect(ports.tab.closeTab).not.toHaveBeenCalled() - expect(ports.tab.clearHistory).not.toHaveBeenCalled() }) it("bootstrapWorkspace sanitizes invalid expanded/pinned directories from settings", async () => { @@ -403,7 +414,7 @@ describe("lifecycle-actions", () => { }) it("bootstrapWorkspace restores opened file history in order", async () => { - const { context, deps, ports } = createActionTestContext() + const { context, deps, events } = createActionTestContext() const actions = createLifecycleActions(context) deps.historyRepository.listWorkspacePaths.mockResolvedValue(["/ws"]) @@ -417,17 +428,15 @@ describe("lifecycle-actions", () => { await bootstrapWorkspace(actions) - expect(ports.tab.hydrateFromOpenedFiles).toHaveBeenCalledTimes(1) - expect(ports.tab.hydrateFromOpenedFiles).toHaveBeenCalledWith([ - "/ws/a.md", - "/ws/b.md", - "/ws/c.md", - ]) - expect(ports.tab.openTab).not.toHaveBeenCalled() + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/opened-files-restore-requested", + workspacePath: "/ws", + paths: ["/ws/a.md", "/ws/b.md", "/ws/c.md"], + }) }) it("bootstrapWorkspace restores only valid opened file paths", async () => { - const { context, deps, ports } = createActionTestContext() + const { context, deps, events } = createActionTestContext() const actions = createLifecycleActions(context) deps.historyRepository.listWorkspacePaths.mockResolvedValue(["/ws"]) @@ -446,16 +455,15 @@ describe("lifecycle-actions", () => { await bootstrapWorkspace(actions) - expect(ports.tab.hydrateFromOpenedFiles).toHaveBeenCalledTimes(1) - expect(ports.tab.hydrateFromOpenedFiles).toHaveBeenCalledWith([ - "/ws/valid-a.md", - "/ws/valid-b.md", - ]) - expect(ports.tab.openTab).not.toHaveBeenCalled() + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/opened-files-restore-requested", + workspacePath: "/ws", + paths: ["/ws/valid-a.md", "/ws/valid-b.md"], + }) }) it("bootstrapWorkspace restores at most five opened file paths", async () => { - const { context, deps, ports } = createActionTestContext() + const { context, deps, events } = createActionTestContext() const actions = createLifecycleActions(context) deps.historyRepository.listWorkspacePaths.mockResolvedValue(["/ws"]) @@ -467,19 +475,15 @@ describe("lifecycle-actions", () => { await bootstrapWorkspace(actions) - expect(ports.tab.hydrateFromOpenedFiles).toHaveBeenCalledTimes(1) - expect(ports.tab.hydrateFromOpenedFiles).toHaveBeenCalledWith([ - "/ws/2.md", - "/ws/3.md", - "/ws/4.md", - "/ws/5.md", - "/ws/6.md", - ]) - expect(ports.tab.openTab).not.toHaveBeenCalled() + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/opened-files-restore-requested", + workspacePath: "/ws", + paths: ["/ws/2.md", "/ws/3.md", "/ws/4.md", "/ws/5.md", "/ws/6.md"], + }) }) - it("bootstrapWorkspace skips tab restore when hydration fails", async () => { - const { context, deps, ports } = createActionTestContext() + it("bootstrapWorkspace emits a restore request for valid paths", async () => { + const { context, deps, events } = createActionTestContext() const actions = createLifecycleActions(context) deps.historyRepository.listWorkspacePaths.mockResolvedValue(["/ws"]) @@ -488,11 +492,13 @@ describe("lifecycle-actions", () => { lastOpenedFilePaths: ["a.md"], }) deps.fileSystemRepository.exists.mockResolvedValue(true) - ports.tab.hydrateFromOpenedFiles.mockResolvedValue(false) await bootstrapWorkspace(actions) - expect(ports.tab.hydrateFromOpenedFiles).toHaveBeenCalledWith(["/ws/a.md"]) - expect(ports.tab.openTab).not.toHaveBeenCalled() + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/opened-files-restore-requested", + workspacePath: "/ws", + paths: ["/ws/a.md"], + }) }) }) diff --git a/packages/store/src/workspace/lifecycle/actions.ts b/packages/store/src/workspace/lifecycle/actions.ts index 77dbd18f..0ec29d29 100644 --- a/packages/store/src/workspace/lifecycle/actions.ts +++ b/packages/store/src/workspace/lifecycle/actions.ts @@ -58,7 +58,9 @@ export const createLifecycleActions = ( return } - closeWorkspaceTabs(ctx, { clearHistoryWhenNoActiveTab: true }) + await closeWorkspaceTabs(ctx, { + clearHistoryWhenNoActiveTab: true, + }) await ctx.deps.historyRepository.touchWorkspace(path) const recentWorkspacePaths = await listRecentWorkspacePaths() await loadWorkspace(ctx, path, { recentWorkspacePaths }) @@ -104,7 +106,7 @@ export const createLifecycleActions = ( } await ctx.deps.fileSystemRepository.moveToTrash(workspacePath) - closeWorkspaceTabs(ctx) + await closeWorkspaceTabs(ctx) await ctx.deps.historyRepository.removeWorkspace(workspacePath) const recentWorkspacePaths = await listRecentWorkspacePaths() resetWorkspaceState(ctx, { diff --git a/packages/store/src/workspace/lifecycle/domain.ts b/packages/store/src/workspace/lifecycle/domain.ts index e458fed7..8f7559ad 100644 --- a/packages/store/src/workspace/lifecycle/domain.ts +++ b/packages/store/src/workspace/lifecycle/domain.ts @@ -2,10 +2,7 @@ import { isPathEqualOrDescendant } from "@mdit/utils/path-utils" import { resolve } from "pathe" import type { WorkspaceActionContext } from "../workspace-action-context" import { buildWorkspaceState, type WorkspaceState } from "../workspace-state" -import { - getActiveTabPathForWorkspacePolicy, - getOpenTabSnapshotsForWorkspacePolicy, -} from "../workspace-tab-policy" +import { getOpenTabSnapshotsForWorkspacePolicy } from "../workspace-tab-policy" const MAX_RESTORED_LAST_OPENED_FILE_PATHS = 5 @@ -53,7 +50,6 @@ export const resetWorkspaceState = ( unwatchFn: resolveUnwatchFnForWorkspaceTransition(ctx, workspacePath), }), ) - ctx.ports.collection.resetCollectionPath() void ctx.runtime.events .emit({ type: "workspace/reset", @@ -64,20 +60,15 @@ export const resetWorkspaceState = ( }) } -export const closeWorkspaceTabs = ( +export const closeWorkspaceTabs = async ( ctx: WorkspaceActionContext, options?: { clearHistoryWhenNoActiveTab?: boolean }, ) => { - const openTabSnapshots = getOpenTabSnapshotsForWorkspacePolicy(ctx) - const activeTabPath = getActiveTabPathForWorkspacePolicy(ctx) - - if (activeTabPath || openTabSnapshots.length > 0) { - ctx.ports.tab.closeAllTabs() - } - - if (openTabSnapshots.length > 0 || options?.clearHistoryWhenNoActiveTab) { - ctx.ports.tab.clearHistory() - } + await ctx.runtime.events.emit({ + type: "workspace/tab-reset-requested", + workspacePath: ctx.get().workspacePath, + clearHistoryWhenNoActiveTab: Boolean(options?.clearHistoryWhenNoActiveTab), + }) } export const hasUnsavedWorkspaceTabs = (ctx: WorkspaceActionContext): boolean => @@ -124,10 +115,11 @@ const restoreLastOpenedFileHistoryFromSettings = async ( return } - const hydrated = await ctx.ports.tab.hydrateFromOpenedFiles(restorablePaths) - if (!hydrated) { - console.debug("Failed to hydrate opened file history") - } + await ctx.runtime.events.emit({ + type: "workspace/opened-files-restore-requested", + workspacePath, + paths: restorablePaths, + }) } catch (error) { console.debug("Failed to restore opened file history:", error) } diff --git a/packages/store/src/workspace/shared/action-test-helpers.ts b/packages/store/src/workspace/shared/action-test-helpers.ts index a450f88c..e2534fd6 100644 --- a/packages/store/src/workspace/shared/action-test-helpers.ts +++ b/packages/store/src/workspace/shared/action-test-helpers.ts @@ -105,16 +105,6 @@ export function createActionTestContext() { const ports = { tab: { - hydrateFromOpenedFiles: vi.fn().mockResolvedValue(true), - openTab: vi.fn().mockResolvedValue(undefined), - closeTab: vi.fn(), - closeAllTabs: vi.fn(), - renameTab: vi.fn().mockResolvedValue(undefined), - clearActiveTabSyncedName: vi.fn(), - refreshTabFromExternalContent: vi.fn(), - updateHistoryPath: vi.fn(), - removePathsFromHistory: vi.fn(), - clearHistory: vi.fn(), getOpenTabSnapshots: vi.fn(() => getOpenTabSnapshotsFromState()), getActiveTabPath: vi.fn(() => { const activeTabId = @@ -128,7 +118,6 @@ export function createActionTestContext() { }), }, collection: { - resetCollectionPath: vi.fn(), getCurrentCollectionPath: vi.fn(() => state.currentCollectionPath), }, } diff --git a/packages/store/src/workspace/tree/entry-actions.test.ts b/packages/store/src/workspace/tree/entry-actions.test.ts index 3a70241b..a6811994 100644 --- a/packages/store/src/workspace/tree/entry-actions.test.ts +++ b/packages/store/src/workspace/tree/entry-actions.test.ts @@ -37,7 +37,7 @@ describe("tree/entry-actions", () => { }) it("entriesDeleted removes history paths in one batch", async () => { - const { context, events, ports, setState } = createActionTestContext() + const { context, events, setState } = createActionTestContext() setState({ workspacePath: "/ws", entries: [ @@ -52,8 +52,6 @@ describe("tree/entry-actions", () => { await actions.entriesDeleted({ paths: ["/ws/a.md", "/ws/folder"] }) - expect(ports.tab.closeTab).not.toHaveBeenCalled() - expect(ports.tab.removePathsFromHistory).not.toHaveBeenCalled() expect(events.emit).toHaveBeenNthCalledWith(1, { type: "workspace/tab-paths-removed", workspacePath: "/ws", @@ -67,7 +65,7 @@ describe("tree/entry-actions", () => { }) it("entriesDeleted emits tab-path removal events for descendant cleanup", async () => { - const { context, events, ports, setState } = createActionTestContext() + const { context, events, setState } = createActionTestContext() setState({ workspacePath: "/ws", entries: [{ path: "/ws/folder", name: "folder", isDirectory: true }], @@ -77,8 +75,6 @@ describe("tree/entry-actions", () => { await actions.entriesDeleted({ paths: ["/ws/folder"] }) - expect(ports.tab.closeTab).not.toHaveBeenCalled() - expect(ports.tab.removePathsFromHistory).not.toHaveBeenCalled() expect(events.emit).toHaveBeenNthCalledWith(1, { type: "workspace/tab-paths-removed", workspacePath: "/ws", @@ -117,7 +113,7 @@ describe("tree/entry-actions", () => { }) it("entryRenamed emits tab/history sync events and workspace rename events", async () => { - const { context, events, ports, setState } = createActionTestContext() + const { context, events, setState } = createActionTestContext() setState({ workspacePath: "/ws", entries: [{ path: "/ws/folder", name: "folder", isDirectory: true }], @@ -134,8 +130,6 @@ describe("tree/entry-actions", () => { newName: "renamed", }) - expect(ports.tab.renameTab).not.toHaveBeenCalled() - expect(ports.tab.updateHistoryPath).not.toHaveBeenCalled() expect(events.emit).toHaveBeenNthCalledWith(1, { type: "workspace/tab-path-renamed", workspacePath: "/ws", @@ -154,7 +148,7 @@ describe("tree/entry-actions", () => { }) it("entryMoved emits tab/history sync events and workspace move events", async () => { - const { context, events, ports, setState } = createActionTestContext() + const { context, events, setState } = createActionTestContext() setState({ workspacePath: "/ws", entries: [{ path: "/ws/folder", name: "folder", isDirectory: true }], @@ -169,7 +163,6 @@ describe("tree/entry-actions", () => { isDirectory: true, }) - expect(ports.tab.renameTab).not.toHaveBeenCalled() expect(events.emit).toHaveBeenNthCalledWith(1, { type: "workspace/tab-path-moved", workspacePath: "/ws", diff --git a/packages/store/src/workspace/watch/actions.test.ts b/packages/store/src/workspace/watch/actions.test.ts index cb60409d..95fa8cc3 100644 --- a/packages/store/src/workspace/watch/actions.test.ts +++ b/packages/store/src/workspace/watch/actions.test.ts @@ -89,7 +89,7 @@ describe("watch/actions", () => { }) it("ignores non-rescan local-only batches", async () => { - const { context, setState, getState, originJournal, deps, ports } = + const { context, setState, getState, originJournal, deps, events } = createActionTestContext() const actions = createWatchActions(context) setState({ workspacePath: "/ws" }) @@ -120,7 +120,13 @@ describe("watch/actions", () => { workspacePath: "/ws", relPaths: ["docs/local.md"], }) - expect(ports.tab.refreshTabFromExternalContent).not.toHaveBeenCalled() + expect(events.emit).not.toHaveBeenCalledWith({ + type: "workspace/tab-content-refresh-requested", + workspacePath: "/ws", + path: "/ws/docs/local.md", + content: "", + preserveSelection: true, + }) expect(deps.fileSystemRepository.readDir).not.toHaveBeenCalled() expect(getState().refreshWorkspaceEntries).not.toHaveBeenCalled() }) @@ -485,7 +491,7 @@ describe("watch/actions", () => { }) it("reloads open tabs when policy has multiple open snapshots", async () => { - const { context, setState, originJournal, deps, ports } = + const { context, setState, originJournal, deps, events, ports } = createActionTestContext() const actions = createWatchActions(context) setState({ @@ -531,15 +537,17 @@ describe("watch/actions", () => { }) await flushQueue() - expect(ports.tab.refreshTabFromExternalContent).toHaveBeenCalledWith( - "/ws/docs/second.md", - "", - { preserveSelection: true }, - ) + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/tab-content-refresh-requested", + workspacePath: "/ws", + path: "/ws/docs/second.md", + content: "", + preserveSelection: true, + }) }) it("reloads the active markdown tab and updates metadata for external file changes", async () => { - const { context, setState, originJournal, getState, deps, ports } = + const { context, setState, originJournal, getState, deps, events } = createActionTestContext() const actions = createWatchActions(context) setState({ @@ -585,11 +593,13 @@ describe("watch/actions", () => { }) await flushQueue() - expect(ports.tab.refreshTabFromExternalContent).toHaveBeenCalledWith( - "/ws/docs/a.md", - "fresh-content", - { preserveSelection: true }, - ) + expect(events.emit).toHaveBeenCalledWith({ + type: "workspace/tab-content-refresh-requested", + workspacePath: "/ws", + path: "/ws/docs/a.md", + content: "fresh-content", + preserveSelection: true, + }) expect(getState().updateEntryModifiedDate).toHaveBeenCalledWith( "/ws/docs/a.md", ) diff --git a/packages/store/src/workspace/watch/batch-apply.ts b/packages/store/src/workspace/watch/batch-apply.ts index f168d428..91bb9944 100644 --- a/packages/store/src/workspace/watch/batch-apply.ts +++ b/packages/store/src/workspace/watch/batch-apply.ts @@ -403,7 +403,11 @@ export const applyWatchBatchChanges = async ( try { const content = await ctx.deps.fileSystemRepository.readTextFile(absolutePath) - ctx.ports.tab.refreshTabFromExternalContent(absolutePath, content, { + await ctx.runtime.events.emit({ + type: "workspace/tab-content-refresh-requested", + workspacePath: input.workspacePath, + path: absolutePath, + content, preserveSelection: true, }) } catch (error) { diff --git a/packages/store/src/workspace/workspace-ports.ts b/packages/store/src/workspace/workspace-ports.ts index 7d51635f..a1971a80 100644 --- a/packages/store/src/workspace/workspace-ports.ts +++ b/packages/store/src/workspace/workspace-ports.ts @@ -2,25 +2,8 @@ import type { CollectionSlice } from "../collection/collection-slice" import type { TabSlice } from "../tab/tab-slice" export type WorkspacePorts = { - tab: Pick< - TabSlice, - | "hydrateFromOpenedFiles" - | "openTab" - | "closeTab" - | "closeAllTabs" - | "renameTab" - | "clearActiveTabSyncedName" - | "refreshTabFromExternalContent" - | "updateHistoryPath" - | "removePathsFromHistory" - | "clearHistory" - | "getOpenTabSnapshots" - | "getActiveTabPath" - > - collection: Pick< - CollectionSlice, - "resetCollectionPath" | "getCurrentCollectionPath" - > + tab: Pick + collection: Pick } type WorkspacePortSource = TabSlice & CollectionSlice @@ -29,22 +12,10 @@ export const createWorkspacePorts = ( get: () => WorkspacePortSource, ): WorkspacePorts => ({ tab: { - hydrateFromOpenedFiles: (...args) => get().hydrateFromOpenedFiles(...args), - openTab: (...args) => get().openTab(...args), - closeTab: (...args) => get().closeTab(...args), - closeAllTabs: () => get().closeAllTabs(), - renameTab: (...args) => get().renameTab(...args), - clearActiveTabSyncedName: () => get().clearActiveTabSyncedName(), - refreshTabFromExternalContent: (...args) => - get().refreshTabFromExternalContent(...args), - updateHistoryPath: (...args) => get().updateHistoryPath(...args), - removePathsFromHistory: (...args) => get().removePathsFromHistory(...args), - clearHistory: (...args) => get().clearHistory(...args), getOpenTabSnapshots: () => get().getOpenTabSnapshots(), getActiveTabPath: () => get().getActiveTabPath(), }, collection: { - resetCollectionPath: (...args) => get().resetCollectionPath(...args), getCurrentCollectionPath: () => get().getCurrentCollectionPath(), }, }) diff --git a/todos/store-followup-prs.md b/todos/store-followup-prs.md deleted file mode 100644 index 5fc2d07e..00000000 --- a/todos/store-followup-prs.md +++ /dev/null @@ -1,176 +0,0 @@ -# Store Follow-up TODOs - -## Context - -Current coupling status: - -- `workspace -> indexing` direct writes removed -- `git-sync -> workspace` direct writes removed -- `workspace -> collection` direct writes removed -- `workspace -> tab` rename/history write orchestration removed -- `workspace -> tab` open/close lifecycle write orchestration still exists - -Recently completed: - -- added typed workspace tree events for collection reactions -- added `packages/store/src/integrations/register-collection-integration.ts` -- removed collection write-style calls from `workspace` -- added typed tab path events for rename/move/delete side effects -- added `packages/store/src/integrations/register-tab-path-integration.ts` -- removed tab rename/history write-style calls from `workspace` - -Remaining goal: - -- finish moving workspace-owned tab write orchestration into integrations or explicit caller workflows -- reduce `WorkspacePorts` to query/read APIs only - -## PR 3: Move Tab Rename/History Sync Out of Workspace - -Status: done - -Goal: - -- remove workspace-owned tab mutation commands for rename, move, and delete side effects - -Definition of done: - -- `workspace` no longer calls: - - `removePathsFromHistory` - - `renameTab` - - `updateHistoryPath` -- tab rename/history synchronization happens in an integration file -- behavior is covered by integration tests - -Implemented: - -- added typed events to `packages/store/src/integrations/store-events.ts` - - `workspace/tab-paths-removed` - - `workspace/tab-path-renamed` - - `workspace/tab-path-moved` -- emit those events from: - - `packages/store/src/workspace/tree/entry-actions.ts` - - `packages/store/src/workspace/fs/structure-actions.ts` edit-mode rename path -- added `packages/store/src/integrations/register-tab-path-integration.ts` -- moved tab reactions into that integration: - - `removePathsFromHistory` - - `renameTab` - - `updateHistoryPath` -- kept read/query tab policy calls in workspace: - - `getOpenTabSnapshots` - - `getActiveTabPath` - -Verified by tests: - -- integration test: delete removes affected paths from tab history -- integration test: rename updates open tab path and history path -- integration test: move updates open tab path and history path -- integration test: event for different workspace is ignored -- workspace action tests assert event emission instead of direct tab write calls - -## PR 4: Move Tab Open/Close Lifecycle Out of Workspace - -Status: pending - -Goal: - -- remove remaining workspace-owned tab open/close orchestration - -Definition of done: - -- `workspace` no longer calls: - - `openTab` - - `closeAllTabs` - - `clearHistory` -- lifecycle-driven tab behavior happens in an integration file or explicit caller workflow -- behavior is covered by integration tests - -Current write call sites: - -- `packages/store/src/workspace/lifecycle/domain.ts` - - `closeWorkspaceTabs()` -> `ctx.ports.tab.closeAllTabs()` - - `closeWorkspaceTabs()` -> `ctx.ports.tab.clearHistory()` -- `packages/store/src/workspace/fs/structure-actions.ts` - - `createNote({ openTab: true })` -> `ctx.ports.tab.openTab(filePath)` - - `createAndOpenNote()` still owns tab-open workflow - -Candidate events: - -- `workspace/switched` -- `workspace/cleared` -- `workspace/note-created` - -Work items: - -- define exact payloads for the lifecycle events before wiring -- add lifecycle events to `packages/store/src/integrations/store-events.ts` -- add a dedicated integration file for tab lifecycle reactions -- move close/open/history-clear behavior out of: - - `packages/store/src/workspace/lifecycle/domain.ts` - - `packages/store/src/workspace/fs/structure-actions.ts` -- preserve current UI behavior while changing ownership -- keep long-term direction explicit: - - `createNote()` should create and return a path - - opening the tab should happen in integration or caller-level workflow - -Tests: - -- integration test: workspace switch/reset closes tabs as before -- integration test: history clearing behavior remains unchanged -- integration test: note creation still opens the new note when requested - -Watch item: - -- newly created note selection currently happens next to `openTab()` -- if tab open moves out, confirm whether selection stays in workspace or moves with the caller workflow - -## PR 5: Re-evaluate WorkspacePorts - -Status: pending - -Goal: - -- keep only read/query ports that are still justified - -Definition of done: - -- `packages/store/src/workspace/workspace-ports.ts` contains only read/query-style APIs -- write orchestration lives in integrations or explicit caller workflows - -Likely keep for now: - -- `tab.getOpenTabSnapshots` -- `tab.getActiveTabPath` -- `collection.getCurrentCollectionPath` - -Likely remove after PR 3 and PR 4: - -- `tab.openTab` -- `tab.closeTab` -- `tab.closeAllTabs` -- `tab.renameTab` -- `tab.clearActiveTabSyncedName` -- `tab.refreshTabFromExternalContent` -- `tab.updateHistoryPath` -- `tab.removePathsFromHistory` -- `tab.clearHistory` -- any remaining collection write-style port - -Work items: - -- audit every remaining `ctx.ports.tab.*` call site -- audit every remaining `ctx.ports.collection.*` call site -- classify each remaining port as: - - query/read - - integration-owned write - - caller-workflow-owned write -- remove dead write ports from `packages/store/src/workspace/workspace-ports.ts` -- remove dead test helper mocks from `packages/store/src/workspace/shared/action-test-helpers.ts` -- update tests to stop depending on removed write ports - -## Guardrails - -- do not introduce a generic event bus with stringly-typed payloads beyond the local typed hub -- keep event names narrow and domain-specific -- prefer one small PR per coupling boundary -- every moved coupling should gain an integration test -- avoid changing UI behavior while moving orchestration