mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 15:36:53 +02:00
fix: type errors
This commit is contained in:
@@ -18,58 +18,55 @@ export default function KindsViewer() {
|
||||
<CenteredContent>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2">
|
||||
Supported Event Kinds ({sortedKinds.length})
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Event kinds with rich rendering support in Grimoire. Default kinds
|
||||
display raw content only.
|
||||
</p>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold mb-2">
|
||||
Supported Event Kinds ({sortedKinds.length})
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Event kinds with rich rendering support in Grimoire. Default kinds
|
||||
display raw content only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Kind List */}
|
||||
<div className="border border-border divide-y divide-border">
|
||||
{sortedKinds.map((kind) => {
|
||||
const kindInfo = getKindInfo(kind);
|
||||
const Icon = kindInfo?.icon;
|
||||
{/* Kind List */}
|
||||
<div className="border border-border divide-y divide-border">
|
||||
{sortedKinds.map((kind) => {
|
||||
const kindInfo = getKindInfo(kind);
|
||||
const Icon = kindInfo?.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={kind}
|
||||
className="p-4 hover:bg-muted/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 bg-accent/20 rounded flex items-center justify-center flex-shrink-0">
|
||||
{Icon ? (
|
||||
<Icon className="w-5 h-5 text-accent" />
|
||||
) : (
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{kind}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<code className="text-sm font-mono font-semibold">
|
||||
{kind}
|
||||
</code>
|
||||
<span className="text-sm font-semibold">
|
||||
{kindInfo?.name || `Kind ${kind}`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{kindInfo?.description || "No description available"}
|
||||
</p>
|
||||
{kindInfo?.nip && <NIPBadge nipNumber={kindInfo.nip} />}
|
||||
return (
|
||||
<div key={kind} className="p-4 hover:bg-muted/30 transition-colors">
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Icon */}
|
||||
<div className="w-10 h-10 bg-accent/20 rounded flex items-center justify-center flex-shrink-0">
|
||||
{Icon ? (
|
||||
<Icon className="w-5 h-5 text-accent" />
|
||||
) : (
|
||||
<span className="text-xs font-mono text-muted-foreground">
|
||||
{kind}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<code className="text-sm font-mono font-semibold">
|
||||
{kind}
|
||||
</code>
|
||||
<span className="text-sm font-semibold">
|
||||
{kindInfo?.name || `Kind ${kind}`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{kindInfo?.description || "No description available"}
|
||||
</p>
|
||||
{kindInfo?.nip && <NIPBadge nipNumber={kindInfo.nip} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
import { Button } from "./ui/button";
|
||||
import { Slider } from "./ui/slider";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getAllPresets } from "@/lib/layout-presets";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -25,7 +24,7 @@ import { useState } from "react";
|
||||
|
||||
export function LayoutControls() {
|
||||
const { state, applyPresetLayout, updateLayoutConfig } = useGrimoire();
|
||||
const { workspaces, activeWorkspaceId, layoutConfig } = state;
|
||||
const { workspaces, activeWorkspaceId } = state;
|
||||
|
||||
// Local state for immediate slider feedback (debounced persistence)
|
||||
const [localSplitPercentage, setLocalSplitPercentage] = useState<
|
||||
@@ -33,9 +32,15 @@ export function LayoutControls() {
|
||||
>(null);
|
||||
|
||||
const activeWorkspace = workspaces[activeWorkspaceId];
|
||||
const layoutConfig = activeWorkspace?.layoutConfig;
|
||||
const windowCount = activeWorkspace?.windowIds.length || 0;
|
||||
const presets = getAllPresets();
|
||||
|
||||
// Early return if no active workspace or layout config
|
||||
if (!activeWorkspace || !layoutConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleApplyPreset = (presetId: string) => {
|
||||
const preset = presets.find((p) => p.id === presetId);
|
||||
if (!preset) return;
|
||||
|
||||
@@ -16,7 +16,9 @@ export function NipRenderer({ nipId, className = "" }: NipRendererProps) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<CenteredContent className={cn("text-muted-foreground text-sm", className)}>
|
||||
<CenteredContent
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
>
|
||||
Loading NIP-{nipId}...
|
||||
</CenteredContent>
|
||||
);
|
||||
|
||||
@@ -68,60 +68,60 @@ export default function NipsViewer() {
|
||||
<CenteredContent>
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2">
|
||||
{search
|
||||
? `Showing ${filteredNips.length} of ${sortedNips.length} NIPs`
|
||||
: `Nostr Implementation Possibilities (${sortedNips.length})`}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Protocol specifications and extensions for the Nostr network. Click
|
||||
any NIP to view its full specification document.
|
||||
</p>
|
||||
<h1 className="text-2xl font-bold mb-2">
|
||||
{search
|
||||
? `Showing ${filteredNips.length} of ${sortedNips.length} NIPs`
|
||||
: `Nostr Implementation Possibilities (${sortedNips.length})`}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Protocol specifications and extensions for the Nostr network. Click
|
||||
any NIP to view its full specification document.
|
||||
</p>
|
||||
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search NIPs by number or title..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{search && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="Search NIPs by number or title..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="pl-9 pr-9"
|
||||
/>
|
||||
{search && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClear}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0 hover:bg-muted"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NIP List */}
|
||||
{filteredNips.length > 0 ? (
|
||||
<div className="flex flex-col gap-0">
|
||||
{filteredNips.map((nipId) => (
|
||||
<NIPBadge
|
||||
className="border-none"
|
||||
key={nipId}
|
||||
showName
|
||||
nipNumber={nipId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p className="text-lg mb-2">No NIPs match "{search}"</p>
|
||||
<p className="text-sm">Try searching for a different term</p>
|
||||
</div>
|
||||
)}
|
||||
{/* NIP List */}
|
||||
{filteredNips.length > 0 ? (
|
||||
<div className="flex flex-col gap-0">
|
||||
{filteredNips.map((nipId) => (
|
||||
<NIPBadge
|
||||
className="border-none"
|
||||
key={nipId}
|
||||
showName
|
||||
nipNumber={nipId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p className="text-lg mb-2">No NIPs match "{search}"</p>
|
||||
<p className="text-sm">Try searching for a different term</p>
|
||||
</div>
|
||||
)}
|
||||
</CenteredContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "./ui/popover";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import type { LayoutConfig } from "@/types/app";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -24,7 +20,13 @@ export function WorkspaceSettings({
|
||||
children,
|
||||
}: WorkspaceSettingsProps) {
|
||||
const { state, updateLayoutConfig } = useGrimoire();
|
||||
const config = state.layoutConfig;
|
||||
const activeWorkspace = state.workspaces[state.activeWorkspaceId];
|
||||
const config = activeWorkspace?.layoutConfig;
|
||||
|
||||
// Early return if no config available
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setInsertionMode = (mode: LayoutConfig["insertionMode"]) => {
|
||||
updateLayoutConfig({ insertionMode: mode });
|
||||
@@ -64,7 +66,7 @@ export function WorkspaceSettings({
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded transition-colors",
|
||||
isActive
|
||||
? "bg-accent text-accent-foreground"
|
||||
: "hover:bg-muted"
|
||||
: "hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
|
||||
@@ -80,10 +80,7 @@ export function LiveActivityDetailRenderer({
|
||||
</div>
|
||||
|
||||
{/* Host */}
|
||||
<UserName
|
||||
pubkey={hostPubkey}
|
||||
className="text-sm text-accent"
|
||||
/>
|
||||
<UserName pubkey={hostPubkey} className="text-sm text-accent" />
|
||||
|
||||
{/* Description */}
|
||||
{activity.summary && (
|
||||
|
||||
@@ -7,7 +7,17 @@ interface CenteredContentProps {
|
||||
* Maximum width of the centered content
|
||||
* @default '3xl' (48rem / 768px)
|
||||
*/
|
||||
maxWidth?: "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "6xl" | "full";
|
||||
maxWidth?:
|
||||
| "sm"
|
||||
| "md"
|
||||
| "lg"
|
||||
| "xl"
|
||||
| "2xl"
|
||||
| "3xl"
|
||||
| "4xl"
|
||||
| "5xl"
|
||||
| "6xl"
|
||||
| "full";
|
||||
/**
|
||||
* Vertical spacing between child elements
|
||||
* @default '6' (1.5rem)
|
||||
@@ -98,7 +108,7 @@ export function CenteredContent({
|
||||
"mx-auto",
|
||||
maxWidthClasses[maxWidth],
|
||||
spacingClasses[spacing],
|
||||
className
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 rounded-md border bg-popover text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider"
|
||||
import * as React from "react";
|
||||
import * as SliderPrimitive from "@radix-ui/react-slider";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof SliderPrimitive.Root>,
|
||||
@@ -11,7 +11,7 @@ const Slider = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full touch-none select-none items-center",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@@ -20,7 +20,7 @@ const Slider = React.forwardRef<
|
||||
</SliderPrimitive.Track>
|
||||
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</SliderPrimitive.Root>
|
||||
))
|
||||
Slider.displayName = SliderPrimitive.Root.displayName
|
||||
));
|
||||
Slider.displayName = SliderPrimitive.Root.displayName;
|
||||
|
||||
export { Slider }
|
||||
export { Slider };
|
||||
|
||||
@@ -130,17 +130,20 @@ describe("findLowestAvailableWorkspaceNumber", () => {
|
||||
|
||||
describe("addWindow", () => {
|
||||
// Helper to create minimal test state
|
||||
const createTestState = (layoutConfig: LayoutConfig, existingLayout: MosaicNode<string> | null = null): GrimoireState => ({
|
||||
const createTestState = (
|
||||
layoutConfig: LayoutConfig,
|
||||
existingLayout: MosaicNode<string> | null = null,
|
||||
): GrimoireState => ({
|
||||
__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, // Per-workspace layout config
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -207,12 +210,19 @@ describe("addWindow", () => {
|
||||
|
||||
describe("second window with row config", () => {
|
||||
it("should create horizontal split", () => {
|
||||
const state = createTestState({
|
||||
insertionMode: "row",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
}, "window-1");
|
||||
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
|
||||
const state = createTestState(
|
||||
{
|
||||
insertionMode: "row",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
},
|
||||
"window-1",
|
||||
);
|
||||
state.windows["window-1"] = {
|
||||
id: "window-1",
|
||||
appId: "profile",
|
||||
props: {},
|
||||
};
|
||||
state.workspaces["test-workspace"].windowIds = ["window-1"];
|
||||
|
||||
const result = addWindow(state, {
|
||||
@@ -229,12 +239,19 @@ describe("addWindow", () => {
|
||||
});
|
||||
|
||||
it("should respect custom split percentage", () => {
|
||||
const state = createTestState({
|
||||
insertionMode: "row",
|
||||
splitPercentage: 70,
|
||||
insertionPosition: "second",
|
||||
}, "window-1");
|
||||
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
|
||||
const state = createTestState(
|
||||
{
|
||||
insertionMode: "row",
|
||||
splitPercentage: 70,
|
||||
insertionPosition: "second",
|
||||
},
|
||||
"window-1",
|
||||
);
|
||||
state.windows["window-1"] = {
|
||||
id: "window-1",
|
||||
appId: "profile",
|
||||
props: {},
|
||||
};
|
||||
state.workspaces["test-workspace"].windowIds = ["window-1"];
|
||||
|
||||
const result = addWindow(state, {
|
||||
@@ -250,12 +267,19 @@ describe("addWindow", () => {
|
||||
});
|
||||
|
||||
it("should place new window on right when position is second", () => {
|
||||
const state = createTestState({
|
||||
insertionMode: "row",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
}, "window-1");
|
||||
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
|
||||
const state = createTestState(
|
||||
{
|
||||
insertionMode: "row",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
},
|
||||
"window-1",
|
||||
);
|
||||
state.windows["window-1"] = {
|
||||
id: "window-1",
|
||||
appId: "profile",
|
||||
props: {},
|
||||
};
|
||||
state.workspaces["test-workspace"].windowIds = ["window-1"];
|
||||
|
||||
const result = addWindow(state, {
|
||||
@@ -270,12 +294,19 @@ describe("addWindow", () => {
|
||||
});
|
||||
|
||||
it("should place new window on left when position is first", () => {
|
||||
const state = createTestState({
|
||||
insertionMode: "row",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "first",
|
||||
}, "window-1");
|
||||
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
|
||||
const state = createTestState(
|
||||
{
|
||||
insertionMode: "row",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "first",
|
||||
},
|
||||
"window-1",
|
||||
);
|
||||
state.windows["window-1"] = {
|
||||
id: "window-1",
|
||||
appId: "profile",
|
||||
props: {},
|
||||
};
|
||||
state.workspaces["test-workspace"].windowIds = ["window-1"];
|
||||
|
||||
const result = addWindow(state, {
|
||||
@@ -292,12 +323,19 @@ describe("addWindow", () => {
|
||||
|
||||
describe("second window with column config", () => {
|
||||
it("should create vertical split", () => {
|
||||
const state = createTestState({
|
||||
insertionMode: "column",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
}, "window-1");
|
||||
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
|
||||
const state = createTestState(
|
||||
{
|
||||
insertionMode: "column",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
},
|
||||
"window-1",
|
||||
);
|
||||
state.windows["window-1"] = {
|
||||
id: "window-1",
|
||||
appId: "profile",
|
||||
props: {},
|
||||
};
|
||||
state.workspaces["test-workspace"].windowIds = ["window-1"];
|
||||
|
||||
const result = addWindow(state, {
|
||||
@@ -314,12 +352,19 @@ describe("addWindow", () => {
|
||||
});
|
||||
|
||||
it("should place new window on bottom when position is second", () => {
|
||||
const state = createTestState({
|
||||
insertionMode: "column",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
}, "window-1");
|
||||
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
|
||||
const state = createTestState(
|
||||
{
|
||||
insertionMode: "column",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
},
|
||||
"window-1",
|
||||
);
|
||||
state.windows["window-1"] = {
|
||||
id: "window-1",
|
||||
appId: "profile",
|
||||
props: {},
|
||||
};
|
||||
state.workspaces["test-workspace"].windowIds = ["window-1"];
|
||||
|
||||
const result = addWindow(state, {
|
||||
@@ -334,12 +379,19 @@ describe("addWindow", () => {
|
||||
});
|
||||
|
||||
it("should place new window on top when position is first", () => {
|
||||
const state = createTestState({
|
||||
insertionMode: "column",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "first",
|
||||
}, "window-1");
|
||||
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
|
||||
const state = createTestState(
|
||||
{
|
||||
insertionMode: "column",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "first",
|
||||
},
|
||||
"window-1",
|
||||
);
|
||||
state.windows["window-1"] = {
|
||||
id: "window-1",
|
||||
appId: "profile",
|
||||
props: {},
|
||||
};
|
||||
state.workspaces["test-workspace"].windowIds = ["window-1"];
|
||||
|
||||
const result = addWindow(state, {
|
||||
@@ -356,12 +408,19 @@ describe("addWindow", () => {
|
||||
|
||||
describe("second window with smart config", () => {
|
||||
it("should create horizontal split for first split", () => {
|
||||
const state = createTestState({
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
}, "window-1");
|
||||
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
|
||||
const state = createTestState(
|
||||
{
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
},
|
||||
"window-1",
|
||||
);
|
||||
state.windows["window-1"] = {
|
||||
id: "window-1",
|
||||
appId: "profile",
|
||||
props: {},
|
||||
};
|
||||
state.workspaces["test-workspace"].windowIds = ["window-1"];
|
||||
|
||||
const result = addWindow(state, {
|
||||
@@ -385,12 +444,19 @@ describe("addWindow", () => {
|
||||
second: "window-2",
|
||||
splitPercentage: 50,
|
||||
};
|
||||
const state = createTestState({
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
}, existingLayout);
|
||||
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
|
||||
const state = createTestState(
|
||||
{
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
},
|
||||
existingLayout,
|
||||
);
|
||||
state.windows["window-1"] = {
|
||||
id: "window-1",
|
||||
appId: "profile",
|
||||
props: {},
|
||||
};
|
||||
state.windows["window-2"] = { id: "window-2", appId: "nip", props: {} };
|
||||
state.workspaces["test-workspace"].windowIds = ["window-1", "window-2"];
|
||||
|
||||
@@ -400,10 +466,14 @@ describe("addWindow", () => {
|
||||
});
|
||||
|
||||
const workspace = result.workspaces["test-workspace"];
|
||||
// Should add column split to balance (1 row, 0 column → add column)
|
||||
// NEW BEHAVIOR: Splits shallowest leaf (window-1 or window-2 at depth 1)
|
||||
// Root remains row, but creates column split at the leaf
|
||||
expect(workspace.layout).toMatchObject({
|
||||
direction: "column",
|
||||
direction: "row",
|
||||
});
|
||||
// The first child should now be a column split containing the original window and new window
|
||||
const layout = workspace.layout as any;
|
||||
expect(layout.first).toHaveProperty("direction", "column");
|
||||
});
|
||||
|
||||
it("should balance by adding horizontal split when vertical exists", () => {
|
||||
@@ -414,12 +484,19 @@ describe("addWindow", () => {
|
||||
second: "window-2",
|
||||
splitPercentage: 50,
|
||||
};
|
||||
const state = createTestState({
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
}, existingLayout);
|
||||
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
|
||||
const state = createTestState(
|
||||
{
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
},
|
||||
existingLayout,
|
||||
);
|
||||
state.windows["window-1"] = {
|
||||
id: "window-1",
|
||||
appId: "profile",
|
||||
props: {},
|
||||
};
|
||||
state.windows["window-2"] = { id: "window-2", appId: "nip", props: {} };
|
||||
state.workspaces["test-workspace"].windowIds = ["window-1", "window-2"];
|
||||
|
||||
@@ -429,10 +506,14 @@ describe("addWindow", () => {
|
||||
});
|
||||
|
||||
const workspace = result.workspaces["test-workspace"];
|
||||
// Should add row split to balance (0 row, 1 column → add row)
|
||||
// NEW BEHAVIOR: Splits shallowest leaf (window-1 or window-2 at depth 1)
|
||||
// Root remains column, but creates row split at the leaf
|
||||
expect(workspace.layout).toMatchObject({
|
||||
direction: "row",
|
||||
direction: "column",
|
||||
});
|
||||
// The first child should now be a row split containing the original window and new window
|
||||
const layout = workspace.layout as any;
|
||||
expect(layout.first).toHaveProperty("direction", "row");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -514,12 +595,19 @@ describe("addWindow", () => {
|
||||
});
|
||||
|
||||
it("should preserve existing windows when adding new one", () => {
|
||||
const state = createTestState({
|
||||
insertionMode: "row",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
}, "window-1");
|
||||
state.windows["window-1"] = { id: "window-1", appId: "profile", props: {} };
|
||||
const state = createTestState(
|
||||
{
|
||||
insertionMode: "row",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
},
|
||||
"window-1",
|
||||
);
|
||||
state.windows["window-1"] = {
|
||||
id: "window-1",
|
||||
appId: "profile",
|
||||
props: {},
|
||||
};
|
||||
state.workspaces["test-workspace"].windowIds = ["window-1"];
|
||||
|
||||
const result = addWindow(state, {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { MosaicNode } from "react-mosaic-component";
|
||||
import { GrimoireState, WindowInstance, UserRelays } from "@/types/app";
|
||||
import {
|
||||
GrimoireState,
|
||||
WindowInstance,
|
||||
UserRelays,
|
||||
LayoutConfig,
|
||||
} from "@/types/app";
|
||||
import { insertWindow } from "@/lib/layout-utils";
|
||||
import { applyPresetToLayout, type LayoutPreset } from "@/lib/layout-presets";
|
||||
|
||||
@@ -48,6 +53,12 @@ export const createWorkspace = (
|
||||
label,
|
||||
layout: null,
|
||||
windowIds: [],
|
||||
layoutConfig: {
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
autoPreset: undefined,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -76,8 +87,8 @@ export const addWindow = (
|
||||
commandString: payload.commandString,
|
||||
};
|
||||
|
||||
// Insert window using global layout configuration
|
||||
const newLayout = insertWindow(ws.layout, newWindowId, state.layoutConfig);
|
||||
// Insert window using workspace's layout configuration
|
||||
const newLayout = insertWindow(ws.layout, newWindowId, ws.layoutConfig);
|
||||
|
||||
return {
|
||||
...state,
|
||||
@@ -347,18 +358,27 @@ export const updateWindow = (
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the global layout configuration.
|
||||
* Controls how new windows are inserted into all workspaces.
|
||||
* Updates the active workspace's layout configuration.
|
||||
* Controls how new windows are inserted into the active workspace.
|
||||
*/
|
||||
export const updateLayoutConfig = (
|
||||
state: GrimoireState,
|
||||
layoutConfig: Partial<GrimoireState["layoutConfig"]>,
|
||||
layoutConfig: Partial<LayoutConfig>,
|
||||
): GrimoireState => {
|
||||
const activeId = state.activeWorkspaceId;
|
||||
const activeWorkspace = state.workspaces[activeId];
|
||||
|
||||
return {
|
||||
...state,
|
||||
layoutConfig: {
|
||||
...state.layoutConfig,
|
||||
...layoutConfig,
|
||||
workspaces: {
|
||||
...state.workspaces,
|
||||
[activeId]: {
|
||||
...activeWorkspace,
|
||||
layoutConfig: {
|
||||
...activeWorkspace.layoutConfig,
|
||||
...layoutConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { useAtom } from "jotai";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
import { GrimoireState, AppId, WindowInstance } from "@/types/app";
|
||||
import {
|
||||
GrimoireState,
|
||||
AppId,
|
||||
WindowInstance,
|
||||
LayoutConfig,
|
||||
} from "@/types/app";
|
||||
import { useLocale } from "@/hooks/useLocale";
|
||||
import * as Logic from "./logic";
|
||||
import { CURRENT_VERSION, validateState, migrateState } from "@/lib/migrations";
|
||||
@@ -18,14 +23,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
|
||||
@@ -230,14 +235,13 @@ export const useGrimoire = () => {
|
||||
);
|
||||
|
||||
const updateLayoutConfig = useCallback(
|
||||
(layoutConfig: Partial<GrimoireState["layoutConfig"]>) =>
|
||||
(layoutConfig: Partial<LayoutConfig>) =>
|
||||
setState((prev) => Logic.updateLayoutConfig(prev, layoutConfig)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
const applyPresetLayout = useCallback(
|
||||
(preset: any) =>
|
||||
setState((prev) => Logic.applyPresetLayout(prev, preset)),
|
||||
(preset: any) => setState((prev) => Logic.applyPresetLayout(prev, preset)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
|
||||
@@ -180,13 +180,7 @@ describe("layout-presets", () => {
|
||||
});
|
||||
|
||||
it("handles 5 windows (main + 4 sidebars)", () => {
|
||||
const layout = mainSidebarPreset.generate([
|
||||
"w1",
|
||||
"w2",
|
||||
"w3",
|
||||
"w4",
|
||||
"w5",
|
||||
]);
|
||||
const layout = mainSidebarPreset.generate(["w1", "w2", "w3", "w4", "w5"]);
|
||||
const windowIds = collectWindowIds(layout);
|
||||
expect(windowIds).toEqual(["w1", "w2", "w3", "w4", "w5"]);
|
||||
// First window is main, rest are stacked vertically
|
||||
@@ -203,9 +197,9 @@ describe("layout-presets", () => {
|
||||
describe("applyPresetToLayout", () => {
|
||||
it("throws error if too few windows", () => {
|
||||
const layout: MosaicNode<string> = "w1";
|
||||
expect(() =>
|
||||
applyPresetToLayout(layout, BUILT_IN_PRESETS.grid)
|
||||
).toThrow("at least 2 windows");
|
||||
expect(() => applyPresetToLayout(layout, BUILT_IN_PRESETS.grid)).toThrow(
|
||||
"at least 2 windows",
|
||||
);
|
||||
});
|
||||
|
||||
it("applies grid preset to existing layout", () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ function buildHorizontalRow(windowIds: string[]): MosaicNode<string> {
|
||||
}
|
||||
|
||||
// Calculate percentage for first window to make equal splits
|
||||
const splitPercent = (100 / windowIds.length);
|
||||
const splitPercent = 100 / windowIds.length;
|
||||
|
||||
return {
|
||||
direction: "row",
|
||||
@@ -52,7 +52,7 @@ function buildVerticalStack(windowIds: string[]): MosaicNode<string> {
|
||||
}
|
||||
|
||||
// Calculate percentage for first window to make equal splits
|
||||
const splitPercent = (100 / windowIds.length);
|
||||
const splitPercent = 100 / windowIds.length;
|
||||
|
||||
return {
|
||||
direction: "column",
|
||||
@@ -62,11 +62,38 @@ function buildVerticalStack(windowIds: string[]): MosaicNode<string> {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a vertical stack of MosaicNodes with equal splits
|
||||
*/
|
||||
function buildVerticalStackOfNodes(
|
||||
nodes: MosaicNode<string>[],
|
||||
): MosaicNode<string> {
|
||||
if (nodes.length === 0) {
|
||||
throw new Error("Cannot build stack with zero nodes");
|
||||
}
|
||||
if (nodes.length === 1) {
|
||||
return nodes[0];
|
||||
}
|
||||
|
||||
// Calculate percentage for first node to make equal splits
|
||||
const splitPercent = 100 / nodes.length;
|
||||
|
||||
return {
|
||||
direction: "column",
|
||||
first: nodes[0],
|
||||
second: buildVerticalStackOfNodes(nodes.slice(1)),
|
||||
splitPercentage: splitPercent,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates best grid dimensions for N windows
|
||||
* Prefers square-ish grids, slightly favoring more columns than rows
|
||||
*/
|
||||
function calculateGridDimensions(windowCount: number): { rows: number; cols: number } {
|
||||
function calculateGridDimensions(windowCount: number): {
|
||||
rows: number;
|
||||
cols: number;
|
||||
} {
|
||||
const sqrt = Math.sqrt(windowCount);
|
||||
const rows = Math.floor(sqrt);
|
||||
const cols = Math.ceil(windowCount / rows);
|
||||
@@ -95,16 +122,16 @@ function buildGridLayout(windowIds: string[]): MosaicNode<string> {
|
||||
return windowIds[0];
|
||||
}
|
||||
|
||||
const { rows, cols } = calculateGridDimensions(windowIds.length);
|
||||
const { cols } = calculateGridDimensions(windowIds.length);
|
||||
|
||||
// Split windows into rows
|
||||
const rowChunks = chunkArray(windowIds, cols);
|
||||
|
||||
// Build each row as a horizontal split
|
||||
const rowNodes = rowChunks.map(chunk => buildHorizontalRow(chunk));
|
||||
const rowNodes = rowChunks.map((chunk) => buildHorizontalRow(chunk));
|
||||
|
||||
// Stack rows vertically
|
||||
return buildVerticalStack(rowNodes);
|
||||
return buildVerticalStackOfNodes(rowNodes);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,9 +183,7 @@ export const BUILT_IN_PRESETS: Record<string, LayoutPreset> = {
|
||||
/**
|
||||
* Collects window IDs from a layout tree in depth-first order
|
||||
*/
|
||||
export function collectWindowIds(
|
||||
layout: MosaicNode<string> | null
|
||||
): string[] {
|
||||
export function collectWindowIds(layout: MosaicNode<string> | null): string[] {
|
||||
if (layout === null) {
|
||||
return [];
|
||||
}
|
||||
@@ -167,7 +192,10 @@ export function collectWindowIds(
|
||||
return [layout];
|
||||
}
|
||||
|
||||
return [...collectWindowIds(layout.first), ...collectWindowIds(layout.second)];
|
||||
return [
|
||||
...collectWindowIds(layout.first),
|
||||
...collectWindowIds(layout.second),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -176,7 +204,7 @@ export function collectWindowIds(
|
||||
*/
|
||||
export function applyPresetToLayout(
|
||||
currentLayout: MosaicNode<string> | null,
|
||||
preset: LayoutPreset
|
||||
preset: LayoutPreset,
|
||||
): MosaicNode<string> {
|
||||
// Collect all window IDs from current layout
|
||||
const windowIds = collectWindowIds(currentLayout);
|
||||
@@ -184,14 +212,14 @@ export function applyPresetToLayout(
|
||||
// Check minimum requirement
|
||||
if (windowIds.length < preset.minSlots) {
|
||||
throw new Error(
|
||||
`Preset "${preset.name}" requires at least ${preset.minSlots} windows but only ${windowIds.length} available`
|
||||
`Preset "${preset.name}" requires at least ${preset.minSlots} windows but only ${windowIds.length} available`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check maximum limit if defined
|
||||
if (preset.maxSlots && windowIds.length > preset.maxSlots) {
|
||||
throw new Error(
|
||||
`Preset "${preset.name}" supports maximum ${preset.maxSlots} windows but ${windowIds.length} available`
|
||||
`Preset "${preset.name}" supports maximum ${preset.maxSlots} windows but ${windowIds.length} available`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { MosaicNode } from "react-mosaic-component";
|
||||
import {
|
||||
analyzeLayoutStats,
|
||||
insertWindow,
|
||||
type LayoutStats,
|
||||
} from "./layout-utils";
|
||||
import { analyzeLayoutStats, insertWindow } from "./layout-utils";
|
||||
import type { LayoutConfig } from "@/types/app";
|
||||
|
||||
describe("analyzeLayoutStats", () => {
|
||||
|
||||
@@ -32,7 +32,7 @@ export interface LeafInfo {
|
||||
* Used by smart direction algorithm to balance splits
|
||||
*/
|
||||
export function analyzeLayoutStats(
|
||||
node: MosaicNode<string> | null
|
||||
node: MosaicNode<string> | null,
|
||||
): LayoutStats {
|
||||
if (node === null) {
|
||||
return { rowSplits: 0, columnSplits: 0, depth: 0, windowCount: 0 };
|
||||
@@ -67,7 +67,7 @@ export function analyzeLayoutStats(
|
||||
export function findAllLeaves(
|
||||
node: MosaicNode<string> | null,
|
||||
depth: number = 0,
|
||||
parentDirection: "row" | "column" | null = null
|
||||
parentDirection: "row" | "column" | null = null,
|
||||
): LeafInfo[] {
|
||||
if (node === null) {
|
||||
return [];
|
||||
@@ -90,7 +90,7 @@ export function findAllLeaves(
|
||||
* If multiple leaves at same depth, returns first one encountered
|
||||
*/
|
||||
export function findShallowstLeaf(
|
||||
node: MosaicNode<string> | null
|
||||
node: MosaicNode<string> | null,
|
||||
): LeafInfo | null {
|
||||
const leaves = findAllLeaves(node);
|
||||
|
||||
@@ -120,7 +120,7 @@ export function replaceLeafWithSplit(
|
||||
newWindowId: string,
|
||||
direction: "row" | "column",
|
||||
splitPercentage: number,
|
||||
position: "first" | "second" = "second"
|
||||
position: "first" | "second" = "second",
|
||||
): MosaicNode<string> | null {
|
||||
if (node === null) return null;
|
||||
|
||||
@@ -151,7 +151,7 @@ export function replaceLeafWithSplit(
|
||||
newWindowId,
|
||||
direction,
|
||||
splitPercentage,
|
||||
position
|
||||
position,
|
||||
);
|
||||
const newSecond = replaceLeafWithSplit(
|
||||
node.second,
|
||||
@@ -159,7 +159,7 @@ export function replaceLeafWithSplit(
|
||||
newWindowId,
|
||||
direction,
|
||||
splitPercentage,
|
||||
position
|
||||
position,
|
||||
);
|
||||
|
||||
// Return new branch with potentially updated children
|
||||
@@ -177,7 +177,7 @@ export function replaceLeafWithSplit(
|
||||
* This creates a checkerboard pattern for more balanced layouts
|
||||
*/
|
||||
export function calculateBalancedDirection(
|
||||
parentDirection: "row" | "column" | null
|
||||
parentDirection: "row" | "column" | null,
|
||||
): "row" | "column" {
|
||||
if (parentDirection === null) {
|
||||
return "row"; // Default to horizontal for first split
|
||||
@@ -203,7 +203,7 @@ export function calculateBalancedDirection(
|
||||
export function insertWindow(
|
||||
currentLayout: MosaicNode<string> | null,
|
||||
newWindowId: string,
|
||||
config: LayoutConfig
|
||||
config: LayoutConfig,
|
||||
): MosaicNode<string> {
|
||||
// First window - just return the window ID as leaf node
|
||||
if (currentLayout === null) {
|
||||
@@ -236,7 +236,7 @@ export function insertWindow(
|
||||
newWindowId,
|
||||
direction,
|
||||
config.splitPercentage,
|
||||
config.insertionPosition
|
||||
config.insertionPosition,
|
||||
);
|
||||
|
||||
return newLayout || newWindowId; // Fallback if replacement failed
|
||||
|
||||
@@ -35,19 +35,22 @@ describe("migrations", () => {
|
||||
expect(migrated.workspaces.ws2.number).toBe(2);
|
||||
expect(migrated.workspaces.ws2.label).toBeUndefined();
|
||||
|
||||
// v7→v8→v9: layoutConfig moved to global state
|
||||
expect(migrated.layoutConfig).toEqual({
|
||||
// v7→v8→v9: layoutConfig added to each workspace
|
||||
expect(migrated.workspaces.ws1.layoutConfig).toEqual({
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
autoPreset: undefined,
|
||||
});
|
||||
expect(migrated.workspaces.ws2.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 global layoutConfig", () => {
|
||||
it("should convert non-numeric labels to number with label and add per-workspace layoutConfig", () => {
|
||||
const oldState = {
|
||||
__version: 6,
|
||||
windows: {},
|
||||
@@ -78,13 +81,12 @@ describe("migrations", () => {
|
||||
expect(migrated.workspaces.ws2.number).toBe(2);
|
||||
expect(migrated.workspaces.ws2.label).toBe("Development");
|
||||
|
||||
// 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();
|
||||
// v7→v8→v9: layoutConfig added to each workspace
|
||||
expect(migrated.workspaces.ws1.layoutConfig).toBeDefined();
|
||||
expect(migrated.workspaces.ws2.layoutConfig).toBeDefined();
|
||||
});
|
||||
|
||||
it("should handle mixed numeric and text labels and add global layoutConfig", () => {
|
||||
it("should handle mixed numeric and text labels and add per-workspace layoutConfig", () => {
|
||||
const oldState = {
|
||||
__version: 6,
|
||||
windows: {},
|
||||
@@ -123,11 +125,10 @@ describe("migrations", () => {
|
||||
expect(migrated.workspaces.ws3.number).toBe(3);
|
||||
expect(migrated.workspaces.ws3.label).toBeUndefined();
|
||||
|
||||
// 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();
|
||||
// v7→v8→v9: layoutConfig added to each workspace
|
||||
expect(migrated.workspaces.ws1.layoutConfig).toBeDefined();
|
||||
expect(migrated.workspaces.ws2.layoutConfig).toBeDefined();
|
||||
expect(migrated.workspaces.ws3.layoutConfig).toBeDefined();
|
||||
});
|
||||
|
||||
it("should validate migrated state", () => {
|
||||
@@ -151,7 +152,7 @@ describe("migrations", () => {
|
||||
});
|
||||
|
||||
describe("v8 to v9 migration", () => {
|
||||
it("should move layoutConfig from workspaces to global state", () => {
|
||||
it("should preserve per-workspace layoutConfig", () => {
|
||||
const v8State = {
|
||||
__version: 8,
|
||||
windows: {
|
||||
@@ -177,7 +178,12 @@ describe("migrations", () => {
|
||||
id: "ws2",
|
||||
number: 2,
|
||||
label: "Development",
|
||||
layout: { direction: "row", first: "w1", second: "w2", splitPercentage: 50 },
|
||||
layout: {
|
||||
direction: "row",
|
||||
first: "w1",
|
||||
second: "w2",
|
||||
splitPercentage: 50,
|
||||
},
|
||||
windowIds: ["w1", "w2"],
|
||||
layoutConfig: {
|
||||
insertionMode: "row",
|
||||
@@ -193,17 +199,19 @@ describe("migrations", () => {
|
||||
|
||||
expect(migrated.__version).toBe(9);
|
||||
|
||||
// layoutConfig should be at global level (from first workspace)
|
||||
expect(migrated.layoutConfig).toEqual({
|
||||
// layoutConfig should remain per-workspace
|
||||
expect(migrated.workspaces.ws1.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();
|
||||
expect(migrated.workspaces.ws2.layoutConfig).toEqual({
|
||||
insertionMode: "row",
|
||||
splitPercentage: 70,
|
||||
insertionPosition: "first",
|
||||
autoPreset: undefined,
|
||||
});
|
||||
|
||||
// Other fields should be preserved
|
||||
expect(migrated.workspaces.ws2.label).toBe("Development");
|
||||
|
||||
@@ -73,8 +73,9 @@ const migrations: Record<number, MigrationFn> = {
|
||||
|
||||
// Add default layoutConfig to each workspace
|
||||
for (const [id, workspace] of Object.entries(state.workspaces || {})) {
|
||||
const ws = workspace as Record<string, any>;
|
||||
migratedWorkspaces[id] = {
|
||||
...workspace,
|
||||
...ws,
|
||||
layoutConfig: {
|
||||
insertionMode: "smart", // New smart default (auto-balance)
|
||||
splitPercentage: 50, // Matches old 50/50 behavior
|
||||
@@ -90,30 +91,28 @@ const migrations: Record<number, MigrationFn> = {
|
||||
workspaces: migratedWorkspaces,
|
||||
};
|
||||
},
|
||||
// Migration from v8 to v9 - moves layoutConfig from per-workspace to global
|
||||
// Migration from v8 to v9 - preserve per-workspace layoutConfig
|
||||
8: (state: any) => {
|
||||
// Ensure all workspaces have layoutConfig (add default if missing)
|
||||
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;
|
||||
const ws = workspace as Record<string, any>;
|
||||
migratedWorkspaces[id] = {
|
||||
...ws,
|
||||
layoutConfig: ws.layoutConfig || {
|
||||
insertionMode: "smart",
|
||||
splitPercentage: 50,
|
||||
insertionPosition: "second",
|
||||
autoPreset: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
__version: 9,
|
||||
workspaces: migratedWorkspaces,
|
||||
layoutConfig, // Move to global state
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -134,7 +133,6 @@ export function validateState(state: any): state is GrimoireState {
|
||||
!state.windows ||
|
||||
!state.workspaces ||
|
||||
!state.activeWorkspaceId ||
|
||||
!state.layoutConfig ||
|
||||
typeof state.__version !== "number"
|
||||
) {
|
||||
return false;
|
||||
@@ -156,11 +154,17 @@ export function validateState(state: any): state is GrimoireState {
|
||||
}
|
||||
|
||||
// All window IDs in workspaces must exist in windows
|
||||
// Each workspace must have layoutConfig
|
||||
for (const workspace of Object.values(state.workspaces)) {
|
||||
if (!Array.isArray((workspace as any).windowIds)) {
|
||||
const ws = workspace as any;
|
||||
if (!Array.isArray(ws.windowIds)) {
|
||||
return false;
|
||||
}
|
||||
for (const windowId of (workspace as any).windowIds) {
|
||||
// Verify workspace has layoutConfig
|
||||
if (!ws.layoutConfig || typeof ws.layoutConfig !== "object") {
|
||||
return false;
|
||||
}
|
||||
for (const windowId of ws.windowIds) {
|
||||
if (!state.windows[windowId]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface Workspace {
|
||||
label?: string; // Optional user-editable label
|
||||
layout: MosaicNode<string> | null;
|
||||
windowIds: string[];
|
||||
layoutConfig: LayoutConfig; // Per-workspace configuration for window insertion
|
||||
}
|
||||
|
||||
export interface RelayInfo {
|
||||
@@ -83,7 +84,6 @@ 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