chore: cleanup, a11y and state migrations

This commit is contained in:
Alejandro Gómez
2025-12-14 16:32:45 +01:00
parent f2ffc406d5
commit e5c871617e
35 changed files with 1658 additions and 225 deletions

127
src/core/logic.test.ts Normal file
View 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);
});
});
});

View File

@@ -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) {

View File

@@ -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,
};
};