From 7f8b7bf567ab2dba39b93bbdb470a0fa9a092a0b Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 10 Sep 2026 15:26:43 -0700 Subject: [PATCH] feat(import): remember the user's import selection (#619) Co-authored-by: Claude Opus 5 --- Cargo.lock | 1 + .../components/ImportDataDialog.tsx | 299 +++-- .../components/core/CheckboxTree.tsx | 6 +- .../components/core/SegmentedControl.tsx | 12 +- .../yaak-cli/src/commands/import_export.rs | 4 + .../yaak-cli/tests/import_export_commands.rs | 58 + .../yaak-rpc-schema/bindings/gen_models.ts | 11 - .../yaak-rpc-schema/bindings/gen_util.ts | 105 +- crates/yaak-grpc/bindings/gen_grpc.ts | 5 + crates/yaak-models/bindings/gen_models.ts | 10 +- crates/yaak-models/bindings/gen_util.ts | 105 +- ...60902000000_import-source-content-hash.sql | 24 + crates/yaak-models/src/models.rs | 10 +- .../src/queries/import_source_resources.rs | 8 +- crates/yaak-models/src/util.rs | 45 + crates/yaak-plugins/bindings/gen_models.ts | 13 + crates/yaak/Cargo.toml | 1 + crates/yaak/src/import.rs | 1075 +++++++++++++++-- 18 files changed, 1494 insertions(+), 298 deletions(-) create mode 100644 crates/yaak-grpc/bindings/gen_grpc.ts create mode 100644 crates/yaak-models/migrations/20260902000000_import-source-content-hash.sql diff --git a/Cargo.lock b/Cargo.lock index 4adf00f7..c81eef00 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11228,6 +11228,7 @@ dependencies = [ "md5 0.8.0", "rusqlite", "serde_json", + "sha2", "tempfile", "thiserror 2.0.17", "tokio", diff --git a/apps/yaak-client/components/ImportDataDialog.tsx b/apps/yaak-client/components/ImportDataDialog.tsx index 95161301..d706b031 100644 --- a/apps/yaak-client/components/ImportDataDialog.tsx +++ b/apps/yaak-client/components/ImportDataDialog.tsx @@ -6,7 +6,7 @@ import { type ImportSource, type Workspace, } from "@yaakapp-internal/models"; -import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui"; +import { Banner, 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"; @@ -260,14 +260,17 @@ function LoadedImportDataDialog({ const itemTree = useMemo(() => buildItemTree(items), [items]); - // A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and - // updates toggle together, while removals only ever cascade beneath a removed folder. - const toggleNode = (node: CheckboxTreeNode, checked: boolean) => { - const targets = new Set( - collectItems(node) - .filter((i) => togglesWith(node.data, i)) - .map((i) => i.modelId), - ); + // 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))); }; @@ -277,25 +280,16 @@ function LoadedImportDataDialog({ ); }; - // A row the user can't meaningfully toggle on its own: a planned resource inside a deselected - // new folder can't exist, and a removed folder takes its contents with it. + // 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) { - 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 || parent.model !== "folder") break; - if (parent.action === "create" && !parent.selected && item.action !== "delete") { + if (item.action !== "delete") continue; + for (const parent of ancestorsOf(item, byId)) { + if (parent.action === "delete" && parent.selected) { disabled.add(item.modelId); } - if (parent.action === "delete" && parent.selected && item.action === "delete") { - disabled.add(item.modelId); - } - parentId = parent.parentId; } } return disabled; @@ -314,18 +308,26 @@ function LoadedImportDataDialog({ return item.selected; }).length; - const destinationLabel = (() => { - if (plan.destination.type === "new_workspace") return "New workspace"; + // 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 [pluralize("New workspace", names.length), names.join(", ")]; + } 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; + return [ + "Destination", + 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 workspaceRoot: CheckboxTreeNode = (() => { const planned = plan.resources.workspaces[0]; const planDestination = plan.destination; const existing = @@ -335,11 +337,9 @@ function LoadedImportDataDialog({ return { key: existing?.id ?? planned?.id ?? "workspace", data: { - action: plan.destination.type === "new_workspace" ? "create" : "unchanged", - model: "workspace", - modelId: existing?.id ?? planned?.id ?? "workspace", - name: existing?.name ?? planned?.name ?? "New workspace", - selected: true, + kind: "destination", + label: existing?.name ?? planned?.name ?? "New workspace", + isNew: planDestination.type === "new_workspace", }, children: itemTree, }; @@ -349,40 +349,42 @@ function LoadedImportDataDialog({
- +
+ {plan.warnings.map((warning) => ( + + +
+
{warning.title}
+
{warning.detail}
+
+
+ ))} +
disabledIds.has(n.key)} - isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"} - renderRow={(n) => } + 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}
}
@@ -545,31 +547,42 @@ function LoadedImportDataDialog({ } function ImportTreeRow({ - item, + row, onResolveConflict, }: { - item: ImportPlanItem; + 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 === "workspace" || item.model === "folder" || item.model === "environment" ? ( - + {item.model === "folder" || item.model === "environment" ? ( + ) : ( )}
{item.name}
{item.action === "conflict" ? ( -
+
onResolveConflict(item.modelId, v)} options={[ @@ -577,29 +590,49 @@ function ImportTreeRow({ { value: "take_source", label: "Take source" }, ]} /> -
) : ( - actionLabel(item) && ( - - {actionLabel(item)} - - + /> ) )} ); } +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": @@ -610,29 +643,68 @@ function actionLabel(item: ImportPlanItem): string | null { 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"; + return "Not in this workspace yet"; case "update": - return "Changed since the last import"; + 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 "Deleted since the last import"; + return "Gone from the source since the last import. Checking it deletes it here"; case "keep_local": - return "Local edits made since the last import. Importing will revert them if checked"; + return help("Changed here since the last import. Checking it reverts to the source"); case "conflict": - return "Changed both here and in the file since the last import"; + 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 buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode[] { +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[] = []; @@ -646,45 +718,66 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode [ + const byKind = (list: ImportPlanItem[]) => [ + ...list.filter((i) => i.model === "environment"), ...list.filter((i) => i.model === "folder"), - ...list.filter((i) => i.model !== "folder"), + ...list.filter((i) => i.model !== "environment" && i.model !== "folder"), ]; - const toNode = (item: ImportPlanItem, seen: Set): CheckboxTreeNode => ({ + const toNode = (item: ImportPlanItem, seen: Set): CheckboxTreeNode => ({ key: item.modelId, - data: item, + data: { kind: "item", item }, children: seen.has(item.modelId) ? [] - : foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) => + : byKind(childrenOf.get(item.modelId) ?? []).map((c) => toNode(c, new Set([...seen, item.modelId])), ), }); - return foldersFirst(roots).map((r) => toNode(r, new Set())); + // 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 collectItems(node: CheckboxTreeNode): ImportPlanItem[] { - return [node.data, ...node.children.flatMap(collectItems)]; +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"); } /** - * Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions - * (deletions, reverting local edits) never ride along with a parent toggle. + * 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 togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean { - if (item.model === "workspace") return false; - if (root.action === "delete") return item.action === "delete"; - if (item.action === "keep_local") { - return root.modelId === item.modelId && item.model !== "folder"; - } - return item.action === "create" || item.action === "update"; +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 nodeCheckedStatus( - node: CheckboxTreeNode, -): boolean | "indeterminate" | "hidden" { - const covered = collectItems(node).filter((i) => togglesWith(node.data, i)); +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; diff --git a/apps/yaak-client/components/core/CheckboxTree.tsx b/apps/yaak-client/components/core/CheckboxTree.tsx index 0f714e35..d6d4423d 100644 --- a/apps/yaak-client/components/core/CheckboxTree.tsx +++ b/apps/yaak-client/components/core/CheckboxTree.tsx @@ -21,6 +21,8 @@ interface Props { isCheckboxDisabled?: (node: CheckboxTreeNode) => boolean; /** An irrelevant row is hidden unless one of its descendants is relevant */ isRelevant: (node: CheckboxTreeNode) => boolean; + /** A node that starts collapsed, so a large subtree doesn't crowd out the rest */ + isCollapsedByDefault?: (node: CheckboxTreeNode) => boolean; renderRow: (node: CheckboxTreeNode) => ReactNode; onSelectRow?: (node: CheckboxTreeNode) => void; canSelectRow?: (node: CheckboxTreeNode) => boolean; @@ -29,7 +31,9 @@ interface Props { export function CheckboxTree(props: Props) { const { node, depth = 0 } = props; - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState( + () => props.isCollapsedByDefault?.(node) ?? false, + ); if (!hasRelevantNode(node, props.isRelevant)) return null; const checked = props.checked(node); diff --git a/apps/yaak-client/components/core/SegmentedControl.tsx b/apps/yaak-client/components/core/SegmentedControl.tsx index 4b6022f5..aa9aca85 100644 --- a/apps/yaak-client/components/core/SegmentedControl.tsx +++ b/apps/yaak-client/components/core/SegmentedControl.tsx @@ -6,6 +6,7 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps"; import { generateId } from "../../lib/generateId"; import { Button } from "./Button"; import { IconButton, type IconButtonProps } from "./IconButton"; +import { IconTooltip } from "./IconTooltip"; import { Label } from "./Label"; interface Props { @@ -36,11 +37,15 @@ export function SegmentedControl({ const containerRef = useRef(null); const id = useRef(`input-${generateId()}`); + // A visually hidden label has nowhere to show the help, so the last option carries it + const inlineHelp = + hideLabel && help ? : null; + return (