feat(import): remember the user's import selection (#619)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-09-10 15:26:43 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 096e83a7bd
commit 7f8b7bf567
18 changed files with 1494 additions and 298 deletions
Generated
+1
View File
@@ -11228,6 +11228,7 @@ dependencies = [
"md5 0.8.0", "md5 0.8.0",
"rusqlite", "rusqlite",
"serde_json", "serde_json",
"sha2",
"tempfile", "tempfile",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
+196 -103
View File
@@ -6,7 +6,7 @@ import {
type ImportSource, type ImportSource,
type Workspace, type Workspace,
} from "@yaakapp-internal/models"; } 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 { platform } from "@yaakapp-internal/platform";
import classNames from "classnames"; import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns"; import { formatDistanceToNowStrict } from "date-fns";
@@ -260,14 +260,17 @@ function LoadedImportDataDialog({
const itemTree = useMemo(() => buildItemTree(items), [items]); const itemTree = useMemo(() => buildItemTree(items), [items]);
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and // A folder row's checkbox carries everything beneath it, deletions included — the row labels
// updates toggle together, while removals only ever cascade beneath a removed folder. // say which of those are destructive. Checking anything also brings back the folders it needs
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => { // to live in.
const targets = new Set( const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
collectItems(node) const targets = new Set(togglableItems(node).map((i) => i.modelId));
.filter((i) => togglesWith(node.data, i)) if (checked && node.data.kind === "item") {
.map((i) => i.modelId), 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))); 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 // Deleting a folder takes its contents with it, so those rows have nothing left to decide.
// new folder can't exist, and a removed folder takes its contents with it.
const disabledIds = useMemo(() => { const disabledIds = useMemo(() => {
const disabled = new Set<string>(); const disabled = new Set<string>();
const byId = new Map(items.map((i) => [i.modelId, i])); const byId = new Map(items.map((i) => [i.modelId, i]));
for (const item of items) { for (const item of items) {
const seen = new Set<string>(); if (item.action !== "delete") continue;
let parentId = item.parentId; for (const parent of ancestorsOf(item, byId)) {
while (parentId != null && !seen.has(parentId)) { if (parent.action === "delete" && parent.selected) {
seen.add(parentId);
const parent = byId.get(parentId);
if (parent == null || parent.model !== "folder") break;
if (parent.action === "create" && !parent.selected && item.action !== "delete") {
disabled.add(item.modelId); disabled.add(item.modelId);
} }
if (parent.action === "delete" && parent.selected && item.action === "delete") {
disabled.add(item.modelId);
}
parentId = parent.parentId;
} }
} }
return disabled; return disabled;
@@ -314,18 +308,26 @@ function LoadedImportDataDialog({
return item.selected; return item.selected;
}).length; }).length;
const destinationLabel = (() => { // The row's label carries what kind of destination it is, so the value can just be its name
if (plan.destination.type === "new_workspace") return "New workspace"; 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 { workspaceId, folderId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace"; const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return folderId != null && folderId === selectedFolder?.id return [
? `${name} / ${selectedFolder.name}` "Destination",
: name; folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name,
];
})(); })();
// The destination workspace roots the tree. It is not a plan item — commit always applies // The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree. // it — so its checkbox only aggregates the subtree.
const workspaceRoot: CheckboxTreeNode<ImportPlanItem> = (() => { const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
const planned = plan.resources.workspaces[0]; const planned = plan.resources.workspaces[0];
const planDestination = plan.destination; const planDestination = plan.destination;
const existing = const existing =
@@ -335,11 +337,9 @@ function LoadedImportDataDialog({
return { return {
key: existing?.id ?? planned?.id ?? "workspace", key: existing?.id ?? planned?.id ?? "workspace",
data: { data: {
action: plan.destination.type === "new_workspace" ? "create" : "unchanged", kind: "destination",
model: "workspace", label: existing?.name ?? planned?.name ?? "New workspace",
modelId: existing?.id ?? planned?.id ?? "workspace", isNew: planDestination.type === "new_workspace",
name: existing?.name ?? planned?.name ?? "New workspace",
selected: true,
}, },
children: itemTree, children: itemTree,
}; };
@@ -349,40 +349,42 @@ function LoadedImportDataDialog({
<VStack space={4} className="pb-4"> <VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle"> <div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} /> <PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label="Destination" value={destinationLabel} /> <PreviewRow label={destinationLabel} value={destinationValue} />
</div> </div>
{plan.warnings.map((warning) => (
<Banner
key={`${warning.title}:${warning.detail}`}
color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
<Icon
icon={warning.level === "warning" ? "alert_triangle" : "info"}
size="sm"
className="mt-0.5"
/>
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</Banner>
))}
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]"> <div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
<CheckboxTree <CheckboxTree
node={workspaceRoot} node={workspaceRoot}
checked={nodeCheckedStatus} checked={nodeCheckedStatus}
onCheck={toggleNode} onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)} isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"} isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />} isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
/> />
</div> </div>
{plan.warnings.length > 0 && (
<div>
<div className="text-sm font-semibold mb-1">Import details</div>
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
{plan.warnings.map((warning) => (
<div
key={`${warning.title}:${warning.detail}`}
className="flex items-start gap-2.5 px-3 py-2.5"
>
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</div>
))}
</div>
</div>
)}
<HStack space={2} alignItems="center" className="mt-3"> <HStack space={2} alignItems="center" className="mt-3">
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>} {footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
<Button <Button
@@ -402,7 +404,7 @@ function LoadedImportDataDialog({
? "Importing" ? "Importing"
: changeCount > 0 : changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}` ? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Apply"} : "Done"}
</Button> </Button>
</HStack> </HStack>
</VStack> </VStack>
@@ -545,31 +547,42 @@ function LoadedImportDataDialog({
} }
function ImportTreeRow({ function ImportTreeRow({
item, row,
onResolveConflict, onResolveConflict,
}: { }: {
item: ImportPlanItem; row: TreeRow;
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void; onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
}) { }) {
if (row.kind !== "item") {
return (
<>
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
<div className="truncate flex-1">{row.label}</div>
{row.kind === "destination" && row.isNew && (
<ActionChip label="new" help="Created by this import" className="text-success" />
)}
</>
);
}
const { item } = row;
const label = actionLabel(item);
return ( return (
<> <>
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? ( {item.model === "folder" || item.model === "environment" ? (
<Icon <Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
color="secondary"
icon={
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
}
/>
) : ( ) : (
<span aria-hidden className="w-4" /> <span aria-hidden className="w-4" />
)} )}
<div className="truncate flex-1">{item.name}</div> <div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? ( {item.action === "conflict" ? (
<div className="shrink-0 flex items-center gap-1.5"> <div className="shrink-0">
<SegmentedControl <SegmentedControl
name={`conflict-${item.modelId}`} name={`conflict-${item.modelId}`}
label={`Resolve conflict for ${item.name}`} label={`Resolve conflict for ${item.name}`}
hideLabel hideLabel
size="2xs"
help={actionHelp(item)}
value={item.resolution ?? "keep_mine"} value={item.resolution ?? "keep_mine"}
onChange={(v) => onResolveConflict(item.modelId, v)} onChange={(v) => onResolveConflict(item.modelId, v)}
options={[ options={[
@@ -577,29 +590,49 @@ function ImportTreeRow({
{ value: "take_source", label: "Take source" }, { value: "take_source", label: "Take source" },
]} ]}
/> />
<IconTooltip content={actionHelp(item)} iconSize="sm" />
</div> </div>
) : ( ) : (
actionLabel(item) && ( label != null && (
<InlineCode <ActionChip
label={label}
help={actionHelp(item)}
className={classNames( className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
item.action === "create" && "text-success", item.action === "create" && "text-success",
item.action === "update" && "text-info", item.action === "update" && "text-info",
item.action === "delete" && "text-danger", item.action === "delete" && "text-danger",
item.action === "keep_local" && item.selected && "text-warning", item.action === "keep_local" && item.selected && "text-warning",
item.action === "ignored" && "text-text-subtlest",
)} )}
> />
{actionLabel(item)}
<IconTooltip content={actionHelp(item)} iconSize="xs" />
</InlineCode>
) )
)} )}
</> </>
); );
} }
function ActionChip({
label,
help,
className,
}: {
label: string;
help: string | null;
className?: string;
}) {
return (
<InlineCode
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
className,
)}
>
{label}
{help != null && <IconTooltip content={help} iconSize="xs" />}
</InlineCode>
);
}
function actionLabel(item: ImportPlanItem): string | null { function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) { switch (item.action) {
case "create": case "create":
@@ -610,29 +643,68 @@ function actionLabel(item: ImportPlanItem): string | null {
return "removed"; return "removed";
case "keep_local": case "keep_local":
return "edited"; return "edited";
case "ignored":
return "ignored";
default: default:
return null; return null;
} }
} }
function actionHelp(item: ImportPlanItem): string | 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) { switch (item.action) {
case "create": case "create":
return "Added since the last import"; return "Not in this workspace yet";
case "update": 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": 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": 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": 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: default:
return null; return null;
} }
} }
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] { 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<string, ImportPlanItem>): ImportPlanItem[] {
const ancestors: ImportPlanItem[] = [];
const seen = new Set<string>();
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<TreeRow>[] {
const byId = new Map(items.map((i) => [i.modelId, i])); const byId = new Map(items.map((i) => [i.modelId, i]));
const childrenOf = new Map<string, ImportPlanItem[]>(); const childrenOf = new Map<string, ImportPlanItem[]>();
const roots: ImportPlanItem[] = []; const roots: ImportPlanItem[] = [];
@@ -646,45 +718,66 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem
} }
} }
const foldersFirst = (list: ImportPlanItem[]) => [ 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 !== "folder"), ...list.filter((i) => i.model !== "environment" && i.model !== "folder"),
]; ];
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({ const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<TreeRow> => ({
key: item.modelId, key: item.modelId,
data: item, data: { kind: "item", item },
children: seen.has(item.modelId) 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])), 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>): ImportPlanItem[] { function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
return [node.data, ...node.children.flatMap(collectItems)]; 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 * The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
* (deletions, reverting local edits) never ride along with a parent toggle. * 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 { function togglableItem(row: TreeRow): ImportPlanItem | null {
if (item.model === "workspace") return false; if (row.kind !== "item") return null;
if (root.action === "delete") return item.action === "delete"; const { item } = row;
if (item.action === "keep_local") { return item.action === "unchanged" || item.action === "conflict" ? null : item;
return root.modelId === item.modelId && item.model !== "folder";
}
return item.action === "create" || item.action === "update";
} }
function nodeCheckedStatus( function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
node: CheckboxTreeNode<ImportPlanItem>, return collectRows(node)
): boolean | "indeterminate" | "hidden" { .map(togglableItem)
const covered = collectItems(node).filter((i) => togglesWith(node.data, i)); .filter((i) => i != null);
}
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
const covered = togglableItems(node);
if (covered.length === 0) return "hidden"; if (covered.length === 0) return "hidden";
const selected = covered.filter((i) => i.selected).length; const selected = covered.filter((i) => i.selected).length;
if (selected === covered.length) return true; if (selected === covered.length) return true;
@@ -21,6 +21,8 @@ interface Props<T> {
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean; isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
/** An irrelevant row is hidden unless one of its descendants is relevant */ /** An irrelevant row is hidden unless one of its descendants is relevant */
isRelevant: (node: CheckboxTreeNode<T>) => boolean; isRelevant: (node: CheckboxTreeNode<T>) => boolean;
/** A node that starts collapsed, so a large subtree doesn't crowd out the rest */
isCollapsedByDefault?: (node: CheckboxTreeNode<T>) => boolean;
renderRow: (node: CheckboxTreeNode<T>) => ReactNode; renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
onSelectRow?: (node: CheckboxTreeNode<T>) => void; onSelectRow?: (node: CheckboxTreeNode<T>) => void;
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean; canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
@@ -29,7 +31,9 @@ interface Props<T> {
export function CheckboxTree<T>(props: Props<T>) { export function CheckboxTree<T>(props: Props<T>) {
const { node, depth = 0 } = props; const { node, depth = 0 } = props;
const [collapsed, setCollapsed] = useState<boolean>(false); const [collapsed, setCollapsed] = useState<boolean>(
() => props.isCollapsedByDefault?.(node) ?? false,
);
if (!hasRelevantNode(node, props.isRelevant)) return null; if (!hasRelevantNode(node, props.isRelevant)) return null;
const checked = props.checked(node); const checked = props.checked(node);
@@ -6,6 +6,7 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { generateId } from "../../lib/generateId"; import { generateId } from "../../lib/generateId";
import { Button } from "./Button"; import { Button } from "./Button";
import { IconButton, type IconButtonProps } from "./IconButton"; import { IconButton, type IconButtonProps } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
import { Label } from "./Label"; import { Label } from "./Label";
interface Props<T extends string> { interface Props<T extends string> {
@@ -36,11 +37,15 @@ export function SegmentedControl<T extends string>({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const id = useRef(`input-${generateId()}`); 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 ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
return ( return (
<div className="w-full grid"> <div className="w-full grid">
<Label <Label
htmlFor={id.current} htmlFor={id.current}
help={help} help={hideLabel ? undefined : help}
visuallyHidden={hideLabel} visuallyHidden={hideLabel}
className={classNames(labelClassName)} className={classNames(labelClassName)}
> >
@@ -78,9 +83,10 @@ export function SegmentedControl<T extends string>({
} }
}} }}
> >
{options.map((o) => { {options.map((o, i) => {
const isSelected = selectedValue === o.value; const isSelected = selectedValue === o.value;
const isActive = value === o.value; const isActive = value === o.value;
const rightSlot = i === options.length - 1 ? inlineHelp : null;
if (o.icon == null) { if (o.icon == null) {
return ( return (
<Button <Button
@@ -95,6 +101,7 @@ export function SegmentedControl<T extends string>({
isActive && "text-text!", isActive && "text-text!",
"focus:ring-1 focus:ring-border-focus", "focus:ring-1 focus:ring-border-focus",
)} )}
rightSlot={rightSlot}
onClick={() => onChange(o.value)} onClick={() => onChange(o.value)}
> >
{o.label} {o.label}
@@ -117,6 +124,7 @@ export function SegmentedControl<T extends string>({
)} )}
title={o.label} title={o.label}
icon={o.icon} icon={o.icon}
rightSlot={rightSlot}
onClick={() => onChange(o.value)} onClick={() => onChange(o.value)}
/> />
); );
@@ -113,6 +113,10 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
if keep_local > 0 { if keep_local > 0 {
parts.push(format!("{keep_local} with local edits")); parts.push(format!("{keep_local} with local edits"));
} }
let ignored = count(ImportPlanAction::Ignored);
if ignored > 0 {
parts.push(format!("{ignored} ignored"));
}
let unchanged = count(ImportPlanAction::Unchanged); let unchanged = count(ImportPlanAction::Unchanged);
if unchanged > 0 { if unchanged > 0 {
parts.push(format!("{unchanged} unchanged")); parts.push(format!("{unchanged} unchanged"));
@@ -4,6 +4,7 @@ use common::{cli_cmd, parse_created_id, query_manager, seed_request};
use predicates::str::contains; use predicates::str::contains;
use serde_json::Value; use serde_json::Value;
use tempfile::TempDir; use tempfile::TempDir;
use yaak_models::util::UpdateSource;
#[test] #[test]
fn export_writes_yaak_workspace_file() { fn export_writes_yaak_workspace_file() {
@@ -257,3 +258,60 @@ fn re_import_merges_into_linked_workspace() {
assert!(requests.iter().any(|r| r.name == "Request B"), "removal must not auto-apply"); assert!(requests.iter().any(|r| r.name == "Request B"), "removal must not auto-apply");
assert!(requests.iter().any(|r| r.name == "Request C")); assert!(requests.iter().any(|r| r.name == "Request C"));
} }
#[test]
fn re_import_leaves_deleted_resources_alone() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let data_dir = temp_dir.path();
let import_path = temp_dir.path().join("linked.json");
write_linked_fixture(
&import_path,
&[
("req_a", "Request A", "https://example.com/a"),
("req_b", "Request B", "https://example.com/b"),
],
);
cli_cmd(data_dir)
.args(["import", import_path.to_str().expect("import path is utf-8")])
.assert()
.success();
let workspace_id = {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
let workspace_id = db
.list_workspaces()
.expect("list workspaces")
.into_iter()
.find(|w| w.name == "Linked Workspace")
.expect("workspace imported")
.id;
let request_b = db
.list_http_requests(&workspace_id)
.expect("list requests")
.into_iter()
.find(|r| r.name == "Request B")
.expect("request B imported");
db.delete_http_request_by_id(&request_b.id, &UpdateSource::Sync)
.expect("delete request B");
workspace_id
};
cli_cmd(data_dir)
.args([
"import",
import_path.to_str().expect("import path is utf-8"),
"--workspace-id",
&workspace_id,
])
.assert()
.success()
.stdout(contains("Skipped 1 ignored"));
let query_manager = query_manager(data_dir);
let requests =
query_manager.connect().list_http_requests(&workspace_id).expect("list requests");
assert_eq!(requests.len(), 1, "a deleted request must not come back: {requests:?}");
assert_eq!(requests[0].name, "Request A");
}
-11
View File
@@ -331,17 +331,6 @@ export type ImportSource = {
lastImportedAt: string; lastImportedAt: string;
}; };
export type ImportSourceResource = {
model: "import_source_resource";
createdAt: string;
updatedAt: string;
importSourceId: string;
sourceKey: string;
modelType: string;
modelId: string;
snapshot: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+82 -23
View File
@@ -1,7 +1,21 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models"; import type {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, }; export type BatchUpsertResult = {
workspaces: Array<Workspace>;
environments: Array<Environment>;
folders: Array<Folder>;
httpRequests: Array<HttpRequest>;
grpcRequests: Array<GrpcRequest>;
websocketRequests: Array<WebsocketRequest>;
};
export type ImportConflictResolution = "keep_mine" | "take_source"; export type ImportConflictResolution = "keep_mine" | "take_source";
@@ -11,38 +25,83 @@ export type ImportConflictResolution = "keep_mine" | "take_source";
* The destination workspace and optional folder IDs are captured in the plan so the preview describes * The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use. * the exact destination that confirmation will use.
*/ */
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, }; export type ImportDestination =
| { type: "new_workspace" }
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
/** /**
* Where an import's contents came from, used to link the committed workspace back to it. * Where an import's contents came from, used to link the committed workspace back to it.
*/ */
export type ImportOrigin = { export type ImportOrigin = {
/** /**
* The absolute file path or URL the contents were read from. * The absolute file path or URL the contents were read from.
*/ */
origin: string, label: string, }; origin: string;
label: string;
};
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>, export type ImportPlan = {
/** importer: string;
* Stable source key for every model in `resources`, keyed by its planned ID. destination: ImportDestination;
*/ resources: BatchUpsertResult;
sourceKeys: { [key in string]?: string }, warnings: Array<ImportPlanWarning>;
/** /**
* One entry per plannable resource; commit applies only the selected ones. * Stable source key for every model in `resources`, keyed by its planned ID.
*/ */
items: Array<ImportPlanItem>, origin?: ImportOrigin, }; sourceKeys: { [key in string]?: string };
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
};
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict"; export type ImportPlanAction =
| "create"
| "update"
| "delete"
| "unchanged"
| "keep_local"
| "conflict"
| "ignored";
export type ImportPlanItem = {
action: ImportPlanAction;
model: ImportResourceType;
modelId: string;
name: string;
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
*/
parentId?: string;
selected: boolean;
resolution?: ImportConflictResolution;
reason?: ImportPlanReason;
/**
* Fields where the source and the local copy disagree, so the preview can say why
*/
changedFields: Array<string>;
};
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
/** /**
* Planned parent folder ID for incoming resources; current parent for deletions. * Extra context for an action that would otherwise be indistinguishable from its plain form.
*/ */
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, }; export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanWarning = { title: string, detail: string, }; export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
* Whether a plan's note is something to know or something to think twice about.
*/
export type ImportPlanWarningLevel = "info" | "warning";
/** /**
* The model types an import plan can contain. * The model types an import plan can contain.
*/ */
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace"; export type ImportResourceType =
| "environment"
| "folder"
| "grpc_request"
| "http_request"
| "websocket_request"
| "workspace";
+5
View File
@@ -0,0 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MethodDefinition = { name: string, schema: string, clientStreaming: boolean, serverStreaming: boolean, };
export type ServiceDefinition = { name: string, methods: Array<MethodDefinition>, };
+8 -2
View File
@@ -356,8 +356,14 @@ export type ImportSourceResource = {
importSourceId: string; importSourceId: string;
sourceKey: string; sourceKey: string;
modelType: string; modelType: string;
modelId: string; /**
snapshot: string; * `None` once the user has decided not to import this key
*/
modelId?: string;
/**
* Hash of the resource as last applied or decided from the source, if one was recorded
*/
contentHash?: string;
}; };
export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
+82 -23
View File
@@ -1,7 +1,21 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models"; import type {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, }; export type BatchUpsertResult = {
workspaces: Array<Workspace>;
environments: Array<Environment>;
folders: Array<Folder>;
httpRequests: Array<HttpRequest>;
grpcRequests: Array<GrpcRequest>;
websocketRequests: Array<WebsocketRequest>;
};
export type ImportConflictResolution = "keep_mine" | "take_source"; export type ImportConflictResolution = "keep_mine" | "take_source";
@@ -11,38 +25,83 @@ export type ImportConflictResolution = "keep_mine" | "take_source";
* The destination workspace and optional folder IDs are captured in the plan so the preview describes * The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use. * the exact destination that confirmation will use.
*/ */
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, }; export type ImportDestination =
| { type: "new_workspace" }
| { type: "existing_workspace"; workspaceId: string; folderId?: string };
/** /**
* Where an import's contents came from, used to link the committed workspace back to it. * Where an import's contents came from, used to link the committed workspace back to it.
*/ */
export type ImportOrigin = { export type ImportOrigin = {
/** /**
* The absolute file path or URL the contents were read from. * The absolute file path or URL the contents were read from.
*/ */
origin: string, label: string, }; origin: string;
label: string;
};
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>, export type ImportPlan = {
/** importer: string;
* Stable source key for every model in `resources`, keyed by its planned ID. destination: ImportDestination;
*/ resources: BatchUpsertResult;
sourceKeys: { [key in string]?: string }, warnings: Array<ImportPlanWarning>;
/** /**
* One entry per plannable resource; commit applies only the selected ones. * Stable source key for every model in `resources`, keyed by its planned ID.
*/ */
items: Array<ImportPlanItem>, origin?: ImportOrigin, }; sourceKeys: { [key in string]?: string };
/**
* One entry per plannable resource; commit applies only the selected ones.
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
};
export type ImportPlanAction = "create" | "update" | "delete" | "unchanged" | "keep_local" | "conflict"; export type ImportPlanAction =
| "create"
| "update"
| "delete"
| "unchanged"
| "keep_local"
| "conflict"
| "ignored";
export type ImportPlanItem = {
action: ImportPlanAction;
model: ImportResourceType;
modelId: string;
name: string;
/**
* Planned parent folder ID for incoming resources; current parent for deletions.
*/
parentId?: string;
selected: boolean;
resolution?: ImportConflictResolution;
reason?: ImportPlanReason;
/**
* Fields where the source and the local copy disagree, so the preview can say why
*/
changedFields: Array<string>;
};
export type ImportPlanItem = { action: ImportPlanAction, model: ImportResourceType, modelId: string, name: string,
/** /**
* Planned parent folder ID for incoming resources; current parent for deletions. * Extra context for an action that would otherwise be indistinguishable from its plain form.
*/ */
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, }; export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanWarning = { title: string, detail: string, }; export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
* Whether a plan's note is something to know or something to think twice about.
*/
export type ImportPlanWarningLevel = "info" | "warning";
/** /**
* The model types an import plan can contain. * The model types an import plan can contain.
*/ */
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace"; export type ImportResourceType =
| "environment"
| "folder"
| "grpc_request"
| "http_request"
| "websocket_request"
| "workspace";
@@ -0,0 +1,24 @@
-- Replace the per-resource snapshot with a content hash, and let a row exist without a model
-- so a resource the user chose not to import can be remembered.
CREATE TABLE import_source_resources_new
(
model TEXT DEFAULT 'import_source_resource' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
import_source_id TEXT NOT NULL,
source_key TEXT NOT NULL,
model_type TEXT NOT NULL,
model_id TEXT,
content_hash TEXT,
PRIMARY KEY (import_source_id, source_key)
);
INSERT INTO import_source_resources_new (model, created_at, updated_at, import_source_id,
source_key, model_type, model_id, content_hash)
SELECT model, created_at, updated_at, import_source_id, source_key, model_type, model_id, NULL
FROM import_source_resources;
DROP TABLE import_source_resources;
ALTER TABLE import_source_resources_new
RENAME TO import_source_resources;
+7 -3
View File
@@ -3118,8 +3118,12 @@ pub struct ImportSourceResource {
pub import_source_id: String, pub import_source_id: String,
pub source_key: String, pub source_key: String,
pub model_type: String, pub model_type: String,
pub model_id: String, /// `None` once the user has decided not to import this key
pub snapshot: String, #[ts(optional)]
pub model_id: Option<String>,
/// Hash of the resource as last applied or decided from the source, if one was recorded
#[ts(optional)]
pub content_hash: Option<String>,
} }
impl<'s> TryFrom<&Row<'s>> for ImportSourceResource { impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
@@ -3134,7 +3138,7 @@ impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
source_key: r.get("source_key")?, source_key: r.get("source_key")?,
model_type: r.get("model_type")?, model_type: r.get("model_type")?,
model_id: r.get("model_id")?, model_id: r.get("model_id")?,
snapshot: r.get("snapshot")?, content_hash: r.get("content_hash")?,
}) })
} }
} }
@@ -34,7 +34,7 @@ impl<'a> ClientDb<'a> {
ImportSourceResourceIden::SourceKey, ImportSourceResourceIden::SourceKey,
ImportSourceResourceIden::ModelType, ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId, ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::Snapshot, ImportSourceResourceIden::ContentHash,
]) ])
.values_panic([ .values_panic([
CurrentTimestamp.into(), CurrentTimestamp.into(),
@@ -42,8 +42,8 @@ impl<'a> ClientDb<'a> {
resource.import_source_id.as_str().into(), resource.import_source_id.as_str().into(),
resource.source_key.as_str().into(), resource.source_key.as_str().into(),
resource.model_type.as_str().into(), resource.model_type.as_str().into(),
resource.model_id.as_str().into(), resource.model_id.clone().into(),
resource.snapshot.as_str().into(), resource.content_hash.clone().into(),
]) ])
.on_conflict( .on_conflict(
OnConflict::columns([ OnConflict::columns([
@@ -54,7 +54,7 @@ impl<'a> ClientDb<'a> {
ImportSourceResourceIden::UpdatedAt, ImportSourceResourceIden::UpdatedAt,
ImportSourceResourceIden::ModelType, ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId, ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::Snapshot, ImportSourceResourceIden::ContentHash,
]) ])
.to_owned(), .to_owned(),
) )
+45
View File
@@ -109,6 +109,36 @@ pub enum ImportDestination {
pub struct ImportPlanWarning { pub struct ImportPlanWarning {
pub title: String, pub title: String,
pub detail: String, pub detail: String,
#[serde(default)]
pub level: ImportPlanWarningLevel,
}
/// Whether a plan's note is something to know or something to think twice about.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanWarningLevel {
#[default]
Info,
Warning,
}
impl ImportPlanWarning {
pub fn info(title: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
title: title.into(),
detail: detail.into(),
level: ImportPlanWarningLevel::Info,
}
}
pub fn warning(title: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
title: title.into(),
detail: detail.into(),
level: ImportPlanWarningLevel::Warning,
}
}
} }
/// Where an import's contents came from, used to link the committed workspace back to it. /// Where an import's contents came from, used to link the committed workspace back to it.
@@ -169,6 +199,16 @@ pub enum ImportPlanAction {
Unchanged, Unchanged,
KeepLocal, KeepLocal,
Conflict, Conflict,
/// Present in the source but previously turned down; selecting it imports it again
Ignored,
}
/// Extra context for an action that would otherwise be indistinguishable from its plain form.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanReason {
MovedIntoIgnoredFolder,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
@@ -193,6 +233,11 @@ pub struct ImportPlanItem {
pub selected: bool, pub selected: bool,
#[ts(optional)] #[ts(optional)]
pub resolution: Option<ImportConflictResolution>, pub resolution: Option<ImportConflictResolution>,
#[ts(optional)]
pub reason: Option<ImportPlanReason>,
/// Fields where the source and the local copy disagree, so the preview can say why
#[serde(default)]
pub changed_fields: Vec<String>,
} }
#[derive(Debug, Deserialize, Serialize, TS)] #[derive(Debug, Deserialize, Serialize, TS)]
+13
View File
@@ -11,6 +11,7 @@ export type AnyModel =
| HttpRequest | HttpRequest
| HttpResponse | HttpResponse
| HttpResponseEvent | HttpResponseEvent
| ImportSource
| KeyValue | KeyValue
| Plugin | Plugin
| Settings | Settings
@@ -318,6 +319,18 @@ export type HttpUrlParameter = {
export type HttpVersion = "auto" | "http1" | "http2"; export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+1
View File
@@ -9,6 +9,7 @@ async-trait = "0.1"
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
log = { workspace = true } log = { workspace = true }
md5 = "0.8.0" md5 = "0.8.0"
sha2 = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
+949 -126
View File
File diff suppressed because it is too large Load Diff