mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-06 10:41:21 +02:00
chore: cleanup, a11y and state migrations
This commit is contained in:
127
src/core/logic.test.ts
Normal file
127
src/core/logic.test.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { findLowestAvailableWorkspaceNumber } from "./logic";
|
||||
|
||||
describe("findLowestAvailableWorkspaceNumber", () => {
|
||||
describe("basic number assignment", () => {
|
||||
it("should return 1 when no workspaces exist", () => {
|
||||
const result = findLowestAvailableWorkspaceNumber({});
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 2 when only workspace 1 exists", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it("should return 4 when workspaces 1, 2, 3 exist", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
id2: { number: 2 },
|
||||
id3: { number: 3 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gap detection", () => {
|
||||
it("should return 2 when workspaces 1, 3, 4 exist", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
id3: { number: 3 },
|
||||
id4: { number: 4 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it("should return 1 when workspaces 2, 3, 4 exist", () => {
|
||||
const workspaces = {
|
||||
id2: { number: 2 },
|
||||
id3: { number: 3 },
|
||||
id4: { number: 4 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
|
||||
it("should return 3 when workspaces 1, 2, 4, 5 exist", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
id2: { number: 2 },
|
||||
id4: { number: 4 },
|
||||
id5: { number: 5 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it("should return 2 when workspaces 1, 3 exist", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
id3: { number: 3 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it("should return first gap when multiple gaps exist", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
id5: { number: 5 },
|
||||
id10: { number: 10 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("large numbers", () => {
|
||||
it("should return 3 when workspaces 1, 2, 100 exist", () => {
|
||||
const workspaces = {
|
||||
id1: { number: 1 },
|
||||
id2: { number: 2 },
|
||||
id100: { number: 100 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle large sequential numbers correctly", () => {
|
||||
const workspaces = {
|
||||
id100: { number: 100 },
|
||||
id101: { number: 101 },
|
||||
id102: { number: 102 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unordered workspaces", () => {
|
||||
it("should handle workspaces in random order", () => {
|
||||
const workspaces = {
|
||||
id5: { number: 5 },
|
||||
id1: { number: 1 },
|
||||
id3: { number: 3 },
|
||||
id7: { number: 7 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(2);
|
||||
});
|
||||
|
||||
it("should find lowest gap regardless of insertion order", () => {
|
||||
const workspaces = {
|
||||
id10: { number: 10 },
|
||||
id2: { number: 2 },
|
||||
id8: { number: 8 },
|
||||
id1: { number: 1 },
|
||||
};
|
||||
const result = findLowestAvailableWorkspaceNumber(workspaces);
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,37 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import type { MosaicNode } from "react-mosaic-component";
|
||||
import { GrimoireState, WindowInstance, UserRelays } from "@/types/app";
|
||||
|
||||
/**
|
||||
* Finds the lowest available workspace number.
|
||||
* - If workspaces have numbers [1, 2, 4], returns 3
|
||||
* - If workspaces have numbers [1, 2, 3], returns 4
|
||||
* - If workspaces have numbers [2, 3, 4], returns 1
|
||||
*/
|
||||
export const findLowestAvailableWorkspaceNumber = (
|
||||
workspaces: Record<string, { number: number }>,
|
||||
): number => {
|
||||
// Get all workspace numbers as a Set for O(1) lookup
|
||||
const numbers = new Set(Object.values(workspaces).map((ws) => ws.number));
|
||||
|
||||
// If no workspaces exist, start at 1
|
||||
if (numbers.size === 0) return 1;
|
||||
|
||||
// Find first gap starting from 1
|
||||
let candidate = 1;
|
||||
while (numbers.has(candidate)) {
|
||||
candidate++;
|
||||
}
|
||||
|
||||
return candidate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new, empty workspace.
|
||||
*/
|
||||
export const createWorkspace = (
|
||||
state: GrimoireState,
|
||||
label: string,
|
||||
number: number,
|
||||
label?: string,
|
||||
): GrimoireState => {
|
||||
const newId = uuidv4();
|
||||
return {
|
||||
@@ -17,6 +42,7 @@ export const createWorkspace = (
|
||||
...state.workspaces,
|
||||
[newId]: {
|
||||
id: newId,
|
||||
number,
|
||||
label,
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
@@ -302,7 +328,9 @@ export const deleteWorkspace = (
|
||||
export const updateWindow = (
|
||||
state: GrimoireState,
|
||||
windowId: string,
|
||||
updates: Partial<Pick<WindowInstance, "props" | "title" | "commandString" | "appId">>,
|
||||
updates: Partial<
|
||||
Pick<WindowInstance, "props" | "title" | "commandString" | "appId">
|
||||
>,
|
||||
): GrimoireState => {
|
||||
const window = state.windows[windowId];
|
||||
if (!window) {
|
||||
|
||||
@@ -1,32 +1,79 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
import { GrimoireState, AppId, WindowInstance } from "@/types/app";
|
||||
import { useLocale } from "@/hooks/useLocale";
|
||||
import * as Logic from "./logic";
|
||||
import {
|
||||
CURRENT_VERSION,
|
||||
validateState,
|
||||
migrateState,
|
||||
} from "@/lib/migrations";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Initial State Definition - Empty canvas on first load
|
||||
const initialState: GrimoireState = {
|
||||
__version: CURRENT_VERSION,
|
||||
windows: {},
|
||||
activeWorkspaceId: "default",
|
||||
workspaces: {
|
||||
default: {
|
||||
id: "default",
|
||||
label: "1",
|
||||
number: 1,
|
||||
windowIds: [],
|
||||
layout: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Custom storage with error handling
|
||||
// Custom storage with error handling and migrations
|
||||
const storage = createJSONStorage<GrimoireState>(() => ({
|
||||
getItem: (key: string) => {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
if (!value) return null;
|
||||
|
||||
// Parse and validate/migrate state
|
||||
const parsed = JSON.parse(value);
|
||||
const storedVersion = parsed.__version || 5;
|
||||
|
||||
// Check if migration is needed
|
||||
if (storedVersion < CURRENT_VERSION) {
|
||||
console.log(
|
||||
`[Storage] State version outdated (v${storedVersion}), migrating...`,
|
||||
);
|
||||
const migrated = migrateState(parsed);
|
||||
|
||||
// Save migrated state back to localStorage
|
||||
localStorage.setItem(key, JSON.stringify(migrated));
|
||||
|
||||
toast.success("State Updated", {
|
||||
description: `Migrated from v${storedVersion} to v${CURRENT_VERSION}`,
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
return JSON.stringify(migrated);
|
||||
}
|
||||
|
||||
// Validate current version state
|
||||
if (!validateState(parsed)) {
|
||||
console.warn(
|
||||
"[Storage] State validation failed, resetting to initial state",
|
||||
);
|
||||
toast.error("State Corrupted", {
|
||||
description: "Your state was corrupted and has been reset.",
|
||||
duration: 5000,
|
||||
});
|
||||
return null; // Return null to use initialState
|
||||
}
|
||||
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.warn("Failed to read from localStorage:", error);
|
||||
console.error("[Storage] Failed to read from localStorage:", error);
|
||||
toast.error("Failed to Load State", {
|
||||
description: "Using default state.",
|
||||
duration: 5000,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
@@ -43,6 +90,10 @@ const storage = createJSONStorage<GrimoireState>(() => ({
|
||||
console.error(
|
||||
"localStorage quota exceeded. State will not be persisted.",
|
||||
);
|
||||
toast.error("Storage Full", {
|
||||
description: "Could not save state. Storage quota exceeded.",
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -74,15 +125,18 @@ export const useGrimoire = () => {
|
||||
}
|
||||
}, [state.locale, browserLocale, setState]);
|
||||
|
||||
return {
|
||||
state,
|
||||
locale: state.locale || browserLocale,
|
||||
activeWorkspace: state.workspaces[state.activeWorkspaceId],
|
||||
createWorkspace: () => {
|
||||
const count = Object.keys(state.workspaces).length + 1;
|
||||
setState((prev) => Logic.createWorkspace(prev, count.toString()));
|
||||
},
|
||||
addWindow: (appId: AppId, props: any, title?: string, commandString?: string) =>
|
||||
// Wrap all callbacks in useCallback for stable references
|
||||
const createWorkspace = useCallback(() => {
|
||||
setState((prev) => {
|
||||
const nextNumber = Logic.findLowestAvailableWorkspaceNumber(
|
||||
prev.workspaces,
|
||||
);
|
||||
return Logic.createWorkspace(prev, nextNumber);
|
||||
});
|
||||
}, [setState]);
|
||||
|
||||
const addWindow = useCallback(
|
||||
(appId: AppId, props: any, title?: string, commandString?: string) =>
|
||||
setState((prev) =>
|
||||
Logic.addWindow(prev, {
|
||||
appId,
|
||||
@@ -91,17 +145,39 @@ export const useGrimoire = () => {
|
||||
commandString,
|
||||
}),
|
||||
),
|
||||
updateWindow: (windowId: string, updates: Partial<Pick<WindowInstance, "props" | "title" | "commandString" | "appId">>) =>
|
||||
setState((prev) => Logic.updateWindow(prev, windowId, updates)),
|
||||
removeWindow: (id: string) =>
|
||||
setState((prev) => Logic.removeWindow(prev, id)),
|
||||
moveWindowToWorkspace: (windowId: string, targetWorkspaceId: string) =>
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateWindow = useCallback(
|
||||
(
|
||||
windowId: string,
|
||||
updates: Partial<
|
||||
Pick<WindowInstance, "props" | "title" | "commandString" | "appId">
|
||||
>,
|
||||
) => setState((prev) => Logic.updateWindow(prev, windowId, updates)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
const removeWindow = useCallback(
|
||||
(id: string) => setState((prev) => Logic.removeWindow(prev, id)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
const moveWindowToWorkspace = useCallback(
|
||||
(windowId: string, targetWorkspaceId: string) =>
|
||||
setState((prev) =>
|
||||
Logic.moveWindowToWorkspace(prev, windowId, targetWorkspaceId),
|
||||
),
|
||||
updateLayout: (layout: any) =>
|
||||
setState((prev) => Logic.updateLayout(prev, layout)),
|
||||
setActiveWorkspace: (id: string) =>
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateLayout = useCallback(
|
||||
(layout: any) => setState((prev) => Logic.updateLayout(prev, layout)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
const setActiveWorkspace = useCallback(
|
||||
(id: string) =>
|
||||
setState((prev) => {
|
||||
// Validate target workspace exists
|
||||
if (!prev.workspaces[id]) {
|
||||
@@ -133,9 +209,33 @@ export const useGrimoire = () => {
|
||||
// Normal workspace switch
|
||||
return { ...prev, activeWorkspaceId: id };
|
||||
}),
|
||||
setActiveAccount: (pubkey: string | undefined) =>
|
||||
[setState],
|
||||
);
|
||||
|
||||
const setActiveAccount = useCallback(
|
||||
(pubkey: string | undefined) =>
|
||||
setState((prev) => Logic.setActiveAccount(prev, pubkey)),
|
||||
setActiveAccountRelays: (relays: any) =>
|
||||
[setState],
|
||||
);
|
||||
|
||||
const setActiveAccountRelays = useCallback(
|
||||
(relays: any) =>
|
||||
setState((prev) => Logic.setActiveAccountRelays(prev, relays)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
locale: state.locale || browserLocale,
|
||||
activeWorkspace: state.workspaces[state.activeWorkspaceId],
|
||||
createWorkspace,
|
||||
addWindow,
|
||||
updateWindow,
|
||||
removeWindow,
|
||||
moveWindowToWorkspace,
|
||||
updateLayout,
|
||||
setActiveWorkspace,
|
||||
setActiveAccount,
|
||||
setActiveAccountRelays,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user