Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/store/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -96,6 +97,7 @@ export const createMditStore = (
registerCollectionIntegration(store, events)
registerIndexingIntegration(store, events)
registerGitSyncWorkspaceIntegration(store, events)
registerTabLifecycleIntegration(store, events)
registerTabPathIntegration(store, events)

return store
Expand Down
138 changes: 138 additions & 0 deletions packages/store/src/integrations/integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Comment on lines +29 to +32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The workspace/tab-reset-requested event handler is missing a workspace path guard. For consistency and to prevent accidental tab closure if a stale event is received or if multiple workspaces were to be supported in the future, it should verify that the event's workspacePath matches the current store's workspacePath (unless the event path is null, indicating a global reset).

case "workspace/tab-reset-requested": {
				if (
					event.workspacePath !== null &&
					state.workspacePath !== event.workspacePath
				) {
					return
				}

				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
}
})
22 changes: 22 additions & 0 deletions packages/store/src/integrations/store-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
28 changes: 21 additions & 7 deletions packages/store/src/workspace/fs/structure-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand All @@ -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)
})

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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",
Expand All @@ -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)
Expand All @@ -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()
})
Expand Down
6 changes: 5 additions & 1 deletion packages/store/src/workspace/fs/structure-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}

Expand Down
Loading
Loading