mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-09-08 19:02:00 +02:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
213458b60c | ||
|
|
cdc034b25a | ||
|
|
83f2606624 | ||
|
|
fd5cb448bb | ||
|
|
33378741af | ||
|
|
0dbefd4283 | ||
|
|
82ee025cd3 | ||
|
|
a3e6afcdc9 | ||
|
|
64b9479deb | ||
|
|
6796569466 | ||
|
|
72d3bda769 | ||
|
|
bd932ce85f |
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ 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 { 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";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { pluralize } from "../lib/pluralize";
|
import { pluralize, pluralizeCount } from "../lib/pluralize";
|
||||||
import { CommercialUseBanner } from "./CommercialUseBanner";
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
@@ -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;
|
||||||
@@ -315,7 +309,11 @@ function LoadedImportDataDialog({
|
|||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
const destinationLabel = (() => {
|
const destinationLabel = (() => {
|
||||||
if (plan.destination.type === "new_workspace") return "New workspace";
|
if (plan.destination.type === "new_workspace") {
|
||||||
|
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
|
||||||
|
if (names.length > 1) return pluralizeCount("new workspace", names.length);
|
||||||
|
return names[0] == null ? "New workspace" : `New workspace · ${names[0]}`;
|
||||||
|
}
|
||||||
const { workspaceId, folderId } = plan.destination;
|
const { 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 folderId != null && folderId === selectedFolder?.id
|
||||||
@@ -325,7 +323,7 @@ function LoadedImportDataDialog({
|
|||||||
|
|
||||||
// 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 +333,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,
|
||||||
};
|
};
|
||||||
@@ -358,8 +354,12 @@ function LoadedImportDataDialog({
|
|||||||
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>
|
||||||
|
|
||||||
@@ -372,7 +372,12 @@ function LoadedImportDataDialog({
|
|||||||
key={`${warning.title}:${warning.detail}`}
|
key={`${warning.title}:${warning.detail}`}
|
||||||
className="flex items-start gap-2.5 px-3 py-2.5"
|
className="flex items-start gap-2.5 px-3 py-2.5"
|
||||||
>
|
>
|
||||||
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
|
<Icon
|
||||||
|
icon={warning.level === "warning" ? "alert_triangle" : "info"}
|
||||||
|
color={warning.level === "warning" ? "warning" : "info"}
|
||||||
|
size="sm"
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium">{warning.title}</div>
|
<div className="text-sm font-medium">{warning.title}</div>
|
||||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||||
@@ -402,7 +407,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 +550,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 +593,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 +646,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 "Added since the last import";
|
||||||
case "update":
|
case "update":
|
||||||
return "Changed since the last import";
|
return help("Changed since the last import");
|
||||||
case "delete":
|
case "delete":
|
||||||
return "Deleted since the last import";
|
return item.reason === "moved_into_ignored_folder"
|
||||||
|
? "Moved into an ignored folder. Import that folder instead to follow the move"
|
||||||
|
: "Deleted since the last import";
|
||||||
case "keep_local":
|
case "keep_local":
|
||||||
return "Local edits made since the last import. Importing will revert them if checked";
|
return help("Local edits made since the last import. Importing will revert them if checked");
|
||||||
case "conflict":
|
case "conflict":
|
||||||
return "Changed both here and in the file since the last import";
|
return help("Changed both here and in the file since the last import");
|
||||||
|
case "ignored":
|
||||||
|
return "In the file, but ignored. Check it to import it";
|
||||||
default:
|
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 +721,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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
+8
-2
@@ -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 };
|
||||||
|
|||||||
Generated
+82
-23
@@ -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;
|
||||||
@@ -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(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
@@ -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 };
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
+861
-126
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user