Files
kandev/apps/web/components/task/layout-preset-selector.tsx
T

313 lines
9.5 KiB
TypeScript

"use client";
import { useCallback, useRef, useState } from "react";
import {
IconLayout,
IconCheck,
IconDeviceFloppy,
IconColumns3,
IconFileText,
IconDeviceDesktop,
IconBrandVscode,
IconTrash,
} from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Checkbox } from "@kandev/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@kandev/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { Badge } from "@kandev/ui/badge";
import { useDockviewStore, type BuiltInPreset } from "@/lib/state/dockview-store";
import { useAppStore } from "@/components/state-provider";
import { updateUserSettings } from "@/lib/api/domains/settings-api";
import type { SavedLayout } from "@/lib/types/http";
type PresetOption = {
id: BuiltInPreset;
label: string;
description: string;
icon: React.ElementType;
};
const BUILT_IN_PRESETS: PresetOption[] = [
{
id: "default",
label: "Default",
description: "Sidebar + Agent + Changes/Files/Terminal",
icon: IconColumns3,
},
{
id: "plan",
label: "Plan Mode",
description: "Sidebar + Agent + Plan (side-by-side)",
icon: IconFileText,
},
{
id: "preview",
label: "Preview Mode",
description: "Sidebar + Agent + Browser (side-by-side)",
icon: IconDeviceDesktop,
},
{
id: "vscode",
label: "VS Code",
description: "Sidebar + Agent/VS Code (tabbed)",
icon: IconBrandVscode,
},
];
function SaveLayoutDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const [name, setName] = useState("");
const [isDefault, setIsDefault] = useState(false);
const [saving, setSaving] = useState(false);
const captureCurrentLayout = useDockviewStore((s) => s.captureCurrentLayout);
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
const handleSave = useCallback(async () => {
const trimmed = name.trim();
if (!trimmed) return;
setSaving(true);
try {
const layout = captureCurrentLayout();
const newLayout: SavedLayout = {
id: `layout-${Date.now()}`,
name: trimmed,
is_default: isDefault,
layout,
created_at: new Date().toISOString(),
};
// If this layout is the default, unset other defaults
const updated = savedLayouts.map((l) => (isDefault ? { ...l, is_default: false } : l));
await updateUserSettings({
saved_layouts: [...updated, newLayout],
});
setName("");
setIsDefault(false);
onOpenChange(false);
} finally {
setSaving(false);
}
}, [name, isDefault, captureCurrentLayout, savedLayouts, onOpenChange]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Save Current Layout</DialogTitle>
<DialogDescription>
Save your current panel arrangement as a reusable layout.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="layout-name">Name</Label>
<Input
id="layout-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My layout"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleSave();
}}
/>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="layout-default"
checked={isDefault}
onCheckedChange={(checked) => setIsDefault(checked === true)}
/>
<Label htmlFor="layout-default" className="text-sm font-normal cursor-pointer">
Set as default layout
</Label>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button
size="sm"
className="cursor-pointer"
onClick={handleSave}
disabled={!name.trim() || saving}
>
{saving ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function SavedLayoutItems({
layouts,
onApply,
onDelete,
}: {
layouts: SavedLayout[];
onApply: (layout: SavedLayout) => void;
onDelete: (layoutId: string) => void;
}) {
if (layouts.length === 0) {
return <div className="px-2 py-1.5 text-xs text-muted-foreground">No saved layouts</div>;
}
return layouts.map((layout) => (
<DropdownMenuItem
key={layout.id}
className="cursor-pointer group/layout"
onClick={() => onApply(layout)}
>
<IconCheck className="h-3.5 w-3.5 mr-2 shrink-0 opacity-0" />
<span className="text-xs truncate flex-1">{layout.name}</span>
{layout.is_default && (
<Badge variant="secondary" className="text-[9px] px-1 py-0 ml-1">
default
</Badge>
)}
<button
type="button"
className="ml-1 text-destructive/60 hover:text-destructive opacity-0 group-hover/layout:opacity-100 transition-opacity cursor-pointer"
onClick={(e) => {
e.stopPropagation();
onDelete(layout.id);
}}
>
<IconTrash className="h-3.5 w-3.5" />
</button>
</DropdownMenuItem>
));
}
export function LayoutPresetSelector() {
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const recentlyClosedRef = useRef(false);
const applyBuiltInPreset = useDockviewStore((s) => s.applyBuiltInPreset);
const applyCustomLayout = useDockviewStore((s) => s.applyCustomLayout);
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
const handleApplyCustom = useCallback(
(layout: SavedLayout) =>
applyCustomLayout({
id: layout.id,
name: layout.name,
isDefault: layout.is_default,
layout: layout.layout,
createdAt: layout.created_at,
}),
[applyCustomLayout],
);
const handleDeleteLayout = useCallback(
async (layoutId: string) => {
await updateUserSettings({ saved_layouts: savedLayouts.filter((l) => l.id !== layoutId) });
},
[savedLayouts],
);
const handleDropdownOpenChange = useCallback((open: boolean) => {
setDropdownOpen(open);
if (!open) {
recentlyClosedRef.current = true;
setTooltipOpen(false);
setTimeout(() => {
recentlyClosedRef.current = false;
}, 200);
}
}, []);
const handleTooltipOpenChange = useCallback(
(open: boolean) => {
if (open && (dropdownOpen || recentlyClosedRef.current)) return;
setTooltipOpen(open);
},
[dropdownOpen],
);
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
<Tooltip open={tooltipOpen} onOpenChange={handleTooltipOpenChange}>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="outline" className="cursor-pointer px-2">
<IconLayout className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Layout presets</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs">Presets</DropdownMenuLabel>
{BUILT_IN_PRESETS.map((preset) => {
const Icon = preset.icon;
return (
<DropdownMenuItem
key={preset.id}
onClick={() => applyBuiltInPreset(preset.id)}
className="cursor-pointer"
>
<Icon className="h-4 w-4 mr-2 shrink-0" />
<div className="flex flex-col min-w-0">
<span className="text-xs font-medium">{preset.label}</span>
<span className="text-[10px] text-muted-foreground truncate">
{preset.description}
</span>
</div>
</DropdownMenuItem>
);
})}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs">Saved Layouts</DropdownMenuLabel>
<SavedLayoutItems
layouts={savedLayouts}
onApply={handleApplyCustom}
onDelete={handleDeleteLayout}
/>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => setSaveDialogOpen(true)} className="cursor-pointer">
<IconDeviceFloppy className="h-4 w-4 mr-2 shrink-0" />
<span className="text-xs">Save current layout...</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<SaveLayoutDialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen} />
</>
);
}