mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-07 02:17:22 +02:00
feat(import): merge re-imports from a linked source instead of duplicating (#618)
This commit is contained in:
@@ -2,20 +2,25 @@ import {
|
||||
type Folder,
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
modelTypeLabel,
|
||||
type ImportPlanItem,
|
||||
type ImportSource,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import classNames from "classnames";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocalStorage } from "react-use";
|
||||
import { pluralizeCount } from "../lib/pluralize";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { pluralize } 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;
|
||||
@@ -23,15 +28,13 @@ interface Props {
|
||||
selectedFolder: Folder | null;
|
||||
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
|
||||
listSources: (workspaceId: string) => Promise<ImportSource[]>;
|
||||
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
|
||||
commit: (plan: ImportPlan) => Promise<void>;
|
||||
cancel: () => void;
|
||||
onError: (err: unknown) => void;
|
||||
}
|
||||
|
||||
/** Sentinel for the "create a new workspace" option. Workspace IDs are prefixed `wk_`, so this
|
||||
* can never collide with a real one. */
|
||||
const NEW_WORKSPACE = "new_workspace";
|
||||
|
||||
/**
|
||||
* 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).
|
||||
@@ -51,22 +54,74 @@ function fileName(path: string): string {
|
||||
return path.split(/[/\\]/).at(-1) || path;
|
||||
}
|
||||
|
||||
export function ImportDataDialog({
|
||||
/**
|
||||
* 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<ImportSource[] | null>(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 <LoadedImportDataDialog {...props} initialSources={initialSources} />;
|
||||
}
|
||||
|
||||
function latestSource(sources: ImportSource[]): ImportSource | null {
|
||||
return sources.reduce<ImportSource | null>(
|
||||
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
function LoadedImportDataDialog({
|
||||
currentWorkspace,
|
||||
workspaces,
|
||||
selectedFolder,
|
||||
planFile,
|
||||
planUrl,
|
||||
listSources,
|
||||
findSourcesForOrigin,
|
||||
commit,
|
||||
cancel,
|
||||
onError,
|
||||
}: Props) {
|
||||
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<boolean>(false);
|
||||
const [plan, setPlan] = useState<ImportPlan | null>(null);
|
||||
const [destinationId, setDestinationId] = useState<string>(NEW_WORKSPACE);
|
||||
const [items, setItems] = useState<ImportPlanItem[]>([]);
|
||||
// null means no explicit choice yet, so the default below applies
|
||||
const [destinationChoice, setDestinationChoice] = useState<"new" | "current" | "other" | null>(
|
||||
null,
|
||||
);
|
||||
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
|
||||
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
|
||||
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
|
||||
prefill != null ? initialSources : [],
|
||||
);
|
||||
const [originSources, setOriginSources] = useState<ImportSource[]>(
|
||||
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] = useLocalStorage<string | null>("importPathOrUrl", null);
|
||||
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
|
||||
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
|
||||
const [isHovering, setIsHovering] = useState<boolean>(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -97,6 +152,63 @@ export function ImportDataDialog({
|
||||
});
|
||||
}, [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;
|
||||
@@ -105,16 +217,15 @@ export function ImportDataDialog({
|
||||
|
||||
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
|
||||
// is also the destination.
|
||||
const canTargetSelectedFolder =
|
||||
selectedFolder != null && destinationId === currentWorkspace?.id;
|
||||
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
|
||||
|
||||
const destination = (): ImportDestination => {
|
||||
if (destinationId === NEW_WORKSPACE) {
|
||||
if (destinationWorkspaceId == null) {
|
||||
return { type: "new_workspace" };
|
||||
}
|
||||
return {
|
||||
type: "existing_workspace",
|
||||
workspaceId: destinationId,
|
||||
workspaceId: destinationWorkspaceId,
|
||||
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
|
||||
};
|
||||
};
|
||||
@@ -127,6 +238,7 @@ export function ImportDataDialog({
|
||||
? await planFile(filePath, destination())
|
||||
: await planUrl(trimmedSource, destination());
|
||||
setPlan(nextPlan);
|
||||
setItems(nextPlan.items);
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
@@ -138,7 +250,7 @@ export function ImportDataDialog({
|
||||
if (plan == null) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await commit(plan);
|
||||
await commit({ ...plan, items });
|
||||
} catch (err) {
|
||||
onError(err);
|
||||
} finally {
|
||||
@@ -146,15 +258,62 @@ export function ImportDataDialog({
|
||||
}
|
||||
};
|
||||
|
||||
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<ImportPlanItem>, checked: boolean) => {
|
||||
const targets = new Set(
|
||||
collectItems(node)
|
||||
.filter((i) => togglesWith(node.data, i))
|
||||
.map((i) => i.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)),
|
||||
);
|
||||
};
|
||||
|
||||
// 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.
|
||||
const disabledIds = useMemo(() => {
|
||||
const disabled = new Set<string>();
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const item of items) {
|
||||
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 || parent.model !== "folder") break;
|
||||
if (parent.action === "create" && !parent.selected && item.action !== "delete") {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
if (parent.action === "delete" && parent.selected && item.action === "delete") {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
return disabled;
|
||||
}, [items]);
|
||||
|
||||
if (plan != null) {
|
||||
const counts = [
|
||||
[plan.resources.workspaces[0], plan.resources.workspaces.length],
|
||||
[plan.resources.environments[0], plan.resources.environments.length],
|
||||
[plan.resources.folders[0], plan.resources.folders.length],
|
||||
[plan.resources.httpRequests[0], plan.resources.httpRequests.length],
|
||||
[plan.resources.grpcRequests[0], plan.resources.grpcRequests.length],
|
||||
[plan.resources.websocketRequests[0], plan.resources.websocketRequests.length],
|
||||
] as const;
|
||||
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") return "New workspace";
|
||||
const { workspaceId, folderId } = plan.destination;
|
||||
@@ -164,6 +323,28 @@ export function ImportDataDialog({
|
||||
: 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<ImportPlanItem> = (() => {
|
||||
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: {
|
||||
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,
|
||||
},
|
||||
children: itemTree,
|
||||
};
|
||||
})();
|
||||
|
||||
return (
|
||||
<VStack space={4} className="pb-4">
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
@@ -171,15 +352,15 @@ export function ImportDataDialog({
|
||||
<PreviewRow label="Destination" value={destinationLabel} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-1">Resources</div>
|
||||
<ul className="list-disc pl-6 text-sm text-text-subtle">
|
||||
{counts.map(([model, count]) =>
|
||||
model == null ? null : (
|
||||
<li key={model.model}>{pluralizeCount(modelTypeLabel(model), count)}</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
|
||||
<CheckboxTree
|
||||
node={workspaceRoot}
|
||||
checked={nodeCheckedStatus}
|
||||
onCheck={toggleNode}
|
||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
||||
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
|
||||
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{plan.warnings.length > 0 && (
|
||||
@@ -202,18 +383,39 @@ export function ImportDataDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
|
||||
Cancel
|
||||
<HStack space={2} alignItems="center" className="mt-3">
|
||||
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
|
||||
<Button
|
||||
className="ml-auto"
|
||||
color="secondary"
|
||||
variant="border"
|
||||
disabled={isLoading}
|
||||
onClick={() => {
|
||||
setPlan(null);
|
||||
setItems([]);
|
||||
}}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
|
||||
{isLoading ? "Importing" : "Confirm Import"}
|
||||
{isLoading
|
||||
? "Importing"
|
||||
: changeCount > 0
|
||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
||||
: "Apply"}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
const lastImported =
|
||||
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
|
||||
linkedSources.reduce<ImportSource | null>(
|
||||
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
|
||||
null,
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack ref={ref} space={4} className="pb-4">
|
||||
<CommercialUseBanner source="data-import" title="Importing work data?" />
|
||||
@@ -224,7 +426,9 @@ export function ImportDataDialog({
|
||||
className={classNames(
|
||||
"w-full rounded-lg border border-dashed px-4 py-6",
|
||||
"flex flex-col items-center gap-1 text-center",
|
||||
isHovering ? "border-notice bg-surface-highlight" : "border-border hover:border-text",
|
||||
isHovering
|
||||
? "border-notice bg-surface-highlight"
|
||||
: "border-border hover:border-text-subtle",
|
||||
)}
|
||||
>
|
||||
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
|
||||
@@ -256,24 +460,60 @@ export function ImportDataDialog({
|
||||
|
||||
<VStack space={2}>
|
||||
<Select
|
||||
name="import-destination"
|
||||
name="import-destination-kind"
|
||||
label="Import location"
|
||||
size="sm"
|
||||
value={destinationId}
|
||||
onChange={setDestinationId}
|
||||
// The native macOS select drops separators
|
||||
filterable
|
||||
value={destinationKind}
|
||||
onChange={setDestinationChoice}
|
||||
options={[
|
||||
{ value: NEW_WORKSPACE, label: "New Workspace" },
|
||||
...(workspaces.length > 0
|
||||
? [{ type: "separator" as const, label: "Existing Workspaces" }]
|
||||
{ value: "new", label: "New Workspace" },
|
||||
...(currentWorkspace != null
|
||||
? [{ value: "current" as const, label: "Current Workspace" }]
|
||||
: []),
|
||||
...workspaces.map((w) => ({
|
||||
value: w.id,
|
||||
label: w.id === currentWorkspace?.id ? `${w.name} (current workspace)` : w.name,
|
||||
})),
|
||||
{ value: "other", label: "Other Workspace" },
|
||||
]}
|
||||
/>
|
||||
{destinationKind === "other" && (
|
||||
<Select
|
||||
name="import-destination-workspace"
|
||||
label="Workspace"
|
||||
hideLabel
|
||||
size="sm"
|
||||
value={otherWorkspaceId ?? ""}
|
||||
onChange={(id) => 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 ? (
|
||||
<div className="text-xs text-text-subtle">
|
||||
Last imported from {lastImported.originLabel} ·{" "}
|
||||
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
|
||||
</div>
|
||||
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
|
||||
<div className="text-xs text-text-subtle">
|
||||
This file was last imported into{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline hocus:text-text"
|
||||
onClick={() => {
|
||||
if (linkedWorkspace.id === currentWorkspace?.id) {
|
||||
setDestinationChoice("current");
|
||||
} else {
|
||||
setDestinationChoice("other");
|
||||
setOtherWorkspaceId(linkedWorkspace.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{linkedWorkspace.name}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{canTargetSelectedFolder && (
|
||||
<Checkbox
|
||||
checked={targetSelectedFolder}
|
||||
@@ -289,7 +529,11 @@ export function ImportDataDialog({
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={trimmedSource === "" || isLoading}
|
||||
disabled={
|
||||
trimmedSource === "" ||
|
||||
isLoading ||
|
||||
(destinationKind === "other" && otherWorkspaceId == null)
|
||||
}
|
||||
isLoading={isLoading}
|
||||
onClick={handlePreview}
|
||||
>
|
||||
@@ -300,6 +544,154 @@ export function ImportDataDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function ImportTreeRow({
|
||||
item,
|
||||
onResolveConflict,
|
||||
}: {
|
||||
item: ImportPlanItem;
|
||||
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{item.name}</div>
|
||||
{item.action === "conflict" ? (
|
||||
<div className="shrink-0 flex items-center gap-1.5">
|
||||
<SegmentedControl
|
||||
name={`conflict-${item.modelId}`}
|
||||
label={`Resolve conflict for ${item.name}`}
|
||||
hideLabel
|
||||
value={item.resolution ?? "keep_mine"}
|
||||
onChange={(v) => onResolveConflict(item.modelId, v)}
|
||||
options={[
|
||||
{ value: "keep_mine", label: "Keep mine" },
|
||||
{ value: "take_source", label: "Take source" },
|
||||
]}
|
||||
/>
|
||||
<IconTooltip content={actionHelp(item)} iconSize="sm" />
|
||||
</div>
|
||||
) : (
|
||||
actionLabel(item) && (
|
||||
<InlineCode
|
||||
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 === "update" && "text-info",
|
||||
item.action === "delete" && "text-danger",
|
||||
item.action === "keep_local" && item.selected && "text-warning",
|
||||
)}
|
||||
>
|
||||
{actionLabel(item)}
|
||||
<IconTooltip content={actionHelp(item)} iconSize="xs" />
|
||||
</InlineCode>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function actionHelp(item: ImportPlanItem): string | null {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "Added since the last import";
|
||||
case "update":
|
||||
return "Changed since the last import";
|
||||
case "delete":
|
||||
return "Deleted since the last import";
|
||||
case "keep_local":
|
||||
return "Local edits made since the last import. Importing will revert them if checked";
|
||||
case "conflict":
|
||||
return "Changed both here and in the file since the last import";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem>[] {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
const childrenOf = new Map<string, ImportPlanItem[]>();
|
||||
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 foldersFirst = (list: ImportPlanItem[]) => [
|
||||
...list.filter((i) => i.model === "folder"),
|
||||
...list.filter((i) => i.model !== "folder"),
|
||||
];
|
||||
|
||||
const toNode = (item: ImportPlanItem, seen: Set<string>): CheckboxTreeNode<ImportPlanItem> => ({
|
||||
key: item.modelId,
|
||||
data: item,
|
||||
children: seen.has(item.modelId)
|
||||
? []
|
||||
: foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||
toNode(c, new Set([...seen, item.modelId])),
|
||||
),
|
||||
});
|
||||
|
||||
return foldersFirst(roots).map((r) => toNode(r, new Set()));
|
||||
}
|
||||
|
||||
function collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
|
||||
return [node.data, ...node.children.flatMap(collectItems)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions
|
||||
* (deletions, reverting local edits) never ride along with a parent toggle.
|
||||
*/
|
||||
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 nodeCheckedStatus(
|
||||
node: CheckboxTreeNode<ImportPlanItem>,
|
||||
): boolean | "indeterminate" | "hidden" {
|
||||
const covered = collectItems(node).filter((i) => togglesWith(node.data, i));
|
||||
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 (
|
||||
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Icon } from "@yaakapp-internal/ui";
|
||||
import classNames from "classnames";
|
||||
import type { ReactNode } from "react";
|
||||
import { useState } from "react";
|
||||
import type { CheckboxProps } from "./Checkbox";
|
||||
import { Checkbox } from "./Checkbox";
|
||||
|
||||
export interface CheckboxTreeNode<T> {
|
||||
key: string;
|
||||
data: T;
|
||||
children: CheckboxTreeNode<T>[];
|
||||
}
|
||||
|
||||
interface Props<T> {
|
||||
node: CheckboxTreeNode<T>;
|
||||
depth?: number;
|
||||
/** Return "hidden" to render row alignment space instead of a checkbox */
|
||||
checked: (node: CheckboxTreeNode<T>) => CheckboxProps["checked"] | "hidden";
|
||||
onCheck: (node: CheckboxTreeNode<T>, checked: boolean) => void;
|
||||
checkboxTitle?: (node: CheckboxTreeNode<T>) => string;
|
||||
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
/** An irrelevant row is hidden unless one of its descendants is relevant */
|
||||
isRelevant: (node: CheckboxTreeNode<T>) => boolean;
|
||||
renderRow: (node: CheckboxTreeNode<T>) => ReactNode;
|
||||
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
|
||||
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
isRowSelected?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
}
|
||||
|
||||
export function CheckboxTree<T>(props: Props<T>) {
|
||||
const { node, depth = 0 } = props;
|
||||
const [collapsed, setCollapsed] = useState<boolean>(false);
|
||||
if (!hasRelevantNode(node, props.isRelevant)) return null;
|
||||
|
||||
const checked = props.checked(node);
|
||||
const selected = props.isRowSelected?.(node) ?? false;
|
||||
const selectable = props.onSelectRow != null && (props.canSelectRow?.(node) ?? true);
|
||||
const hasVisibleChildren = node.children.some((c) => hasRelevantNode(c, props.isRelevant));
|
||||
const rowContent = (
|
||||
<div className="flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left">
|
||||
{props.renderRow(node)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"relative flex gap-1 w-full h-xs items-center",
|
||||
selected ? "text-text" : "text-text-subtle",
|
||||
)}
|
||||
>
|
||||
{selected && (
|
||||
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
||||
)}
|
||||
{hasVisibleChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={collapsed ? "Expand" : "Collapse"}
|
||||
aria-expanded={!collapsed}
|
||||
className="shrink-0 text-text-subtlest hocus:text-text"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
<Icon size="sm" icon={collapsed ? "chevron_right" : "chevron_down"} />
|
||||
</button>
|
||||
) : (
|
||||
<span aria-hidden className="w-4 shrink-0" />
|
||||
)}
|
||||
{checked === "hidden" ? (
|
||||
<span aria-hidden className="w-4 mr-0.5 shrink-0" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
title={props.checkboxTitle?.(node) ?? "Toggle"}
|
||||
hideLabel
|
||||
disabled={props.isCheckboxDisabled?.(node)}
|
||||
onChange={(checked) => props.onCheck(node, checked)}
|
||||
/>
|
||||
)}
|
||||
{selectable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 min-w-0 flex text-left"
|
||||
onClick={() => props.onSelectRow?.(node)}
|
||||
>
|
||||
{rowContent}
|
||||
</button>
|
||||
) : (
|
||||
rowContent
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed &&
|
||||
node.children.map((child) => (
|
||||
<CheckboxTree key={child.key} {...props} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasRelevantNode<T>(
|
||||
node: CheckboxTreeNode<T>,
|
||||
isRelevant: (node: CheckboxTreeNode<T>) => boolean,
|
||||
): boolean {
|
||||
return isRelevant(node) || node.children.some((c) => hasRelevantNode(c, isRelevant));
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||
import { Button } from "../core/Button";
|
||||
import type { CheckboxProps } from "../core/Checkbox";
|
||||
import { Checkbox } from "../core/Checkbox";
|
||||
import type { CheckboxTreeNode } from "../core/CheckboxTree";
|
||||
import { CheckboxTree } from "../core/CheckboxTree";
|
||||
import { DiffViewer } from "../core/Editor/DiffViewer";
|
||||
import { Input } from "../core/Input";
|
||||
import { Separator } from "../core/Separator";
|
||||
@@ -43,10 +45,7 @@ interface CommitTreeNode {
|
||||
|
||||
export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
const callbacks = useGitCallbacks(syncDir);
|
||||
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(
|
||||
syncDir,
|
||||
callbacks,
|
||||
);
|
||||
const [{ status }, { commit, commitAndPush, add, unstage, restore }] = useGit(syncDir, callbacks);
|
||||
const [isPushing, setIsPushing] = useState(false);
|
||||
const [commitError, setCommitError] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string>("");
|
||||
@@ -143,6 +142,15 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
return next(workspace, []);
|
||||
}, [workspace, internalEntries]);
|
||||
|
||||
const treeNode: CheckboxTreeNode<CommitTreeNode> | null = useMemo(() => {
|
||||
const toTreeNode = (n: CommitTreeNode): CheckboxTreeNode<CommitTreeNode> => ({
|
||||
key: n.status.relaPath + n.status.status + n.status.staged,
|
||||
data: n,
|
||||
children: n.children.map(toTreeNode),
|
||||
});
|
||||
return tree == null ? null : toTreeNode(tree);
|
||||
}, [tree]);
|
||||
|
||||
const checkNode = useCallback(
|
||||
(treeNode: CommitTreeNode) => {
|
||||
const checked = nodeCheckedStatus(treeNode);
|
||||
@@ -190,7 +198,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
[restore],
|
||||
);
|
||||
|
||||
if (tree == null) {
|
||||
if (tree == null || treeNode == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -221,12 +229,18 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
style={innerStyle}
|
||||
className="h-full overflow-y-auto pb-3 pr-0.5 transform-cpu"
|
||||
>
|
||||
<TreeNodeChildren
|
||||
node={tree}
|
||||
depth={0}
|
||||
onCheck={checkNode}
|
||||
onSelect={handleSelectChild}
|
||||
selectedPath={selectedEntry?.relaPath ?? null}
|
||||
<CheckboxTree
|
||||
node={treeNode}
|
||||
checked={(n) => nodeCheckedStatus(n.data)}
|
||||
onCheck={(n) => checkNode(n.data)}
|
||||
checkboxTitle={(n) =>
|
||||
nodeCheckedStatus(n.data) ? "Unstage change" : "Stage change"
|
||||
}
|
||||
isRelevant={(n) => n.data.status.status !== "current"}
|
||||
canSelectRow={(n) => n.data.status.status !== "current"}
|
||||
onSelectRow={(n) => handleSelectChild(n.data.status)}
|
||||
isRowSelected={(n) => selectedEntry?.relaPath === n.data.status.relaPath}
|
||||
renderRow={(n) => <CommitTreeRow node={n.data} />}
|
||||
/>
|
||||
{externalEntries.find((e) => e.status !== "current") && (
|
||||
<>
|
||||
@@ -244,10 +258,7 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
</div>
|
||||
)}
|
||||
secondSlot={({ style: innerStyle }) => (
|
||||
<div
|
||||
style={innerStyle}
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2"
|
||||
>
|
||||
<div style={innerStyle} className="grid grid-rows-[minmax(0,1fr)_auto] gap-3 pb-2">
|
||||
<Input
|
||||
className="text-base! font-sans rounded-md"
|
||||
placeholder="Commit message..."
|
||||
@@ -301,96 +312,39 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
function TreeNodeChildren({
|
||||
node,
|
||||
depth,
|
||||
onCheck,
|
||||
onSelect,
|
||||
selectedPath,
|
||||
}: {
|
||||
node: CommitTreeNode | null;
|
||||
depth: number;
|
||||
onCheck: (node: CommitTreeNode, checked: boolean) => void;
|
||||
onSelect: (entry: GitStatusEntry) => void;
|
||||
selectedPath: string | null;
|
||||
}) {
|
||||
if (node === null) return null;
|
||||
if (!isNodeRelevant(node)) return null;
|
||||
|
||||
const checked = nodeCheckedStatus(node);
|
||||
const isSelected = selectedPath === node.status.relaPath;
|
||||
|
||||
function CommitTreeRow({ node }: { node: CommitTreeNode }) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
depth > 0 && "pl-4 ml-2 border-l border-dashed border-border-subtle relative",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"relative flex gap-1 w-full h-xs items-center",
|
||||
isSelected ? "text-text" : "text-text-subtle",
|
||||
)}
|
||||
>
|
||||
{isSelected && (
|
||||
<div className="absolute left-[-100vw] right-0 top-0 bottom-0 bg-surface-active opacity-30 -z-10" />
|
||||
)}
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
title={checked ? "Unstage change" : "Stage change"}
|
||||
hideLabel
|
||||
onChange={(checked) => onCheck(node, checked)}
|
||||
<>
|
||||
{node.model.model !== "http_request" &&
|
||||
node.model.model !== "grpc_request" &&
|
||||
node.model.model !== "websocket_request" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
node.model.model === "folder"
|
||||
? "folder"
|
||||
: node.model.model === "environment"
|
||||
? "variable"
|
||||
: "house"
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames("flex-1 min-w-0 flex items-center gap-1 px-1 py-0.5 text-left")}
|
||||
onClick={() => node.status.status !== "current" && onSelect(node.status)}
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
||||
{node.status.status !== "current" && (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-24 text-center shrink-0",
|
||||
node.status.status === "modified" && "text-info",
|
||||
node.status.status === "untracked" && "text-success",
|
||||
node.status.status === "removed" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{node.model.model !== "http_request" &&
|
||||
node.model.model !== "grpc_request" &&
|
||||
node.model.model !== "websocket_request" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
node.model.model === "folder"
|
||||
? "folder"
|
||||
: node.model.model === "environment"
|
||||
? "variable"
|
||||
: "house"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
|
||||
{node.status.status !== "current" && (
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-24 text-center shrink-0",
|
||||
node.status.status === "modified" && "text-info",
|
||||
node.status.status === "untracked" && "text-success",
|
||||
node.status.status === "removed" && "text-danger",
|
||||
)}
|
||||
>
|
||||
{node.status.status}
|
||||
</InlineCode>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{node.children.map((childNode) => {
|
||||
return (
|
||||
<TreeNodeChildren
|
||||
key={childNode.status.relaPath + childNode.status.status + childNode.status.staged}
|
||||
node={childNode}
|
||||
depth={depth + 1}
|
||||
onCheck={onCheck}
|
||||
onSelect={onSelect}
|
||||
selectedPath={selectedPath}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{node.status.status}
|
||||
</InlineCode>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -495,15 +449,6 @@ function setCheckedAndChildren(
|
||||
if (toUnstage.length > 0) unstage({ relaPaths: toUnstage });
|
||||
}
|
||||
|
||||
function isNodeRelevant(node: CommitTreeNode): boolean {
|
||||
if (node.status.status !== "current") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Recursively check children
|
||||
return node.children.some((c) => isNodeRelevant(c));
|
||||
}
|
||||
|
||||
function DiffPanel({
|
||||
entry,
|
||||
onDiscardChanges,
|
||||
@@ -526,13 +471,11 @@ function DiffPanel({
|
||||
size="2xs"
|
||||
variant="border"
|
||||
onClick={() => onDiscardChanges(entry)}
|
||||
>Discard Changes</Button>
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</div>
|
||||
<DiffViewer
|
||||
original={prevYaml ?? ""}
|
||||
modified={nextYaml ?? ""}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
<DiffViewer original={prevYaml ?? ""} modified={nextYaml ?? ""} className="flex-1 min-h-0" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
type BatchUpsertResult,
|
||||
type ImportDestination,
|
||||
type ImportPlan,
|
||||
type ImportSource,
|
||||
workspacesAtom,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { FormattedError, VStack } from "@yaakapp-internal/ui";
|
||||
@@ -17,6 +18,17 @@ import { pluralizeCount } from "./pluralize";
|
||||
import { router } from "./router";
|
||||
import { rpc } from "./rpc";
|
||||
|
||||
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
|
||||
// fetches) every time the dialog container re-renders.
|
||||
const planFile = (filePath: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
|
||||
const planUrl = (url: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_url", { url, destination });
|
||||
const listSources = (workspaceId: string) =>
|
||||
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
|
||||
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
|
||||
rpc<ImportSource[]>("cmd_import_sources_for_origin", args);
|
||||
|
||||
export const importData = createFastMutation({
|
||||
mutationKey: ["import_data"],
|
||||
onError: (err: string) => {
|
||||
@@ -35,7 +47,7 @@ export const importData = createFastMutation({
|
||||
showDialog({
|
||||
id: "import",
|
||||
title: "Import Data",
|
||||
size: "sm",
|
||||
size: "lg",
|
||||
disableClose: true,
|
||||
render: ({ hide }) => {
|
||||
const cancel = () => {
|
||||
@@ -57,12 +69,10 @@ export const importData = createFastMutation({
|
||||
currentWorkspace={currentWorkspace}
|
||||
workspaces={workspaces}
|
||||
selectedFolder={selectedFolder}
|
||||
planFile={(filePath: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_data", { filePath, destination })
|
||||
}
|
||||
planUrl={(url: string, destination: ImportDestination) =>
|
||||
rpc<ImportPlan>("cmd_import_url", { url, destination })
|
||||
}
|
||||
planFile={planFile}
|
||||
planUrl={planUrl}
|
||||
listSources={listSources}
|
||||
findSourcesForOrigin={findSourcesForOrigin}
|
||||
commit={commit}
|
||||
cancel={cancel}
|
||||
onError={fail}
|
||||
|
||||
Reference in New Issue
Block a user