mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-10 07:27:23 +02:00
feat: add layout presets system with /layout command
Phase 3 implementation: - Created layout-presets.ts with 3 built-in presets (side-by-side, main-sidebar, grid) - Implemented fillLayoutTemplate() for recursive template filling with window IDs - Added collectWindowIds() for depth-first traversal of layout trees - Created applyPresetToLayout() to reorganize existing windows - Created layout-parser.ts for /layout command argument parsing - Added layout command to man.ts with documentation and examples - Built LayoutViewer component with: * Visual preset gallery with diagrams * Window count validation * Apply preset functionality * Error handling for insufficient windows * Command-line preset specification support - Wired LayoutViewer into WindowRenderer with lazy loading - Added "layout" to AppId type definition - Exposed applyPresetLayout in useGrimoire hook Presets allow users to quickly reorganize multiple windows into common layouts: 50/50 splits, 70/30 main+sidebar, or 2×2 grids. Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
216
src/components/LayoutViewer.tsx
Normal file
216
src/components/LayoutViewer.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import { useState } from "react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { getAllPresets } from "@/lib/layout-presets";
|
||||
import type { LayoutPreset } from "@/lib/layout-presets";
|
||||
import { Button } from "./ui/button";
|
||||
import { Grid2X2, Columns2, Split, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface LayoutViewerProps {
|
||||
presetId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LAYOUT viewer - displays available layout presets and allows applying them
|
||||
*/
|
||||
export function LayoutViewer({ presetId, error }: LayoutViewerProps) {
|
||||
const { state, applyPresetLayout } = useGrimoire();
|
||||
const activeWorkspace = state.workspaces[state.activeWorkspaceId];
|
||||
const windowCount = activeWorkspace.windowIds.length;
|
||||
const presets = getAllPresets();
|
||||
const [applying, setApplying] = useState<string | null>(null);
|
||||
|
||||
// If a preset was specified via command line, show error or success
|
||||
const specifiedPreset = presetId
|
||||
? presets.find((p) => p.id === presetId)
|
||||
: null;
|
||||
|
||||
const handleApplyPreset = async (preset: LayoutPreset) => {
|
||||
if (windowCount < preset.slots) {
|
||||
toast.error(`Not enough windows`, {
|
||||
description: `Preset "${preset.name}" requires ${preset.slots} windows, but only ${windowCount} available.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setApplying(preset.id);
|
||||
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",
|
||||
});
|
||||
} finally {
|
||||
setApplying(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getPresetIcon = (presetId: string) => {
|
||||
switch (presetId) {
|
||||
case "side-by-side":
|
||||
return <Columns2 className="h-8 w-8" />;
|
||||
case "main-sidebar":
|
||||
return <Split className="h-8 w-8" />;
|
||||
case "grid":
|
||||
return <Grid2X2 className="h-8 w-8" />;
|
||||
default:
|
||||
return <Grid2X2 className="h-8 w-8" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPresetDiagram = (preset: LayoutPreset) => {
|
||||
// Visual representation of the layout
|
||||
switch (preset.id) {
|
||||
case "side-by-side":
|
||||
return (
|
||||
<div className="flex gap-2 h-16">
|
||||
<div className="flex-1 border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
<div className="flex-1 border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
</div>
|
||||
);
|
||||
case "main-sidebar":
|
||||
return (
|
||||
<div className="flex gap-2 h-16">
|
||||
<div className="flex-[7] border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
<div className="flex-[3] border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
</div>
|
||||
);
|
||||
case "grid":
|
||||
return (
|
||||
<div className="grid grid-cols-2 grid-rows-2 gap-2 h-16">
|
||||
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
<div className="border-2 border-muted-foreground/30 rounded bg-muted/20" />
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col bg-background text-foreground">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold mb-2">Layout Presets</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Apply preset layouts to reorganize windows in workspace{" "}
|
||||
{activeWorkspace.number}
|
||||
</p>
|
||||
<div className="mt-2 text-sm">
|
||||
<span className="text-muted-foreground">Current windows: </span>
|
||||
<span className="font-semibold">{windowCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command-line specified preset with error */}
|
||||
{error && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-destructive/10 border border-destructive/50 flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-destructive mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-destructive mb-1">
|
||||
Command Error
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{error}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Command-line specified preset (valid) */}
|
||||
{specifiedPreset && !error && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-accent/10 border border-accent/50 flex items-start gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-accent-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold mb-1">
|
||||
Preset: {specifiedPreset.name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground mb-3">
|
||||
{specifiedPreset.description}
|
||||
</div>
|
||||
{windowCount < specifiedPreset.slots ? (
|
||||
<div className="text-sm text-destructive">
|
||||
⚠️ Not enough windows (requires {specifiedPreset.slots}, have{" "}
|
||||
{windowCount})
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleApplyPreset(specifiedPreset)}
|
||||
size="sm"
|
||||
disabled={applying === specifiedPreset.id}
|
||||
>
|
||||
{applying === specifiedPreset.id
|
||||
? "Applying..."
|
||||
: "Apply Preset"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preset Gallery */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{presets.map((preset) => {
|
||||
const canApply = windowCount >= preset.slots;
|
||||
const isApplying = applying === preset.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={preset.id}
|
||||
className={`p-4 rounded-lg border transition-colors ${
|
||||
canApply
|
||||
? "border-border hover:border-accent/50 hover:bg-accent/5"
|
||||
: "border-muted-foreground/20 opacity-60"
|
||||
}`}
|
||||
>
|
||||
{/* Icon and Title */}
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="text-muted-foreground">
|
||||
{getPresetIcon(preset.id)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-semibold">{preset.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{preset.slots} windows
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{preset.description}
|
||||
</p>
|
||||
|
||||
{/* Visual Diagram */}
|
||||
<div className="mb-3">{getPresetDiagram(preset)}</div>
|
||||
|
||||
{/* Apply Button */}
|
||||
{canApply ? (
|
||||
<Button
|
||||
onClick={() => handleApplyPreset(preset)}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isApplying}
|
||||
>
|
||||
{isApplying ? "Applying..." : "Apply"}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground text-center py-2">
|
||||
Requires {preset.slots} windows (have {windowCount})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,9 @@ const DebugViewer = lazy(() =>
|
||||
import("./DebugViewer").then((m) => ({ default: m.DebugViewer })),
|
||||
);
|
||||
const ConnViewer = lazy(() => import("./ConnViewer"));
|
||||
const LayoutViewer = lazy(() =>
|
||||
import("./LayoutViewer").then((m) => ({ default: m.LayoutViewer })),
|
||||
);
|
||||
|
||||
// Loading fallback component
|
||||
function ViewerLoading() {
|
||||
@@ -162,6 +165,14 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
|
||||
case "conn":
|
||||
content = <ConnViewer />;
|
||||
break;
|
||||
case "layout":
|
||||
content = (
|
||||
<LayoutViewer
|
||||
presetId={window.props.presetId}
|
||||
error={window.props.error}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import type { MosaicNode } from "react-mosaic-component";
|
||||
import { GrimoireState, WindowInstance, UserRelays } from "@/types/app";
|
||||
import { insertWindow } from "@/lib/layout-utils";
|
||||
import { applyPresetToLayout, type LayoutPreset } from "@/lib/layout-presets";
|
||||
|
||||
/**
|
||||
* Finds the lowest available workspace number.
|
||||
@@ -373,3 +374,35 @@ export const updateWorkspaceLayoutConfig = (
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Applies a preset layout to the active workspace.
|
||||
* Reorganizes existing windows according to the preset template.
|
||||
*/
|
||||
export const applyPresetLayout = (
|
||||
state: GrimoireState,
|
||||
preset: LayoutPreset,
|
||||
): GrimoireState => {
|
||||
const activeId = state.activeWorkspaceId;
|
||||
const ws = state.workspaces[activeId];
|
||||
|
||||
try {
|
||||
// Apply preset to current layout
|
||||
const newLayout = applyPresetToLayout(ws.layout, preset);
|
||||
|
||||
return {
|
||||
...state,
|
||||
workspaces: {
|
||||
...state.workspaces,
|
||||
[activeId]: {
|
||||
...ws,
|
||||
layout: newLayout,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// If preset application fails (not enough windows, etc.), return unchanged
|
||||
console.error("[Layout] Failed to apply preset:", error);
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -240,6 +240,12 @@ export const useGrimoire = () => {
|
||||
[setState],
|
||||
);
|
||||
|
||||
const applyPresetLayout = useCallback(
|
||||
(preset: any) =>
|
||||
setState((prev) => Logic.applyPresetLayout(prev, preset)),
|
||||
[setState],
|
||||
);
|
||||
|
||||
return {
|
||||
state,
|
||||
locale: state.locale || browserLocale,
|
||||
@@ -254,5 +260,6 @@ export const useGrimoire = () => {
|
||||
setActiveAccount,
|
||||
setActiveAccountRelays,
|
||||
updateWorkspaceLayoutConfig,
|
||||
applyPresetLayout,
|
||||
};
|
||||
};
|
||||
|
||||
39
src/lib/layout-parser.ts
Normal file
39
src/lib/layout-parser.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { getPreset, getAllPresets } from "./layout-presets";
|
||||
|
||||
export interface LayoutCommandResult {
|
||||
/** The preset ID to apply, or undefined to show preset list */
|
||||
presetId?: string;
|
||||
/** Error message if parsing failed */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the /layout command arguments
|
||||
*
|
||||
* Usage:
|
||||
* /layout - Show all available presets
|
||||
* /layout side-by-side - Apply the side-by-side preset
|
||||
* /layout grid - Apply the grid preset
|
||||
*/
|
||||
export function parseLayoutCommand(args: string[]): LayoutCommandResult {
|
||||
// No arguments - show preset list
|
||||
if (args.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Get the preset ID (first argument)
|
||||
const presetId = args[0].toLowerCase();
|
||||
|
||||
// Validate preset exists
|
||||
const preset = getPreset(presetId);
|
||||
if (!preset) {
|
||||
const availablePresets = getAllPresets()
|
||||
.map((p) => p.id)
|
||||
.join(", ");
|
||||
return {
|
||||
error: `Unknown preset "${presetId}". Available presets: ${availablePresets}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { presetId };
|
||||
}
|
||||
166
src/lib/layout-presets.ts
Normal file
166
src/lib/layout-presets.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { MosaicNode } from "react-mosaic-component";
|
||||
|
||||
/**
|
||||
* A layout preset template with null values that get filled with window IDs
|
||||
*/
|
||||
export interface LayoutPreset {
|
||||
/** Unique identifier for the preset */
|
||||
id: string;
|
||||
/** Display name for the preset */
|
||||
name: string;
|
||||
/** Description of the layout arrangement */
|
||||
description: string;
|
||||
/** Template structure with null values to be replaced by window IDs */
|
||||
template: MosaicNode<null>;
|
||||
/** Number of windows required for this preset */
|
||||
slots: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Built-in layout presets
|
||||
*/
|
||||
export const BUILT_IN_PRESETS: Record<string, LayoutPreset> = {
|
||||
"side-by-side": {
|
||||
id: "side-by-side",
|
||||
name: "Side by Side",
|
||||
description: "Two windows side-by-side (50/50 horizontal split)",
|
||||
template: {
|
||||
direction: "row",
|
||||
first: null,
|
||||
second: null,
|
||||
splitPercentage: 50,
|
||||
},
|
||||
slots: 2,
|
||||
},
|
||||
|
||||
"main-sidebar": {
|
||||
id: "main-sidebar",
|
||||
name: "Main + Sidebar",
|
||||
description: "Large main window with sidebar (70/30 horizontal split)",
|
||||
template: {
|
||||
direction: "row",
|
||||
first: null,
|
||||
second: null,
|
||||
splitPercentage: 70,
|
||||
},
|
||||
slots: 2,
|
||||
},
|
||||
|
||||
grid: {
|
||||
id: "grid",
|
||||
name: "Grid",
|
||||
description: "Four windows in 2×2 grid layout",
|
||||
template: {
|
||||
direction: "row",
|
||||
first: {
|
||||
direction: "column",
|
||||
first: null,
|
||||
second: null,
|
||||
splitPercentage: 50,
|
||||
},
|
||||
second: {
|
||||
direction: "column",
|
||||
first: null,
|
||||
second: null,
|
||||
splitPercentage: 50,
|
||||
},
|
||||
splitPercentage: 50,
|
||||
},
|
||||
slots: 4,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills a layout template with actual window IDs
|
||||
* Uses depth-first traversal to assign window IDs to null slots
|
||||
*/
|
||||
export function fillLayoutTemplate(
|
||||
template: MosaicNode<null>,
|
||||
windowIds: string[]
|
||||
): MosaicNode<string> {
|
||||
let windowIndex = 0;
|
||||
|
||||
const fill = (node: MosaicNode<null>): MosaicNode<string> => {
|
||||
// Leaf node - replace null with next window ID
|
||||
if (node === null) {
|
||||
if (windowIndex >= windowIds.length) {
|
||||
throw new Error("Not enough window IDs to fill template");
|
||||
}
|
||||
return windowIds[windowIndex++];
|
||||
}
|
||||
|
||||
// Branch node - recursively fill children
|
||||
return {
|
||||
...node,
|
||||
first: fill(node.first),
|
||||
second: fill(node.second),
|
||||
};
|
||||
};
|
||||
|
||||
const result = fill(template);
|
||||
|
||||
// Verify all windows were used
|
||||
if (windowIndex !== windowIds.length) {
|
||||
throw new Error(
|
||||
`Template requires ${windowIndex} windows but ${windowIds.length} were provided`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects window IDs from a layout tree in depth-first order
|
||||
*/
|
||||
export function collectWindowIds(
|
||||
layout: MosaicNode<string> | null
|
||||
): string[] {
|
||||
if (layout === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof layout === "string") {
|
||||
return [layout];
|
||||
}
|
||||
|
||||
return [...collectWindowIds(layout.first), ...collectWindowIds(layout.second)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a preset layout to existing windows
|
||||
* Takes the first N windows from the current layout and arranges them according to the preset
|
||||
*/
|
||||
export function applyPresetToLayout(
|
||||
currentLayout: MosaicNode<string> | null,
|
||||
preset: LayoutPreset
|
||||
): MosaicNode<string> {
|
||||
// Collect all window IDs from current layout
|
||||
const windowIds = collectWindowIds(currentLayout);
|
||||
|
||||
// Check if we have enough windows
|
||||
if (windowIds.length < preset.slots) {
|
||||
throw new Error(
|
||||
`Preset "${preset.name}" requires ${preset.slots} windows but only ${windowIds.length} available`
|
||||
);
|
||||
}
|
||||
|
||||
// Take first N windows for the preset
|
||||
const windowsToUse = windowIds.slice(0, preset.slots);
|
||||
|
||||
// Fill template with window IDs
|
||||
return fillLayoutTemplate(preset.template, windowsToUse);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a preset by ID
|
||||
*/
|
||||
export function getPreset(presetId: string): LayoutPreset | undefined {
|
||||
return BUILT_IN_PRESETS[presetId];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available presets
|
||||
*/
|
||||
export function getAllPresets(): LayoutPreset[] {
|
||||
return Object.values(BUILT_IN_PRESETS);
|
||||
}
|
||||
@@ -15,7 +15,8 @@ export type AppId =
|
||||
| "decode"
|
||||
| "relay"
|
||||
| "debug"
|
||||
| "conn";
|
||||
| "conn"
|
||||
| "layout";
|
||||
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { AppId } from "./app";
|
||||
import { parseOpenCommand } from "@/lib/open-parser";
|
||||
import { parseProfileCommand } from "@/lib/profile-parser";
|
||||
import { parseRelayCommand } from "@/lib/relay-parser";
|
||||
import { parseLayoutCommand } from "@/lib/layout-parser";
|
||||
import { resolveNip05Batch } from "@/lib/nip05";
|
||||
|
||||
export interface ManPageEntry {
|
||||
@@ -457,4 +458,30 @@ export const manPages: Record<string, ManPageEntry> = {
|
||||
category: "System",
|
||||
defaultProps: {},
|
||||
},
|
||||
layout: {
|
||||
name: "layout",
|
||||
section: "1",
|
||||
synopsis: "layout [preset-name]",
|
||||
description:
|
||||
"Apply a preset layout to reorganize windows in the current workspace. Presets provide common layout arrangements like side-by-side splits, main+sidebar configurations, and grid layouts. Running without arguments shows all available presets.",
|
||||
options: [
|
||||
{
|
||||
flag: "[preset-name]",
|
||||
description: "Preset layout to apply (side-by-side, main-sidebar, grid)",
|
||||
},
|
||||
],
|
||||
examples: [
|
||||
"layout View all available presets",
|
||||
"layout side-by-side Apply 50/50 horizontal split (2 windows)",
|
||||
"layout main-sidebar Apply 70/30 horizontal split (2 windows)",
|
||||
"layout grid Apply 2×2 grid layout (4 windows)",
|
||||
],
|
||||
seeAlso: ["man"],
|
||||
appId: "layout",
|
||||
category: "System",
|
||||
argParser: (args: string[]) => {
|
||||
const result = parseLayoutCommand(args);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user