import { type Folder, type ImportDestination, type ImportPlan, type ImportPlanItem, type ImportSource, type Workspace, } from "@yaakapp-internal/models"; import { HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui"; import { platform } from "@yaakapp-internal/platform"; import classNames from "classnames"; import { formatDistanceToNowStrict } from "date-fns"; import { useEffect, useMemo, useRef, useState } from "react"; import { pluralize, pluralizeCount } from "../lib/pluralize"; import { CommercialUseBanner } from "./CommercialUseBanner"; import { Button } from "./core/Button"; import { Checkbox } from "./core/Checkbox"; import type { CheckboxTreeNode } from "./core/CheckboxTree"; import { CheckboxTree } from "./core/CheckboxTree"; import { IconTooltip } from "./core/IconTooltip"; import { PlainInput } from "./core/PlainInput"; import { Select } from "./core/Select"; import { SegmentedControl } from "./core/SegmentedControl"; interface Props { currentWorkspace: Workspace | null; workspaces: Workspace[]; selectedFolder: Folder | null; planFile: (filePath: string, destination: ImportDestination) => Promise; planUrl: (url: string, destination: ImportDestination) => Promise; listSources: (workspaceId: string) => Promise; findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise; commit: (plan: ImportPlan) => Promise; cancel: () => void; onError: (err: unknown) => void; } /** * An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a * bare host like `example.com/openapi.json` still works (the backend defaults it to https). */ function isFilePath(value: string): boolean { return ( value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("~/") || value.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(value) ); } function fileName(path: string): string { return path.split(/[/\\]/).at(-1) || path; } /** * Loads the current workspace's linked sources before rendering the dialog, so the inner * component can construct its initial state (prefilled path, destination) in one pass instead of * patching it in with effects after the first paint. */ export function ImportDataDialog(props: Props) { const [initialSources, setInitialSources] = useState(null); const { currentWorkspace, listSources } = props; useEffect(() => { let cancelled = false; const load = currentWorkspace == null ? Promise.resolve([]) : listSources(currentWorkspace.id); load .then((sources) => { if (!cancelled) setInitialSources(sources); }) .catch(() => { if (!cancelled) setInitialSources([]); }); return () => { cancelled = true; }; }, [currentWorkspace, listSources]); if (initialSources == null) return null; return ; } function latestSource(sources: ImportSource[]): ImportSource | null { return sources.reduce( (latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest), null, ); } function LoadedImportDataDialog({ currentWorkspace, workspaces, selectedFolder, planFile, planUrl, listSources, findSourcesForOrigin, commit, cancel, onError, initialSources, }: Props & { initialSources: ImportSource[] }) { // A workspace with a linked source is probably being re-imported, so start from that source const prefill = latestSource(initialSources); const [isLoading, setIsLoading] = useState(false); const [plan, setPlan] = useState(null); const [items, setItems] = useState([]); // null means no explicit choice yet, so the default below applies const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>( null, ); const [otherWorkspaceId, setOtherWorkspaceId] = useState(null); const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null); const [linkedSources, setLinkedSources] = useState( prefill != null ? initialSources : [], ); const [originSources, setOriginSources] = useState( prefill != null ? [prefill] : [], ); // A file path or a URL. Both inputs write here, so there is only ever one thing to import const [source, setSource] = useState(prefill?.origin ?? null); const [forceUpdateKey, setForceUpdateKey] = useState(0); const [isHovering, setIsHovering] = useState(false); const ref = useRef(null); const trimmedSource = source?.trim() ?? ""; const filePath = isFilePath(trimmedSource) ? trimmedSource : null; const selectSource = (value: string) => { setSource(value); // Remount the input so it shows the path of the newly-picked file setForceUpdateKey((k) => k + 1); }; // Accept a file dropped anywhere on the dialog, the way SelectFile does for its button useEffect(() => { return platform.window.onDragDrop((event) => { if (event.type === "over") { const p = event.position; const r = ref.current?.getBoundingClientRect(); if (r == null) return; setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom); } else if (event.type === "drop" && isHovering) { const p = event.paths[0]; if (p) selectSource(p); setIsHovering(false); } else { setIsHovering(false); } }); }, [isHovering, setSource]); useEffect(() => { if (trimmedSource === "") { setOriginSources([]); return; } let cancelled = false; const timeout = setTimeout(() => { findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource }) .then((sources) => { if (!cancelled) setOriginSources(sources); }) .catch(() => setOriginSources([])); }, 300); return () => { cancelled = true; clearTimeout(timeout); }; }, [trimmedSource, filePath, findSourcesForOrigin]); // The one workspace this file is linked to, if there is exactly one. const linkedWorkspace = useMemo(() => { const ids = [...new Set(originSources.map((s) => s.workspaceId))]; if (ids.length !== 1) return null; return workspaces.find((w) => w.id === ids[0]) ?? null; }, [originSources, workspaces]); // A file linked to the current workspace defaults back into it, so re-importing doesn't // accidentally create a duplicate workspace. A file linked elsewhere only gets a suggestion — // silently targeting a workspace that is neither new nor current is too surprising. An // explicit choice always wins. const destinationKind = destinationChoice ?? (linkedWorkspace != null && linkedWorkspace.id === currentWorkspace?.id ? "current" : "new"); const destinationWorkspaceId = destinationKind === "current" ? (currentWorkspace?.id ?? null) : destinationKind === "other" ? otherWorkspaceId : null; useEffect(() => { if (destinationWorkspaceId == null) { setLinkedSources([]); return; } let cancelled = false; listSources(destinationWorkspaceId) .then((sources) => { if (!cancelled) setLinkedSources(sources); }) .catch(() => setLinkedSources([])); return () => { cancelled = true; }; }, [destinationWorkspaceId, listSources]); const handleSelectFile = async () => { const selected = await platform.dialog.open({ title: "Select File", multiple: false }); if (selected == null) return; selectSource(selected); }; // The selected folder belongs to the workspace being viewed, so it is only offerable when that // is also the destination. const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current"; const destination = (): ImportDestination => { if (destinationWorkspaceId == null) { return { type: "new_workspace" }; } return { type: "existing_workspace", workspaceId: destinationWorkspaceId, folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined, }; }; const handlePreview = async () => { setIsLoading(true); try { const nextPlan = filePath != null ? await planFile(filePath, destination()) : await planUrl(trimmedSource, destination()); setPlan(nextPlan); setItems(nextPlan.items); } catch (err) { onError(err); } finally { setIsLoading(false); } }; const handleCommit = async () => { if (plan == null) return; setIsLoading(true); try { await commit({ ...plan, items }); } catch (err) { onError(err); } finally { setIsLoading(false); } }; const itemTree = useMemo(() => buildItemTree(items), [items]); // A folder row's checkbox carries everything beneath it, deletions included — the row labels // say which of those are destructive. Checking anything also brings back the folders it needs // to live in. const toggleNode = (node: CheckboxTreeNode, checked: boolean) => { const targets = new Set(togglableItems(node).map((i) => i.modelId)); if (checked && node.data.kind === "item") { const byId = new Map(items.map((i) => [i.modelId, i])); for (const ancestor of ancestorsOf(node.data.item, byId)) { if (isMissingFolder(ancestor)) targets.add(ancestor.modelId); } } setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i))); }; const resolveConflict = (modelId: string, resolution: "keep_mine" | "take_source") => { setItems((prev) => prev.map((item) => (item.modelId === modelId ? { ...item, resolution } : item)), ); }; // Deleting a folder takes its contents with it, so those rows have nothing left to decide. const disabledIds = useMemo(() => { const disabled = new Set(); const byId = new Map(items.map((i) => [i.modelId, i])); for (const item of items) { if (item.action !== "delete") continue; for (const parent of ancestorsOf(item, byId)) { if (parent.action === "delete" && parent.selected) { disabled.add(item.modelId); } } } return disabled; }, [items]); if (plan != null) { const unchanged = items.filter((i) => i.action === "unchanged"); const footerNote = unchanged.length > 0 ? `${unchanged.length} ${pluralize("resource", unchanged.length)} unchanged` : ""; const changeCount = items.filter((item) => { if (disabledIds.has(item.modelId)) return false; if (item.action === "conflict") return item.resolution === "take_source"; if (item.action === "unchanged") return false; return item.selected; }).length; const destinationLabel = (() => { if (plan.destination.type === "new_workspace") { const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== ""); if (names.length > 1) return pluralizeCount("new workspace", names.length); return names[0] == null ? "New workspace" : `New workspace · ${names[0]}`; } const { workspaceId, folderId } = plan.destination; const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace"; return folderId != null && folderId === selectedFolder?.id ? `${name} / ${selectedFolder.name}` : name; })(); // The destination workspace roots the tree. It is not a plan item — commit always applies // it — so its checkbox only aggregates the subtree. const workspaceRoot: CheckboxTreeNode = (() => { const planned = plan.resources.workspaces[0]; const planDestination = plan.destination; const existing = planDestination.type === "existing_workspace" ? workspaces.find((w) => w.id === planDestination.workspaceId) : null; return { key: existing?.id ?? planned?.id ?? "workspace", data: { kind: "destination", label: existing?.name ?? planned?.name ?? "New workspace", isNew: planDestination.type === "new_workspace", }, children: itemTree, }; })(); return (
disabledIds.has(n.key)} isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"} isRelevant={(n) => n.data.kind === "destination" || (n.data.kind === "item" && n.data.item.action !== "unchanged") } renderRow={(n) => } />
{plan.warnings.length > 0 && (
Import details
{plan.warnings.map((warning) => (
{warning.title}
{warning.detail}
))}
)} {footerNote !== "" &&
{footerNote}
}
); } const lastImported = originSources.find((s) => s.workspaceId === destinationWorkspaceId) ?? linkedSources.reduce( (latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest), null, ); return ( setOtherWorkspaceId(id === "" ? null : id)} filterable options={[ { value: "", label: "Select a workspace" }, ...workspaces .filter((w) => w.id !== currentWorkspace?.id) .map((w) => ({ value: w.id, label: w.name })), ]} /> )} {lastImported != null && destinationWorkspaceId != null ? (
Last imported from {lastImported.originLabel} ·{" "} {formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
This file was last imported into{" "}
) : null} {canTargetSelectedFolder && ( )}
); } function ImportTreeRow({ row, onResolveConflict, }: { row: TreeRow; onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void; }) { if (row.kind !== "item") { return ( <>
{row.label}
{row.kind === "destination" && row.isNew && ( )} ); } const { item } = row; const label = actionLabel(item); return ( <> {item.model === "folder" || item.model === "environment" ? ( ) : ( )}
{item.name}
{item.action === "conflict" ? (
onResolveConflict(item.modelId, v)} options={[ { value: "keep_mine", label: "Keep mine" }, { value: "take_source", label: "Take source" }, ]} />
) : ( label != null && ( ) )} ); } function ActionChip({ label, help, className, }: { label: string; help: string | null; className?: string; }) { return ( {label} {help != null && } ); } function actionLabel(item: ImportPlanItem): string | null { switch (item.action) { case "create": return "new"; case "update": return "updated"; case "delete": return "removed"; case "keep_local": return "edited"; case "ignored": return "ignored"; default: return null; } } function actionHelp(item: ImportPlanItem): string | null { const help = (text: string) => item.changedFields.length > 0 ? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}` : text; switch (item.action) { case "create": return "Added since the last import"; case "update": return help("Changed since the last import"); case "delete": return item.reason === "moved_into_ignored_folder" ? "Moved into an ignored folder. Import that folder instead to follow the move" : "Deleted since the last import"; case "keep_local": return help("Local edits made since the last import. Importing will revert them if checked"); case "conflict": return help("Changed both here and in the file since the last import"); case "ignored": return "In the file, but ignored. Check it to import it"; default: return null; } } function fieldLabel(field: string): string { return field.replace(/([A-Z])/g, " $1").toLowerCase(); } /** Every plan item above `item`, nearest first. */ function ancestorsOf(item: ImportPlanItem, byId: Map): ImportPlanItem[] { const ancestors: ImportPlanItem[] = []; const seen = new Set(); let parentId = item.parentId; while (parentId != null && !seen.has(parentId)) { seen.add(parentId); const parent = byId.get(parentId); if (parent == null) break; ancestors.push(parent); parentId = parent.parentId; } return ancestors; } /** * A row of the preview tree. Most are plan items, but the destination workspace and the group the * workspace's environments sit in are headings: they aggregate their children and decide nothing * themselves. */ type TreeRow = | { kind: "destination"; label: string; isNew: boolean } | { kind: "group"; label: string; icon: IconProps["icon"] } | { kind: "item"; item: ImportPlanItem }; function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode[] { const byId = new Map(items.map((i) => [i.modelId, i])); const childrenOf = new Map(); const roots: ImportPlanItem[] = []; for (const item of items) { if (item.parentId != null && byId.has(item.parentId)) { const siblings = childrenOf.get(item.parentId) ?? []; siblings.push(item); childrenOf.set(item.parentId, siblings); } else { roots.push(item); } } const byKind = (list: ImportPlanItem[]) => [ ...list.filter((i) => i.model === "environment"), ...list.filter((i) => i.model === "folder"), ...list.filter((i) => i.model !== "environment" && i.model !== "folder"), ]; const toNode = (item: ImportPlanItem, seen: Set): CheckboxTreeNode => ({ key: item.modelId, data: { kind: "item", item }, children: seen.has(item.modelId) ? [] : byKind(childrenOf.get(item.modelId) ?? []).map((c) => toNode(c, new Set([...seen, item.modelId])), ), }); // The workspace's environments have nothing to sit under — a sub-environment is a sibling of // the base one, not its child — so a heading groups them into one thing to turn on and off. const environments = roots.filter((i) => i.model === "environment"); const others = byKind(roots.filter((i) => i.model !== "environment")); const nodes = others.map((r) => toNode(r, new Set())); if (environments.length === 0) return nodes; return [ { key: "group:environments", data: { kind: "group", label: "Variables", icon: "variable" }, children: environments.map((e) => toNode(e, new Set())), }, ...nodes, ]; } function collectRows(node: CheckboxTreeNode): TreeRow[] { return [node.data, ...node.children.flatMap(collectRows)]; } /** A folder that isn't there yet, so anything inside it needs it brought in first. */ function isMissingFolder(item: ImportPlanItem): boolean { return item.model === "folder" && (item.action === "create" || item.action === "ignored"); } /** * The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to * decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides * along with a parent's. */ function togglableItem(row: TreeRow): ImportPlanItem | null { if (row.kind !== "item") return null; const { item } = row; return item.action === "unchanged" || item.action === "conflict" ? null : item; } function togglableItems(node: CheckboxTreeNode): ImportPlanItem[] { return collectRows(node) .map(togglableItem) .filter((i) => i != null); } function nodeCheckedStatus(node: CheckboxTreeNode): boolean | "indeterminate" | "hidden" { const covered = togglableItems(node); if (covered.length === 0) return "hidden"; const selected = covered.filter((i) => i.selected).length; if (selected === covered.length) return true; if (selected === 0) return false; return "indeterminate"; } function PreviewRow({ label, value }: { label: string; value: string }) { return (
{label} {value}
); }