mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
refactor(layouts): simplify to global config + preserve windows + add UI dropdown
Simplified layout system based on user feedback: **1. Global Layout Config (Simpler)** - Moved layoutConfig from per-workspace to global state - One configuration applies to all workspaces (easier to understand) - Updated state migration v8→v9 to move config to global level - Updated WorkspaceSettings UI to edit global config - Renamed updateWorkspaceLayoutConfig → updateLayoutConfig **2. Preserve Extra Windows (Fixed Bug)** - Fixed applyPresetToLayout to keep windows beyond preset slots - When applying 4-window grid to 6 windows, windows 5-6 are preserved - Extra windows stacked vertically on right side (70/30 split) - No more window loss when applying presets **3. Layout Dropdown in TabBar (Better UX)** - Added dropdown menu next to workspace tabs - Shows all available presets with icons (Grid2X2, Columns2, Split) - Displays window requirements and availability - Disables presets that need more windows than available - One-click preset application with toast feedback - More accessible than /layout command All tests passing (457 passed). State migration handles v6→v7→v8→v9 correctly. Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
@@ -1,24 +1,71 @@
|
||||
import { Plus, SlidersHorizontal } from "lucide-react";
|
||||
import { Plus, SlidersHorizontal, Grid2X2, Columns2, Split } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WorkspaceSettings } from "./WorkspaceSettings";
|
||||
import { getAllPresets } from "@/lib/layout-presets";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
|
||||
export function TabBar() {
|
||||
const { state, setActiveWorkspace, createWorkspace } = useGrimoire();
|
||||
const { state, setActiveWorkspace, createWorkspace, applyPresetLayout } = useGrimoire();
|
||||
const { workspaces, activeWorkspaceId } = state;
|
||||
const [settingsWorkspaceId, setSettingsWorkspaceId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
const activeWorkspace = workspaces[activeWorkspaceId];
|
||||
const windowCount = activeWorkspace?.windowIds.length || 0;
|
||||
const presets = getAllPresets();
|
||||
|
||||
const handleNewTab = () => {
|
||||
createWorkspace();
|
||||
};
|
||||
|
||||
const handleSettingsClick = (e: React.MouseEvent, workspaceId: string) => {
|
||||
const handleSettingsClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Prevent workspace switch
|
||||
setSettingsWorkspaceId(workspaceId);
|
||||
setSettingsOpen(true);
|
||||
};
|
||||
|
||||
const handleApplyPreset = (presetId: string) => {
|
||||
const preset = presets.find((p) => p.id === presetId);
|
||||
if (!preset) return;
|
||||
|
||||
if (windowCount < preset.slots) {
|
||||
toast.error(`Not enough windows`, {
|
||||
description: `Preset "${preset.name}" requires ${preset.slots} windows, but only ${windowCount} available.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
applyPresetLayout(preset);
|
||||
toast.success(`Layout applied`, {
|
||||
description: `Applied "${preset.name}" preset to workspace ${activeWorkspace.number}`,
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error(`Failed to apply layout`, {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getPresetIcon = (presetId: string) => {
|
||||
switch (presetId) {
|
||||
case "side-by-side":
|
||||
return <Columns2 className="h-4 w-4" />;
|
||||
case "main-sidebar":
|
||||
return <Split className="h-4 w-4" />;
|
||||
case "grid":
|
||||
return <Grid2X2 className="h-4 w-4" />;
|
||||
default:
|
||||
return <Grid2X2 className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Sort workspaces by number
|
||||
@@ -46,19 +93,60 @@ export function TabBar() {
|
||||
: ws.number}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => handleSettingsClick(e, ws.id)}
|
||||
onClick={(e) => handleSettingsClick(e)}
|
||||
className={cn(
|
||||
"absolute right-0.5 top-1/2 -translate-y-1/2 p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
ws.id === activeWorkspaceId
|
||||
? "text-primary-foreground hover:bg-primary-foreground/20"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted",
|
||||
)}
|
||||
aria-label={`Settings for workspace ${ws.number}`}
|
||||
aria-label="Layout settings"
|
||||
>
|
||||
<SlidersHorizontal className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Layout Preset Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 ml-1 flex-shrink-0"
|
||||
aria-label="Apply layout preset"
|
||||
>
|
||||
<Grid2X2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
|
||||
Apply Layout Preset
|
||||
</div>
|
||||
{presets.map((preset) => {
|
||||
const canApply = windowCount >= preset.slots;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={preset.id}
|
||||
onClick={() => handleApplyPreset(preset.id)}
|
||||
disabled={!canApply}
|
||||
className="flex items-center gap-3 cursor-pointer"
|
||||
>
|
||||
<div className="flex-shrink-0">{getPresetIcon(preset.id)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm">{preset.name}</div>
|
||||
<div className="text-xs text-muted-foreground truncate">
|
||||
{canApply
|
||||
? `${preset.slots} windows`
|
||||
: `Needs ${preset.slots} (have ${windowCount})`}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -71,15 +159,10 @@ export function TabBar() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settingsWorkspaceId && (
|
||||
<WorkspaceSettings
|
||||
workspaceId={settingsWorkspaceId}
|
||||
open={settingsWorkspaceId !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSettingsWorkspaceId(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<WorkspaceSettings
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,34 +18,29 @@ import type { LayoutConfig } from "@/types/app";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface WorkspaceSettingsProps {
|
||||
workspaceId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function WorkspaceSettings({
|
||||
workspaceId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: WorkspaceSettingsProps) {
|
||||
const { state, updateWorkspaceLayoutConfig } = useGrimoire();
|
||||
const workspace = state.workspaces[workspaceId];
|
||||
const { state, updateLayoutConfig } = useGrimoire();
|
||||
|
||||
// Local state for settings
|
||||
const [insertionMode, setInsertionMode] = useState<LayoutConfig["insertionMode"]>(
|
||||
workspace?.layoutConfig?.insertionMode || "smart"
|
||||
state.layoutConfig?.insertionMode || "smart"
|
||||
);
|
||||
const [splitPercentage, setSplitPercentage] = useState(
|
||||
workspace?.layoutConfig?.splitPercentage || 50
|
||||
state.layoutConfig?.splitPercentage || 50
|
||||
);
|
||||
const [insertionPosition, setInsertionPosition] = useState<LayoutConfig["insertionPosition"]>(
|
||||
workspace?.layoutConfig?.insertionPosition || "second"
|
||||
state.layoutConfig?.insertionPosition || "second"
|
||||
);
|
||||
|
||||
if (!workspace) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
updateWorkspaceLayoutConfig(workspaceId, {
|
||||
updateLayoutConfig({
|
||||
insertionMode,
|
||||
splitPercentage,
|
||||
insertionPosition,
|
||||
@@ -63,12 +58,9 @@ export function WorkspaceSettings({
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Workspace {workspace.number}
|
||||
{workspace.label && ` - ${workspace.label}`} Settings
|
||||
</DialogTitle>
|
||||
<DialogTitle>Layout Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure how new windows are inserted into this workspace layout.
|
||||
Configure how new windows are inserted into all workspaces.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -131,16 +131,16 @@ describe("findLowestAvailableWorkspaceNumber", () => {
|
||||
describe("addWindow", () => {
|
||||
// Helper to create minimal test state
|
||||
const createTestState = (layoutConfig: LayoutConfig, existingLayout: MosaicNode<string> | null = null): GrimoireState => ({
|
||||
__version: 8,
|
||||
__version: 9,
|
||||
windows: {},
|
||||
activeWorkspaceId: "test-workspace",
|
||||
layoutConfig, // Global layout config (not per-workspace)
|
||||
workspaces: {
|
||||
"test-workspace": {
|
||||
id: "test-workspace",
|
||||
number: 1,
|
||||
windowIds: [],
|
||||
layout: existingLayout,
|
||||
layoutConfig,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -76,8 +76,8 @@ export const addWindow = (
|
||||
commandString: payload.commandString,
|
||||
};
|
||||
|
||||
// Insert window using workspace layout configuration
|
||||
const newLayout = insertWindow(ws.layout, newWindowId, ws.layoutConfig);
|
||||
// Insert window using global layout configuration
|
||||
const newLayout = insertWindow(ws.layout, newWindowId, state.layoutConfig);
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -347,30 +347,18 @@ export const updateWindow = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the layout configuration for a workspace.
|
||||
* Controls how new windows are inserted into the workspace layout.
|
||||
* Updates the global layout configuration.
|
||||
* Controls how new windows are inserted into all workspaces.
|
||||
*/
|
||||
export const updateWorkspaceLayoutConfig = (
|
||||
export const updateLayoutConfig = (
|
||||
state: GrimoireState,
|
||||
workspaceId: string,
|
||||
layoutConfig: Partial<GrimoireState["workspaces"][string]["layoutConfig"]>,
|
||||
layoutConfig: Partial<GrimoireState["layoutConfig"]>,
|
||||
): GrimoireState => {
|
||||
const workspace = state.workspaces[workspaceId];
|
||||
if (!workspace) {
|
||||
return state; // Workspace doesn't exist, return unchanged
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
workspaces: {
|
||||
...state.workspaces,
|
||||
[workspaceId]: {
|
||||
...workspace,
|
||||
layoutConfig: {
|
||||
...workspace.layoutConfig,
|
||||
...layoutConfig,
|
||||
},
|
||||
},
|
||||
layoutConfig: {
|
||||
...state.layoutConfig,
|
||||
...layoutConfig,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,14 +18,14 @@ const initialState: GrimoireState = {
|
||||
number: 1,
|
||||
windowIds: [],
|
||||
layout: null,
|
||||
layoutConfig: {
|
||||
insertionMode: "smart", // Smart auto-balancing by default
|
||||
splitPercentage: 50, // Equal split
|
||||
insertionPosition: "second", // New windows on right/bottom
|
||||
autoPreset: undefined, // No preset maintenance
|
||||
},
|
||||
},
|
||||
},
|
||||
layoutConfig: {
|
||||
insertionMode: "smart", // Smart auto-balancing by default
|
||||
splitPercentage: 50, // Equal split
|
||||
insertionPosition: "second", // New windows on right/bottom
|
||||
autoPreset: undefined, // No preset maintenance
|
||||
},
|
||||
};
|
||||
|
||||
// Custom storage with error handling and migrations
|
||||
@@ -229,14 +229,9 @@ export const useGrimoire = () => {
|
||||
[setState],
|
||||
);
|
||||
|
||||
const updateWorkspaceLayoutConfig = useCallback(
|
||||
(
|
||||
workspaceId: string,
|
||||
layoutConfig: Partial<GrimoireState["workspaces"][string]["layoutConfig"]>,
|
||||
) =>
|
||||
setState((prev) =>
|
||||
Logic.updateWorkspaceLayoutConfig(prev, workspaceId, layoutConfig),
|
||||
),
|
||||
const updateLayoutConfig = useCallback(
|
||||
(layoutConfig: Partial<GrimoireState["layoutConfig"]>) =>
|
||||
setState((prev) => Logic.updateLayoutConfig(prev, layoutConfig)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
@@ -259,7 +254,7 @@ export const useGrimoire = () => {
|
||||
setActiveWorkspace,
|
||||
setActiveAccount,
|
||||
setActiveAccountRelays,
|
||||
updateWorkspaceLayoutConfig,
|
||||
updateLayoutConfig,
|
||||
applyPresetLayout,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -129,6 +129,7 @@ export function collectWindowIds(
|
||||
/**
|
||||
* Applies a preset layout to existing windows
|
||||
* Takes the first N windows from the current layout and arranges them according to the preset
|
||||
* Preserves any remaining windows by adding them to the right side of the preset
|
||||
*/
|
||||
export function applyPresetToLayout(
|
||||
currentLayout: MosaicNode<string> | null,
|
||||
@@ -144,11 +145,36 @@ export function applyPresetToLayout(
|
||||
);
|
||||
}
|
||||
|
||||
// Take first N windows for the preset
|
||||
const windowsToUse = windowIds.slice(0, preset.slots);
|
||||
// Split windows: first N for preset, rest to preserve
|
||||
const presetWindows = windowIds.slice(0, preset.slots);
|
||||
const remainingWindows = windowIds.slice(preset.slots);
|
||||
|
||||
// Fill template with window IDs
|
||||
return fillLayoutTemplate(preset.template, windowsToUse);
|
||||
// Fill template with preset windows
|
||||
let result = fillLayoutTemplate(preset.template, presetWindows);
|
||||
|
||||
// If there are remaining windows, add them to the right side
|
||||
if (remainingWindows.length > 0) {
|
||||
// Create a vertical stack for remaining windows
|
||||
let remainingStack: MosaicNode<string> = remainingWindows[0];
|
||||
for (let i = 1; i < remainingWindows.length; i++) {
|
||||
remainingStack = {
|
||||
direction: "column",
|
||||
first: remainingStack,
|
||||
second: remainingWindows[i],
|
||||
splitPercentage: 50,
|
||||
};
|
||||
}
|
||||
|
||||
// Put preset on left, remaining on right (70/30 split)
|
||||
result = {
|
||||
direction: "row",
|
||||
first: result,
|
||||
second: remainingStack,
|
||||
splitPercentage: 70, // Give more space to the preset layout
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,8 @@ import { describe, it, expect } from "vitest";
|
||||
import { migrateState, validateState, CURRENT_VERSION } from "./migrations";
|
||||
|
||||
describe("migrations", () => {
|
||||
describe("v6 to v8 migration (v6→v7→v8)", () => {
|
||||
it("should convert numeric labels to number field and add layoutConfig", () => {
|
||||
describe("v6 to v9 migration (v6→v7→v8→v9)", () => {
|
||||
it("should convert numeric labels to number field and add global layoutConfig", () => {
|
||||
const oldState = {
|
||||
__version: 6,
|
||||
windows: {},
|
||||
@@ -26,7 +26,7 @@ describe("migrations", () => {
|
||||
|
||||
const migrated = migrateState(oldState);
|
||||
|
||||
// Should migrate to v8
|
||||
// Should migrate to v9
|
||||
expect(migrated.__version).toBe(CURRENT_VERSION);
|
||||
|
||||
// v6→v7: numeric labels converted to number
|
||||
@@ -35,22 +35,19 @@ describe("migrations", () => {
|
||||
expect(migrated.workspaces.ws2.number).toBe(2);
|
||||
expect(migrated.workspaces.ws2.label).toBeUndefined();
|
||||
|
||||
// v7→v8: layoutConfig added
|
||||
expect(migrated.workspaces.ws1.layoutConfig).toEqual({
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
autoPreset: undefined,
|
||||
});
|
||||
expect(migrated.workspaces.ws2.layoutConfig).toEqual({
|
||||
// v7→v8→v9: layoutConfig moved to global state
|
||||
expect(migrated.layoutConfig).toEqual({
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
autoPreset: undefined,
|
||||
});
|
||||
// Workspaces should NOT have layoutConfig
|
||||
expect(migrated.workspaces.ws1.layoutConfig).toBeUndefined();
|
||||
expect(migrated.workspaces.ws2.layoutConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should convert non-numeric labels to number with label and add layoutConfig", () => {
|
||||
it("should convert non-numeric labels to number with label and add global layoutConfig", () => {
|
||||
const oldState = {
|
||||
__version: 6,
|
||||
windows: {},
|
||||
@@ -81,12 +78,13 @@ describe("migrations", () => {
|
||||
expect(migrated.workspaces.ws2.number).toBe(2);
|
||||
expect(migrated.workspaces.ws2.label).toBe("Development");
|
||||
|
||||
// v7→v8: layoutConfig added
|
||||
expect(migrated.workspaces.ws1.layoutConfig).toBeDefined();
|
||||
expect(migrated.workspaces.ws2.layoutConfig).toBeDefined();
|
||||
// v7→v8→v9: layoutConfig is global, not per-workspace
|
||||
expect(migrated.layoutConfig).toBeDefined();
|
||||
expect(migrated.workspaces.ws1.layoutConfig).toBeUndefined();
|
||||
expect(migrated.workspaces.ws2.layoutConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle mixed numeric and text labels and add layoutConfig", () => {
|
||||
it("should handle mixed numeric and text labels and add global layoutConfig", () => {
|
||||
const oldState = {
|
||||
__version: 6,
|
||||
windows: {},
|
||||
@@ -125,10 +123,11 @@ describe("migrations", () => {
|
||||
expect(migrated.workspaces.ws3.number).toBe(3);
|
||||
expect(migrated.workspaces.ws3.label).toBeUndefined();
|
||||
|
||||
// v7→v8: layoutConfig added to all workspaces
|
||||
expect(migrated.workspaces.ws1.layoutConfig).toBeDefined();
|
||||
expect(migrated.workspaces.ws2.layoutConfig).toBeDefined();
|
||||
expect(migrated.workspaces.ws3.layoutConfig).toBeDefined();
|
||||
// v7→v8→v9: layoutConfig is global
|
||||
expect(migrated.layoutConfig).toBeDefined();
|
||||
expect(migrated.workspaces.ws1.layoutConfig).toBeUndefined();
|
||||
expect(migrated.workspaces.ws2.layoutConfig).toBeUndefined();
|
||||
expect(migrated.workspaces.ws3.layoutConfig).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should validate migrated state", () => {
|
||||
@@ -151,10 +150,10 @@ describe("migrations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("v7 to v8 migration", () => {
|
||||
it("should add layoutConfig to existing workspaces", () => {
|
||||
const v7State = {
|
||||
__version: 7,
|
||||
describe("v8 to v9 migration", () => {
|
||||
it("should move layoutConfig from workspaces to global state", () => {
|
||||
const v8State = {
|
||||
__version: 8,
|
||||
windows: {
|
||||
w1: { id: "w1", appId: "profile", props: {} },
|
||||
w2: { id: "w2", appId: "nip", props: {} },
|
||||
@@ -167,6 +166,12 @@ describe("migrations", () => {
|
||||
label: undefined,
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
layoutConfig: {
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
autoPreset: undefined,
|
||||
},
|
||||
},
|
||||
ws2: {
|
||||
id: "ws2",
|
||||
@@ -174,27 +179,33 @@ describe("migrations", () => {
|
||||
label: "Development",
|
||||
layout: { direction: "row", first: "w1", second: "w2", splitPercentage: 50 },
|
||||
windowIds: ["w1", "w2"],
|
||||
layoutConfig: {
|
||||
insertionMode: "row",
|
||||
splitPercentage: 70,
|
||||
insertionPosition: "first",
|
||||
autoPreset: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = migrateState(v7State);
|
||||
const migrated = migrateState(v8State);
|
||||
|
||||
expect(migrated.__version).toBe(8);
|
||||
expect(migrated.workspaces.ws1.layoutConfig).toEqual({
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
autoPreset: undefined,
|
||||
});
|
||||
expect(migrated.workspaces.ws2.layoutConfig).toEqual({
|
||||
expect(migrated.__version).toBe(9);
|
||||
|
||||
// layoutConfig should be at global level (from first workspace)
|
||||
expect(migrated.layoutConfig).toEqual({
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
autoPreset: undefined,
|
||||
});
|
||||
|
||||
// Existing fields should be preserved
|
||||
// Workspaces should NOT have layoutConfig
|
||||
expect(migrated.workspaces.ws1.layoutConfig).toBeUndefined();
|
||||
expect(migrated.workspaces.ws2.layoutConfig).toBeUndefined();
|
||||
|
||||
// Other fields should be preserved
|
||||
expect(migrated.workspaces.ws2.label).toBe("Development");
|
||||
expect(migrated.workspaces.ws2.layout).toEqual({
|
||||
direction: "row",
|
||||
@@ -204,9 +215,9 @@ describe("migrations", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should validate v7→v8 migrated state", () => {
|
||||
const v7State = {
|
||||
__version: 7,
|
||||
it("should validate v8→v9 migrated state", () => {
|
||||
const v8State = {
|
||||
__version: 8,
|
||||
windows: { w1: { id: "w1", appId: "profile", props: {} } },
|
||||
activeWorkspaceId: "ws1",
|
||||
workspaces: {
|
||||
@@ -215,11 +226,17 @@ describe("migrations", () => {
|
||||
number: 1,
|
||||
layout: "w1",
|
||||
windowIds: ["w1"],
|
||||
layoutConfig: {
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
autoPreset: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const migrated = migrateState(v7State);
|
||||
const migrated = migrateState(v8State);
|
||||
expect(validateState(migrated)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -230,6 +247,12 @@ describe("migrations", () => {
|
||||
__version: CURRENT_VERSION,
|
||||
windows: {},
|
||||
activeWorkspaceId: "default",
|
||||
layoutConfig: {
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
autoPreset: undefined,
|
||||
},
|
||||
workspaces: {
|
||||
default: {
|
||||
id: "default",
|
||||
@@ -247,6 +270,11 @@ describe("migrations", () => {
|
||||
const state = {
|
||||
windows: {},
|
||||
activeWorkspaceId: "default",
|
||||
layoutConfig: {
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
},
|
||||
workspaces: {
|
||||
default: {
|
||||
id: "default",
|
||||
@@ -265,6 +293,29 @@ describe("migrations", () => {
|
||||
__version: CURRENT_VERSION,
|
||||
windows: {},
|
||||
activeWorkspaceId: "default",
|
||||
layoutConfig: {
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
},
|
||||
};
|
||||
|
||||
expect(validateState(state)).toBe(false);
|
||||
});
|
||||
|
||||
it("should reject state without layoutConfig", () => {
|
||||
const state = {
|
||||
__version: CURRENT_VERSION,
|
||||
windows: {},
|
||||
activeWorkspaceId: "default",
|
||||
workspaces: {
|
||||
default: {
|
||||
id: "default",
|
||||
number: 1,
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(validateState(state)).toBe(false);
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { GrimoireState } from "@/types/app";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export const CURRENT_VERSION = 8;
|
||||
export const CURRENT_VERSION = 9;
|
||||
|
||||
/**
|
||||
* Migration function type
|
||||
@@ -90,6 +90,32 @@ const migrations: Record<number, MigrationFn> = {
|
||||
workspaces: migratedWorkspaces,
|
||||
};
|
||||
},
|
||||
// Migration from v8 to v9 - moves layoutConfig from per-workspace to global
|
||||
8: (state: any) => {
|
||||
const migratedWorkspaces: Record<string, any> = {};
|
||||
|
||||
// Get layoutConfig from first workspace (or use default)
|
||||
const firstWorkspace = Object.values(state.workspaces || {})[0] as any;
|
||||
const layoutConfig = firstWorkspace?.layoutConfig || {
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
autoPreset: undefined,
|
||||
};
|
||||
|
||||
// Remove layoutConfig from all workspaces
|
||||
for (const [id, workspace] of Object.entries(state.workspaces || {})) {
|
||||
const { layoutConfig: _, ...workspaceWithoutConfig } = workspace as any;
|
||||
migratedWorkspaces[id] = workspaceWithoutConfig;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
__version: 9,
|
||||
workspaces: migratedWorkspaces,
|
||||
layoutConfig, // Move to global state
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -108,6 +134,7 @@ export function validateState(state: any): state is GrimoireState {
|
||||
!state.windows ||
|
||||
!state.workspaces ||
|
||||
!state.activeWorkspaceId ||
|
||||
!state.layoutConfig ||
|
||||
typeof state.__version !== "number"
|
||||
) {
|
||||
return false;
|
||||
|
||||
@@ -65,7 +65,6 @@ export interface Workspace {
|
||||
label?: string; // Optional user-editable label
|
||||
layout: MosaicNode<string> | null;
|
||||
windowIds: string[];
|
||||
layoutConfig: LayoutConfig; // How new windows are inserted into layout
|
||||
}
|
||||
|
||||
export interface RelayInfo {
|
||||
@@ -85,6 +84,7 @@ export interface GrimoireState {
|
||||
windows: Record<string, WindowInstance>;
|
||||
workspaces: Record<string, Workspace>;
|
||||
activeWorkspaceId: string;
|
||||
layoutConfig: LayoutConfig; // Global configuration for window insertion (applies to all workspaces)
|
||||
activeAccount?: {
|
||||
pubkey: string;
|
||||
relays?: UserRelays;
|
||||
|
||||
Reference in New Issue
Block a user