Files
kandev/apps/web/components/task/layout-preset-selector.tsx
T
2026-08-02 20:14:53 +01:00

449 lines
14 KiB
TypeScript

"use client";
import { useCallback, useRef, useState } from "react";
import { toast } from "@/lib/toast/sonner";
import {
IconLayout,
IconCheck,
IconDeviceFloppy,
IconColumns3,
IconFileText,
IconDeviceDesktop,
IconBrandVscode,
IconRestore,
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
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 } from "@/lib/state/dockview-store";
import {
BUILT_IN_LAYOUT_PROFILES,
createLayoutProfile,
createLayoutProfileId,
deleteLayoutProfile,
getBuiltInLayoutOverride,
getLayoutProfileCompatibility,
isBuiltInLayoutOverride,
type BuiltInLayoutProfileId,
} from "@/lib/layout/layout-profiles";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { updateUserSettings } from "@/lib/api/domains/settings-api";
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import type { SavedLayout } from "@/lib/types/http";
import { useTaskSessions } from "@/hooks/use-task-sessions";
import { resolveLayoutApplySessionIds } from "./layout-preset-selector-session-ids";
type PresetOption = {
id: BuiltInLayoutProfileId;
label: string;
description: string;
icon: React.ElementType;
};
const PRESET_ICONS: Record<BuiltInLayoutProfileId, React.ElementType> = {
default: IconColumns3,
plan: IconFileText,
preview: IconDeviceDesktop,
vscode: IconBrandVscode,
};
const BUILT_IN_PRESETS: PresetOption[] = BUILT_IN_LAYOUT_PROFILES.map((profile) => ({
id: profile.id,
label: profile.name,
description: profile.description,
icon: PRESET_ICONS[profile.id],
}));
function mutationError(error: unknown): string {
return error instanceof Error ? error.message : "Please try again.";
}
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 setUserSettings = useAppStore((s) => s.setUserSettings);
const handleSave = useCallback(async () => {
const trimmed = name.trim();
if (!trimmed) return;
setSaving(true);
try {
const layout = captureCurrentLayout();
const response = await updateUserSettings({
saved_layouts: createLayoutProfile(savedLayouts, {
id: createLayoutProfileId(),
name: trimmed,
isDefault,
layout,
createdAt: new Date().toISOString(),
}),
});
setUserSettings(mapUserSettingsResponse(response));
setName("");
setIsDefault(false);
onOpenChange(false);
} catch (error) {
toast.error("Failed to save layout", { description: mutationError(error) });
} finally {
setSaving(false);
}
}, [name, isDefault, captureCurrentLayout, savedLayouts, setUserSettings, 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
/>
</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 | Promise<void>;
onDelete: (layout: SavedLayout) => 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) => (
<div key={layout.id} className="flex items-stretch" role="presentation">
<DropdownMenuItem
className="min-w-0 flex-1 cursor-pointer"
onSelect={() => void onApply(layout)}
>
<IconCheck className="mr-2 h-3.5 w-3.5 shrink-0 opacity-0" />
<span className="flex-1 truncate text-xs">{layout.name}</span>
{layout.is_default && (
<Badge variant="secondary" className="ml-1 px-1 py-0 text-[9px]">
default
</Badge>
)}
</DropdownMenuItem>
<DropdownMenuItem
className="min-h-11 min-w-11 shrink-0 cursor-pointer justify-center px-2 text-destructive/60 focus:text-destructive sm:min-h-7 sm:min-w-7"
aria-label={`Delete ${layout.name}`}
data-testid="layout-saved-delete"
data-layout-id={layout.id}
onSelect={() => onDelete(layout)}
>
<IconTrash className="h-3.5 w-3.5" />
</DropdownMenuItem>
</div>
));
}
/** Built-in preset menu items. Code-defined presets reset widths; customized
* built-ins restore the dimensions saved in their override. */
function BuiltInPresetItems({ onApply }: { onApply: (presetId: BuiltInLayoutProfileId) => void }) {
return BUILT_IN_PRESETS.map((preset) => {
const Icon = preset.icon;
return (
<DropdownMenuItem
key={preset.id}
data-testid="layout-preset-item"
data-preset-id={preset.id}
onClick={() => onApply(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>
);
});
}
function useApplySavedLayout() {
const applyCustomLayout = useDockviewStore((s) => s.applyCustomLayout);
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const activeSessionId = useAppStore((s) => s.tasks.activeSessionId);
const appStore = useAppStoreApi();
const { isLoaded: taskSessionsLoaded, loadSessions } = useTaskSessions(activeTaskId);
return useCallback(
async (layout: SavedLayout) => {
const sessionIds = await resolveLayoutApplySessionIds({
activeTaskId,
activeSessionId,
sessionsLoaded: taskSessionsLoaded,
loadSessions,
getSessionsForTask: (taskId) =>
appStore.getState().taskSessionsByTask.itemsByTaskId[taskId] ?? [],
});
applyCustomLayout(
{
id: layout.id,
name: layout.name,
isDefault: layout.is_default,
layout: layout.layout,
createdAt: layout.created_at,
},
{ activeSessionId, sessionIds },
);
},
[activeSessionId, activeTaskId, appStore, applyCustomLayout, loadSessions, taskSessionsLoaded],
);
}
function useApplyBuiltInLayout(
savedLayouts: SavedLayout[],
applySavedLayout: (layout: SavedLayout) => Promise<void>,
) {
const applyBuiltInPreset = useDockviewStore((s) => s.applyBuiltInPreset);
return useCallback(
(presetId: BuiltInLayoutProfileId) => {
const override = getBuiltInLayoutOverride(savedLayouts, presetId);
if (override && getLayoutProfileCompatibility(override).status === "editable") {
void applySavedLayout(override);
return;
}
applyBuiltInPreset(presetId, true);
},
[applyBuiltInPreset, applySavedLayout, savedLayouts],
);
}
function DeleteLayoutDialog({
layout,
onClose,
onDelete,
}: {
layout: SavedLayout | null;
onClose: () => void;
onDelete: (layoutId: string) => Promise<void>;
}) {
const [deleting, setDeleting] = useState(false);
const handleDelete = async () => {
if (!layout) return;
setDeleting(true);
try {
await onDelete(layout.id);
onClose();
} catch (error) {
toast.error("Failed to delete layout", { description: mutationError(error) });
} finally {
setDeleting(false);
}
};
return (
<AlertDialog open={Boolean(layout)} onOpenChange={(open) => !open && !deleting && onClose()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {layout?.name ?? "saved layout"}?</AlertDialogTitle>
<AlertDialogDescription>
{layout?.is_default
? "The built-in Default layout will become the default."
: "This saved layout will be permanently removed."}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer" disabled={deleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer"
disabled={deleting}
onClick={(event) => {
event.preventDefault();
void handleDelete();
}}
>
{deleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function LayoutPresetSelector() {
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const [deleteCandidate, setDeleteCandidate] = useState<SavedLayout | null>(null);
const recentlyClosedRef = useRef(false);
const resetLayout = useDockviewStore((s) => s.resetLayout);
const savedLayouts = useAppStore((s) => s.userSettings.savedLayouts);
const setUserSettings = useAppStore((s) => s.setUserSettings);
const handleApplyCustom = useApplySavedLayout();
const handleApplyBuiltIn = useApplyBuiltInLayout(savedLayouts, handleApplyCustom);
const handleDeleteLayout = useCallback(
async (layoutId: string) => {
const response = await updateUserSettings({
saved_layouts: deleteLayoutProfile(savedLayouts, layoutId),
});
setUserSettings(mapUserSettingsResponse(response));
},
[savedLayouts, setUserSettings],
);
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"
data-testid="layout-preset-trigger"
>
<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>
<BuiltInPresetItems onApply={handleApplyBuiltIn} />
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
data-testid="layout-reset-item"
onClick={resetLayout}
className="cursor-pointer"
>
<IconRestore className="h-4 w-4 mr-2 shrink-0" />
<span className="text-xs">Reset Layout</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuLabel className="text-xs">Saved Layouts</DropdownMenuLabel>
<SavedLayoutItems
layouts={savedLayouts.filter((layout) => !isBuiltInLayoutOverride(layout))}
onApply={handleApplyCustom}
onDelete={setDeleteCandidate}
/>
</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} />
<DeleteLayoutDialog
layout={deleteCandidate}
onClose={() => setDeleteCandidate(null)}
onDelete={handleDeleteLayout}
/>
</>
);
}