import type { Environment, Workspace } from "@yaakapp-internal/models"; import { duplicateModel, patchModel } from "@yaakapp-internal/models"; import { atom, useAtomValue } from "jotai"; import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createSubEnvironmentAndActivate } from "../commands/createEnvironment"; import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace"; import { environmentsBreakdownAtom, useEnvironmentsBreakdown, } from "../hooks/useEnvironmentsBreakdown"; import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm"; import { fireAndForget } from "../lib/fireAndForget"; import { jotaiStore } from "../lib/jotai"; import { isBaseEnvironment, isSubEnvironment } from "../lib/model_util"; import { resolvedModelName } from "../lib/resolvedModelName"; import { showColorPicker } from "../lib/showColorPicker"; import { Banner } from "./core/Banner"; import type { ContextMenuProps, DropdownItem } from "./core/Dropdown"; import { Icon } from "./core/Icon"; import { IconButton } from "./core/IconButton"; import { IconTooltip } from "./core/IconTooltip"; import { InlineCode } from "./core/InlineCode"; import type { PairEditorHandle } from "./core/PairEditor"; import { SplitLayout } from "./core/SplitLayout"; import type { TreeNode } from "./core/tree/common"; import type { TreeHandle, TreeProps } from "./core/tree/Tree"; import { Tree } from "./core/tree/Tree"; import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator"; import { EnvironmentEditor } from "./EnvironmentEditor"; import { EnvironmentSharableTooltip } from "./EnvironmentSharableTooltip"; interface Props { initialEnvironmentId: string | null; setRef?: (ref: PairEditorHandle | null) => void; } type TreeModel = Environment | Workspace; export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) { const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown(); const [selectedEnvironmentId, setSelectedEnvironmentId] = useState( initialEnvironmentId ?? null, ); const selectedEnvironment = selectedEnvironmentId != null ? allEnvironments.find((e) => e.id === selectedEnvironmentId) : baseEnvironment; return ( ( )} secondSlot={() => (
{baseEnvironments.length > 1 ? (
There are multiple base environments for this workspace. Please delete the environments you no longer need.
) : ( )} {selectedEnvironment == null ? (
Failed to find selected environment {selectedEnvironmentId}
) : ( )}
)} /> ); } const sharableTooltip = ( ); function EnvironmentEditDialogSidebar({ selectedEnvironmentId, setSelectedEnvironmentId, }: { selectedEnvironmentId: string | null; setSelectedEnvironmentId: (id: string | null) => void; }) { const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? ""; const treeId = `environment.${activeWorkspaceId}.sidebar`; const treeRef = useRef(null); const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown(); // oxlint-disable-next-line react-hooks/exhaustive-deps useLayoutEffect(() => { if (selectedEnvironmentId == null) return; treeRef.current?.selectItem(selectedEnvironmentId); treeRef.current?.focus(); }, []); const handleDeleteEnvironment = useCallback( async (environment: Environment) => { await deleteModelWithConfirm(environment); if (selectedEnvironmentId === environment.id) { setSelectedEnvironmentId(baseEnvironment?.id ?? null); } }, [baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId], ); const actions = useMemo(() => { const enable = () => treeRef.current?.hasFocus() ?? false; const actions = { "sidebar.selected.rename": { enable, allowDefault: true, priority: 100, cb: async (items: TreeModel[]) => { const item = items[0]; if (items.length === 1 && item != null) { treeRef.current?.renameItem(item.id); } }, }, "sidebar.selected.delete": { priority: 100, enable, cb: (items: TreeModel[]) => deleteModelWithConfirm(items), }, "sidebar.selected.duplicate": { priority: 100, enable, cb: async (items: TreeModel[]) => { if (items.length === 1 && items[0]) { const item = items[0]; const newId = await duplicateModel(item); setSelectedEnvironmentId(newId); } else { await Promise.all(items.map(duplicateModel)); } }, }, } as const; return actions; }, [setSelectedEnvironmentId]); const hotkeys = useMemo["hotkeys"]>(() => ({ actions }), [actions]); const getContextMenu = useCallback( (items: TreeModel[]): ContextMenuProps["items"] => { const environment = items[0]; const addEnvironmentItem: DropdownItem = { label: "Create Sub Environment", leftSlot: , onSelect: async () => { await createSubEnvironment(); }, }; if (environment == null || environment.model !== "environment") { return [addEnvironmentItem]; } const singleEnvironment = items.length === 1; const canDeleteEnvironment = isSubEnvironment(environment) || (isBaseEnvironment(environment) && baseEnvironments.length > 1); const menuItems: DropdownItem[] = [ { label: "Rename", leftSlot: , hidden: isBaseEnvironment(environment) || !singleEnvironment, hotKeyAction: "sidebar.selected.rename", hotKeyLabelOnly: true, onSelect: async () => { // Not sure why this is needed, but without it the // edit input blurs immediately after opening. requestAnimationFrame(() => { fireAndForget(actions["sidebar.selected.rename"].cb(items)); }); }, }, { label: "Duplicate", leftSlot: , hidden: isBaseEnvironment(environment), hotKeyAction: "sidebar.selected.duplicate", hotKeyLabelOnly: true, onSelect: () => actions["sidebar.selected.duplicate"].cb(items), }, { label: environment.color ? "Change Color" : "Assign Color", leftSlot: , hidden: isBaseEnvironment(environment) || !singleEnvironment, onSelect: async () => showColorPicker(environment), }, { label: `Make ${environment.public ? "Private" : "Sharable"}`, leftSlot: , rightSlot: , hidden: items.length > 1, onSelect: async () => { await patchModel(environment, { public: !environment.public }); }, }, { color: "danger", label: "Delete", hotKeyAction: "sidebar.selected.delete", hotKeyLabelOnly: true, hidden: !canDeleteEnvironment, leftSlot: , onSelect: () => handleDeleteEnvironment(environment), }, ]; // Add sub environment to base environment if (isBaseEnvironment(environment) && singleEnvironment) { menuItems.push({ type: "separator" }); menuItems.push(addEnvironmentItem); } return menuItems; }, [actions, baseEnvironments.length, handleDeleteEnvironment], ); const handleDragEnd = useCallback(async function handleDragEnd({ items, children, insertAt, }: { items: TreeModel[]; children: TreeModel[]; insertAt: number; }) { const prev = children[insertAt - 1] as Exclude; const next = children[insertAt] as Exclude; const beforePriority = prev?.sortPriority ?? 0; const afterPriority = next?.sortPriority ?? 0; const shouldUpdateAll = afterPriority - beforePriority < 1; try { if (shouldUpdateAll) { // Add items to children at insertAt children.splice(insertAt, 0, ...items); await Promise.all(children.map((m, i) => patchModel(m, { sortPriority: i * 1000 }))); } else { const range = afterPriority - beforePriority; const increment = range / (items.length + 2); await Promise.all( items.map((m, i) => { const sortPriority = beforePriority + (i + 1) * increment; // Spread item sortPriority out over before/after range return patchModel(m, { sortPriority }); }), ); } } catch (e) { console.error(e); } }, []); const handleActivate = useCallback( (item: TreeModel) => { setSelectedEnvironmentId(item.id); }, [setSelectedEnvironmentId], ); const tree = useAtomValue(treeAtom); return ( ); } const treeAtom = atom | null>((get) => { const activeWorkspace = get(activeWorkspaceAtom); const { baseEnvironment, baseEnvironments, subEnvironments } = get(environmentsBreakdownAtom); if (activeWorkspace == null || baseEnvironment == null) return null; const root: TreeNode = { item: activeWorkspace, parent: null, children: [], depth: 0, }; for (const item of baseEnvironments) { root.children?.push({ item, parent: root, depth: 0, draggable: false, }); } const parent = root.children?.[0]; if (baseEnvironments.length <= 1 && parent != null) { parent.children = subEnvironments.map((item) => ({ item, parent, depth: 1, localDrag: true, })); } return root; }); function ItemLeftSlotInner({ item }: { item: TreeModel }) { const { baseEnvironments } = useEnvironmentsBreakdown(); return baseEnvironments.length > 1 ? ( ) : ( item.model === "environment" && item.color && ); } function ItemRightSlot({ item }: { item: TreeModel }) { const { baseEnvironments } = useEnvironmentsBreakdown(); return ( <> {item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && ( )} ); } function ItemInner({ item }: { item: TreeModel }) { return (
{item.model === "environment" && item.public ? (
{sharableTooltip}
) : ( )}
{resolvedModelName(item)}
); } async function createSubEnvironment() { const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom); if (baseEnvironment == null) return; const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment); return id; } function getEditOptions(item: TreeModel) { const options: ReturnType["getEditOptions"]>> = { defaultValue: item.name, placeholder: "Name", async onChange(item, name) { await patchModel(item, { name }); }, }; return options; }