import { type ImportDestination, type ImportPlan, type ImportPlanItem, type ImportSource, type Workspace, } from "@yaakapp-internal/models"; import { Banner, FormattedError, HStack, Icon, VStack, IconTooltip, SegmentedControl, } from "@yaakapp-internal/ui"; import classNames from "classnames"; import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { errorMessage } from "../lib/errorMessage"; import { CommercialUseBanner } from "./CommercialUseBanner"; import { ImportSourceList, type ImportSourceDetection, type ImportSourcePath, InlineButton, toSourcePath, } from "./ImportSourceList"; import type { CheckboxTreeNode } from "./core/CheckboxTree"; import { CheckboxTree } from "./core/CheckboxTree"; import { Chip } from "./core/Chip"; import { DialogFooter } from "./core/Dialog"; import { Select } from "./core/Select"; import { pluralize } from "@yaakapp-internal/lib/pluralize"; interface Props { currentWorkspace: Workspace | null; workspaces: Workspace[]; planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise; detectSource: (source: ImportSourcePath) => Promise; listSources: (workspaceId: string) => Promise; findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise; commit: (plan: ImportPlan) => Promise; cancel: () => void; onError: (err: unknown) => void; } /** * 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 LoadedImportDataDialog({ currentWorkspace, workspaces, planSources, detectSource, findSourcesForOrigin, commit, cancel, onError, initialSources, }: Props & { initialSources: ImportSource[] }) { const [isLoading, setIsLoading] = useState(false); const [sourceError, setSourceError] = useState(null); 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 [originSources, setOriginSources] = useState(initialSources); const initialPaths = useMemo( () => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath), [initialSources], ); const [sources, setSources] = useState(initialPaths); const knownPaths = useRef(new Set(initialPaths.map((source) => source.path))); const [detections, setDetections] = useState>({}); const detect = useCallback( (added: ImportSourcePath[]) => { setDetections((prev) => ({ ...prev, ...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])), })); for (const source of added) { const settle = (detection: ImportSourceDetection) => setDetections((prev) => source.path in prev ? { ...prev, [source.path]: detection } : prev, ); detectSource(source).then( (importer) => settle({ status: "ok", importer }), (err) => settle({ status: "error", message: errorMessage(err) }), ); } }, [detectSource], ); const allDetected = sources.every((source) => detections[source.path]?.status === "ok"); useEffect(() => { detect(initialPaths); }, [detect, initialPaths]); const addSources = useCallback( (added: ImportSourcePath[]) => { setSourceError(null); const fresh = added.filter(({ path }) => { if (knownPaths.current.has(path)) return false; knownPaths.current.add(path); return true; }); if (fresh.length === 0) return; setSources((existing) => [...existing, ...fresh]); detect(fresh); }, [detect], ); const removeSource = (path: string) => { setSourceError(null); knownPaths.current.delete(path); setSources((current) => current.filter((source) => source.path !== path)); setDetections(({ [path]: _removed, ...rest }) => rest); }; useEffect(() => { let cancelled = false; if (sources.length === 0) { setOriginSources([]); return; } Promise.all( sources.map(({ path, kind }) => findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }), ), ) .then((found) => { if (!cancelled) setOriginSources(found.flat()); }) .catch(() => { if (!cancelled) setOriginSources([]); }); return () => { cancelled = true; }; }, [sources, findSourcesForOrigin]); // The one workspace these sources are 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; const destination = (): ImportDestination => { if (destinationWorkspaceId == null) { return { type: "new_workspace" }; } return { type: "existing_workspace", workspaceId: destinationWorkspaceId, }; }; const handlePreview = async () => { setIsLoading(true); setSourceError(null); try { const nextPlan = await planSources(sources, destination()); setPlan(nextPlan); setItems(nextPlan.items); } catch (err) { setSourceError(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); } }; // 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; // The row's label carries what kind of destination it is, so the value can just be its name const [destinationLabel, destinationValue] = ((): [string, string] => { if (plan.destination.type === "new_workspace") { const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== ""); if (names.length === 0) return ["New workspace", "Untitled"]; return [ names.length === 1 ? "New workspace" : `${names.length} new workspaces`, names.join(", "), ]; } const { workspaceId } = plan.destination; const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace"; return ["Destination", 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 workspaceRoots: CheckboxTreeNode[] = (() => { const planDestination = plan.destination; const existing = planDestination.type === "existing_workspace" ? workspaces.find((w) => w.id === planDestination.workspaceId) : null; const destinations = existing ? [existing] : plan.resources.workspaces; if (destinations.length === 1) { const workspace = destinations[0]!; return [ { key: workspace.id, data: { kind: "destination", label: workspace.name, isNew: planDestination.type === "new_workspace", }, children: buildItemTree(items), }, ]; } const workspaceById = new Map( [ ...plan.resources.environments, ...plan.resources.folders, ...plan.resources.httpRequests, ...plan.resources.grpcRequests, ...plan.resources.websocketRequests, ].map((resource) => [resource.id, resource.workspaceId]), ); return destinations.map((workspace) => ({ key: workspace.id, data: { kind: "destination", label: workspace.name, isNew: planDestination.type === "new_workspace", }, children: buildItemTree( items.filter((item) => workspaceById.get(item.modelId) === workspace.id), ), })); })(); return ( <>
{plan.warnings .filter( (warning, index, warnings) => warnings.findIndex( (other) => other.title === warning.title && other.detail === warning.detail && other.level === warning.level, ) === index, ) .map((warning) => (
{warning.title}
{warning.detail}
))} {workspaceRoots.map((root) => (
disabledIds.has(n.key)} isCollapsedByDefault={(n) => (n.data.kind === "destination" && workspaceRoots.length > 1) || (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) => ( )} />
))}
{footerNote} ) : undefined } actions={[ { label: "Back", disabled: isLoading, onClick: () => { setPlan(null); setItems([]); }, }, { label: isLoading ? "Importing" : changeCount > 0 ? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}` : "Done", color: "primary", isLoading, onClick: handleCommit, }, ]} /> ); } const destinationPicker = ( 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 })), ]} /> )} ); return ( <> {sourceError != null && ( {errorMessage(sourceError)} )} {linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
{sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "} { if (linkedWorkspace.id === currentWorkspace?.id) { setDestinationChoice("current"); } else { setDestinationChoice("other"); setOtherWorkspaceId(linkedWorkspace.id); } }} > {linkedWorkspace.name}
)}
); } function ImportTreeRow({ row, onResolveConflict, }: { row: TreeRow; onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void; }) { if (row.kind !== "item") { return ( <> {row.kind === "destination" && ( )}
{row.label}
{row.kind === "destination" && row.isNew && ( )} ); } const { item } = row; const label = actionLabel(item); return ( <>
{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, color, }: { label: string; help: string | null; color: ComponentProps["color"]; }) { return ( {label} {help != null && } ); } function actionColor(item: ImportPlanItem): ComponentProps["color"] { switch (item.action) { case "create": return "success"; case "update": return "info"; case "delete": return "danger"; case "keep_local": return item.selected ? "warning" : "default"; case "unchanged": case "conflict": case "ignored": return "default"; } } 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 "Not in this workspace yet"; case "update": return item.reason === "moved_into_ignored_folder" ? "Moved into a folder that isn't imported. Importing that folder brings it along" : help("Changed in the source since the last import"); case "delete": return "Gone from the source since the last import. Checking it deletes it here"; case "keep_local": return help("Changed here since the last import. Checking it reverts to the source"); case "conflict": return help("Changed here and in the source since the last import"); case "ignored": return "Not in this workspace. Imports leave it alone until you check 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 } | { 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" }, 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}
); }