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
146 changes: 146 additions & 0 deletions packages/store/src/git-sync/git-sync-slice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, expect, it, vi } from "vitest"
import { createStore } from "zustand/vanilla"
import { type GitSyncSlice, prepareGitSyncSlice } from "./git-sync-slice"

describe("git-sync-slice", () => {
it("emits a pulled-changes event instead of refreshing the workspace directly", async () => {
const sync = vi.fn().mockResolvedValue({ pulledChanges: true })
const events = {
emit: vi.fn().mockResolvedValue(undefined),
subscribe: vi.fn(),
}
const store = createStore<GitSyncSlice>()((set, get, api) =>
prepareGitSyncSlice(
{
loadSettings: vi.fn().mockResolvedValue({}),
saveSettings: vi.fn().mockResolvedValue(undefined),
createGitSyncCore: () => ({
isGitRepository: vi.fn().mockResolvedValue(true),
getCurrentBranch: vi.fn().mockResolvedValue("main"),
hasChangesToCommit: vi.fn().mockResolvedValue(false),
getCurrentCommitHash: vi.fn().mockResolvedValue(null),
ensureGitignoreEntry: vi.fn().mockResolvedValue(undefined),
detectSyncStatus: vi.fn().mockResolvedValue("synced"),
sync,
}),
},
{ events: events as any },
)(set, get, api),
)

store.setState((state) => ({
...state,
gitSyncState: {
...state.gitSyncState,
workspacePath: "/ws",
isGitRepo: true,
status: "unsynced",
},
}))

await store.getState().performSync()

expect(sync).toHaveBeenCalledTimes(1)
expect(events.emit).toHaveBeenCalledWith({
type: "git-sync/pulled-changes",
workspacePath: "/ws",
})
})

it("waits for the pulled-changes event handlers before resolving", async () => {
const sync = vi.fn().mockResolvedValue({ pulledChanges: true })
let resolveEmit!: () => void
const emitPromise = new Promise<void>((resolve) => {
resolveEmit = resolve
})
const events = {
emit: vi.fn().mockReturnValue(emitPromise),
subscribe: vi.fn(),
}
const store = createStore<GitSyncSlice>()((set, get, api) =>
prepareGitSyncSlice(
{
loadSettings: vi.fn().mockResolvedValue({}),
saveSettings: vi.fn().mockResolvedValue(undefined),
createGitSyncCore: () => ({
isGitRepository: vi.fn().mockResolvedValue(true),
getCurrentBranch: vi.fn().mockResolvedValue("main"),
hasChangesToCommit: vi.fn().mockResolvedValue(false),
getCurrentCommitHash: vi.fn().mockResolvedValue(null),
ensureGitignoreEntry: vi.fn().mockResolvedValue(undefined),
detectSyncStatus: vi.fn().mockResolvedValue("synced"),
sync,
}),
},
{ events: events as any },
)(set, get, api),
)

store.setState((state) => ({
...state,
gitSyncState: {
...state.gitSyncState,
workspacePath: "/ws",
isGitRepo: true,
status: "unsynced",
},
}))

let resolved = false
const syncPromise = store
.getState()
.performSync()
.then(() => {
resolved = true
})

await Promise.resolve()
expect(resolved).toBe(false)

resolveEmit()
await syncPromise

expect(resolved).toBe(true)
})

it("sets an error state when the pulled-changes refresh fails", async () => {
const sync = vi.fn().mockResolvedValue({ pulledChanges: true })
const events = {
emit: vi.fn().mockRejectedValue(new Error("refresh failed")),
subscribe: vi.fn(),
}
const store = createStore<GitSyncSlice>()((set, get, api) =>
prepareGitSyncSlice(
{
loadSettings: vi.fn().mockResolvedValue({}),
saveSettings: vi.fn().mockResolvedValue(undefined),
createGitSyncCore: () => ({
isGitRepository: vi.fn().mockResolvedValue(true),
getCurrentBranch: vi.fn().mockResolvedValue("main"),
hasChangesToCommit: vi.fn().mockResolvedValue(false),
getCurrentCommitHash: vi.fn().mockResolvedValue(null),
ensureGitignoreEntry: vi.fn().mockResolvedValue(undefined),
detectSyncStatus: vi.fn().mockResolvedValue("synced"),
sync,
}),
},
{ events: events as any },
)(set, get, api),
)

store.setState((state) => ({
...state,
gitSyncState: {
...state.gitSyncState,
workspacePath: "/ws",
isGitRepo: true,
status: "unsynced",
},
}))

await store.getState().performSync()

expect(store.getState().gitSyncState.status).toBe("error")
expect(store.getState().gitSyncState.error).toBe("refresh failed")
})
})
22 changes: 9 additions & 13 deletions packages/store/src/git-sync/git-sync-slice.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { GitSyncCore, GitSyncStatus, SyncConfig } from "@mdit/git-sync"
import type { StateCreator } from "zustand"
import type { StoreEventHub } from "../integrations/store-events"
import type { WorkspaceSettings } from "../workspace/workspace-settings"
import type { WorkspaceSlice } from "../workspace/workspace-slice"

export type { SyncConfig } from "@mdit/git-sync"

Expand Down Expand Up @@ -56,16 +56,10 @@ const buildInitialGitSyncState = (): GitSyncState => ({
})

export const prepareGitSyncSlice =
({
loadSettings,
saveSettings,
createGitSyncCore,
}: GitSyncSliceDependencies): StateCreator<
GitSyncSlice & WorkspaceSlice,
[],
[],
GitSyncSlice
> =>
(
{ loadSettings, saveSettings, createGitSyncCore }: GitSyncSliceDependencies,
{ events }: { events: StoreEventHub },
): StateCreator<GitSyncSlice, [], [], GitSyncSlice> =>
(set, get) => {
const gitSyncCore = createGitSyncCore()

Expand Down Expand Up @@ -204,9 +198,11 @@ export const prepareGitSyncSlice =
},
}))

// Refresh workspace entries if pull merged changes
if (result.pulledChanges) {
await get().refreshWorkspaceEntries()
await events.emit({
type: "git-sync/pulled-changes",
workspacePath,
})
}
} catch (error) {
const message =
Expand Down
19 changes: 16 additions & 3 deletions packages/store/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ import {
type IndexingSliceDependencies,
prepareIndexingSlice,
} from "./indexing/indexing-slice"
import { registerGitSyncWorkspaceIntegration } from "./integrations/register-git-sync-workspace-integration"
import { registerIndexingIntegration } from "./integrations/register-indexing-integration"
import { createStoreEventHub } from "./integrations/store-events"
import type { TabSlice } from "./tab/tab-slice"
import { prepareTabSlice, type TabSliceDependencies } from "./tab/tab-slice"
import type { UISlice } from "./ui/ui-slice"
Expand Down Expand Up @@ -59,18 +62,23 @@ export type MditStore = UseBoundStore<StoreApi<StoreState>>
export const createMditStore = (
dependencies: MditStoreDependencies,
): MditStore => {
const events = createStoreEventHub()
const createCollectionSlice = prepareCollectionSlice()
const createEditorSlice = prepareEditorSlice()
const createImageEditSlice = prepareImageEditSlice()
const createAISettingsSlice = prepareAISettingsSlice(dependencies.aiSettings)
const createGitSyncSlice = prepareGitSyncSlice(dependencies.gitSync)
const createGitSyncSlice = prepareGitSyncSlice(dependencies.gitSync, {
events,
})
const createHotkeysSlice = prepareHotkeysSlice(dependencies.hotkeys)
const createIndexingSlice = prepareIndexingSlice(dependencies.indexing)
const createTabSlice = prepareTabSlice(dependencies.tab)
const createUISlice = prepareUISlice(dependencies.ui)
const createWorkspaceSlice = prepareWorkspaceSlice(dependencies.workspace)
const createWorkspaceSlice = prepareWorkspaceSlice(dependencies.workspace, {
events,
})

return create<StoreState>()((...args) => ({
const store = create<StoreState>()((...args) => ({
...createCollectionSlice(...args),
...createTabSlice(...args),
...createWorkspaceSlice(...args),
Expand All @@ -82,4 +90,9 @@ export const createMditStore = (
...createHotkeysSlice(...args),
...createUISlice(...args),
}))

registerIndexingIntegration(store, events)
registerGitSyncWorkspaceIntegration(store, events)

return store
}
128 changes: 128 additions & 0 deletions packages/store/src/integrations/integrations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from "vitest"
import type { MditStore } from ".."
import { registerGitSyncWorkspaceIntegration } from "./register-git-sync-workspace-integration"
import { registerIndexingIntegration } from "./register-indexing-integration"
import { createStoreEventHub } from "./store-events"

describe("store integrations", () => {
it("resets indexing state when workspace resets", async () => {
const events = createStoreEventHub()
const state = {
resetIndexingState: vi.fn(),
getIndexingConfig: vi.fn(),
}
const store = {
getState: () => state,
} as unknown as MditStore

registerIndexingIntegration(store, events)
await events.emit({
type: "workspace/reset",
workspacePath: "/ws",
})

expect(state.resetIndexingState).toHaveBeenCalledTimes(1)
})

it("preloads indexing config when workspace loads", async () => {
const events = createStoreEventHub()
const getIndexingConfig = vi.fn().mockResolvedValue(null)
const store = {
getState: () => ({
resetIndexingState: vi.fn(),
getIndexingConfig,
}),
} as unknown as MditStore

registerIndexingIntegration(store, events)
await events.emit({
type: "workspace/loaded",
workspacePath: "/ws",
})

expect(getIndexingConfig).toHaveBeenCalledWith("/ws")
})

it("logs indexing preload failures without throwing", async () => {
const events = createStoreEventHub()
const preloadError = new Error("preload failed")
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
const store = {
getState: () => ({
resetIndexingState: vi.fn(),
getIndexingConfig: vi.fn().mockRejectedValue(preloadError),
}),
} as unknown as MditStore

registerIndexingIntegration(store, events)
await events.emit({
type: "workspace/loaded",
workspacePath: "/ws",
})

expect(errorSpy).toHaveBeenCalledWith(
"Failed to preload indexing config:",
preloadError,
)

errorSpy.mockRestore()
})

it("refreshes the active workspace when sync pulled changes", async () => {
const events = createStoreEventHub()
const refreshWorkspaceEntries = vi.fn().mockResolvedValue(undefined)
const store = {
getState: () => ({
workspacePath: "/ws",
refreshWorkspaceEntries,
}),
} as unknown as MditStore

registerGitSyncWorkspaceIntegration(store, events)
await events.emit({
type: "git-sync/pulled-changes",
workspacePath: "/ws",
})

expect(refreshWorkspaceEntries).toHaveBeenCalledTimes(1)
})

it("propagates workspace refresh failures for sync pulled changes", async () => {
const events = createStoreEventHub()
const refreshError = new Error("refresh failed")
const store = {
getState: () => ({
workspacePath: "/ws",
refreshWorkspaceEntries: vi.fn().mockRejectedValue(refreshError),
}),
} as unknown as MditStore

registerGitSyncWorkspaceIntegration(store, events)

await expect(
events.emit({
type: "git-sync/pulled-changes",
workspacePath: "/ws",
}),
).rejects.toThrow("refresh failed")
})

it("ignores sync refresh events for a different workspace", async () => {
const events = createStoreEventHub()
const refreshWorkspaceEntries = vi.fn().mockResolvedValue(undefined)
const store = {
getState: () => ({
workspacePath: "/other",
refreshWorkspaceEntries,
}),
} as unknown as MditStore

registerGitSyncWorkspaceIntegration(store, events)
await events.emit({
type: "git-sync/pulled-changes",
workspacePath: "/ws",
})

expect(refreshWorkspaceEntries).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { MditStore } from ".."
import type { StoreEventHub } from "./store-events"

export const registerGitSyncWorkspaceIntegration = (
store: MditStore,
events: StoreEventHub,
) =>
events.subscribe(async (event) => {
if (event.type !== "git-sync/pulled-changes") {
return
}

const state = store.getState()
if (state.workspacePath !== event.workspacePath) {
return
}

await state.refreshWorkspaceEntries()
})
Loading
Loading