Compare commits

...
Author SHA1 Message Date
Gregory SchierandClaude Opus 5 213458b60c feat(import): warn that a document already has a workspace
Renaming the second workspace was the mechanism, not the news: what
matters is that this document already produced a workspace, so importing
it here copies it instead of updating it. The plan says that outright,
names the workspace, and does it on key overlap rather than on the name —
so it holds after either side has been renamed, and stays quiet when the
import is merging into that very workspace.

Plan notes carry a level so the ones worth a second thought read as
cautions, and the destination row gets its "new" chip back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:56:44 -07:00
Gregory SchierandClaude Opus 5 cdc034b25a fix(import): parenthesize the number on a taken workspace name
"Name 2" reads as part of the name; "Name (2)" reads as the second one.
The destination row leads with "New workspace ·" rather than trailing a
second parenthetical after it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:51:42 -07:00
Gregory SchierandClaude Opus 5 83f2606624 fix(import): name the new workspace in the preview's destination row
The row read "New workspace" no matter what the import was about to
create, which matters more now that the name can come back numbered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:50:28 -07:00
Gregory SchierandClaude Opus 5 fd5cb448bb feat(import): number a new workspace whose name is taken
Importing a document into a new workspace twice left two workspaces with
the same name and nothing in the sidebar to tell them apart. The second
one becomes "Name 2", and the preview's details say so rather than
renaming behind the user's back.

The " Copy N" convention duplication uses would claim something was
copied, which is not what happened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:45:40 -07:00
Gregory SchierandClaude Opus 5 33378741af feat(import): group the workspace's environments under one heading
An imported OpenAPI document brings a base environment plus one per
server, and they landed as loose rows at the top of the tree with no way
to take them as a set. They can't nest under anything real — a
sub-environment is a sibling of the base one, not its child — so a
heading groups them and its checkbox turns the set on and off.

The tree's rows are now either a plan item or a heading, which is what
the destination workspace has always been: it no longer has to pose as a
plan item with an invented action and selection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:39:04 -07:00
Gregory SchierandClaude Opus 5 0dbefd4283 refactor(import): give the preview one rule for what a checkbox carries
A parent's checkbox covered creates and updates but skipped deletions and
local-edit reverts, so a folder holding only those got no checkbox at all
and the ones it did get moved a subset of what sat under them. The labels
already mark destructive rows, so a parent now carries everything beneath
it that has a checkbox of its own.

Checking a row also brings in the folders it needs, which is the same rule
read upwards and leaves nothing to disable: a resource can no longer be
checked inside a folder that will not exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:32:06 -07:00
Gregory SchierandClaude Opus 5 82ee025cd3 refactor(import): call a turned-down resource ignored
"Not imported" was two words in a column of one-word states, and it read
as a description of this run rather than the standing decision it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:27:17 -07:00
Gregory SchierandClaude Opus 5 a3e6afcdc9 fix(import): list environments above folders in the preview
Sorting put folders first and swept environments in with the requests, so
the two environments an OpenAPI import creates landed underneath every
folder in the document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:23:14 -07:00
Gregory SchierandClaude Opus 5 64b9479deb fix(import): treat a pair's row ID as identity, not content
Headers, URL parameters, and environment variables each carry an `id`
that identifies the row to the pair editor. Importers leave it empty and
the editor stamps one in the first time it touches a resource, so merely
opening an imported request made the next import report it as locally
edited. `comparable` now drops `id` at every level rather than only the
top, since a row ID says no more about content than the model's own ID.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:31:12 -07:00
Gregory SchierandClaude Opus 5 6796569466 feat(import): say which fields differ, and stop offering to apply nothing
A row marked "edited" or "conflict" gave no way to tell what actually
differs, so a resource that drifted from the file — however it drifted —
reads as an unexplained accusation. The plan now carries the field names
the source and the local copy disagree on, and the row's tooltip lists
them.

The preview's primary button also read "Apply" when the current selection
would change nothing. It now reads "Done", since committing an empty
selection only records the decisions made in the preview.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:24:25 -07:00
Gregory SchierandClaude Opus 5 72d3bda769 fix(import): shrink the conflict control and fold its help inside it
The conflict row's segmented control ran at the default size with the help
icon floating outside the group. Drop it to the smallest button size and
hand the help to the control, which shows it in its last option whenever
the label is hidden — the only case where the label has nowhere to put it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:13:19 -07:00
Gregory SchierandClaude Opus 5 bd932ce85f feat(import): remember the user's import selection
Yaak now remembers, per item of a linked import source, whether the user
wants it. A mapping row that still points at a model means wanted, so
re-imports merge into it; a row with no model behind it means not wanted,
so re-imports leave it alone. Wanted-ness is structural rather than a flag:
deleting the model locally, or turning the item down in the preview, is
what makes it not wanted.

- import_source_resources drops `snapshot` for a nullable `content_hash`
  and lets `model_id` be NULL. The migration keeps every beta row's
  key to model mapping and starts hashes empty; until a hash is recorded,
  a difference can't be attributed to either side, so the item is offered
  once as a conflict that keeps local changes.
- comparable() ignores sortPriority. Importers number it from source
  order, so inserting one operation used to plan a fake update for
  everything after it. The trade is that pure reorders don't propagate.
- Keys the user turned down come back as unchecked "not imported" rows
  instead of being re-offered as new resources or resurrected. Checking
  one imports it under the same source key and pulls in the folders it
  needs.
- An imported resource the source moves into a folder that isn't imported
  is offered as a deletion, with a tooltip pointing at the folder.
- Plan-time source resolution binds by source-key overlap instead of by
  path, so a renamed or moved file merges into what it created and heals
  the stored origin. Several sources sharing keys never guess-merge: the
  plan says so and imports everything as new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 10:56:34 -07:00
17 changed files with 1380 additions and 274 deletions
Generated
+1
View File
@@ -11228,6 +11228,7 @@ dependencies = [
"md5 0.8.0", "md5 0.8.0",
"rusqlite", "rusqlite",
"serde_json", "serde_json",
"sha2",
"tempfile", "tempfile",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
+174 -78
View File
@@ -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 ( return (
<> <>
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? ( <Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
<Icon <div className="truncate flex-1">{row.label}</div>
color="secondary" {row.kind === "destination" && row.isNew && (
icon={ <ActionChip label="new" help="Created by this import" className="text-success" />
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable" )}
</>
);
} }
/>
const { item } = row;
const label = actionLabel(item);
return (
<>
{item.model === "folder" || item.model === "environment" ? (
<Icon color="secondary" icon={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");
}
-11
View File
@@ -331,17 +331,6 @@ export type ImportSource = {
lastImportedAt: string; lastImportedAt: string;
}; };
export type ImportSourceResource = {
model: "import_source_resource";
createdAt: string;
updatedAt: string;
importSourceId: string;
sourceKey: string;
modelType: string;
modelId: string;
snapshot: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+71 -12
View File
@@ -1,7 +1,21 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models"; import type {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, }; export type BatchUpsertResult = {
workspaces: Array<Workspace>;
environments: Array<Environment>;
folders: Array<Folder>;
httpRequests: Array<HttpRequest>;
grpcRequests: Array<GrpcRequest>;
websocketRequests: Array<WebsocketRequest>;
};
export type ImportConflictResolution = "keep_mine" | "take_source"; export type ImportConflictResolution = "keep_mine" | "take_source";
@@ -11,7 +25,9 @@ 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.
@@ -20,29 +36,72 @@ 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;
destination: ImportDestination;
resources: BatchUpsertResult;
warnings: Array<ImportPlanWarning>;
/** /**
* Stable source key for every model in `resources`, keyed by its planned ID. * Stable source key for every model in `resources`, keyed by its planned ID.
*/ */
sourceKeys: { [key in string]?: string }, sourceKeys: { [key in string]?: string };
/** /**
* One entry per plannable resource; commit applies only the selected ones. * One entry per plannable resource; commit applies only the selected ones.
*/ */
items: Array<ImportPlanItem>, origin?: ImportOrigin, }; 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, export type ImportPlanItem = {
action: ImportPlanAction;
model: ImportResourceType;
modelId: string;
name: string;
/** /**
* Planned parent folder ID for incoming resources; current parent for deletions. * Planned parent folder ID for incoming resources; current parent for deletions.
*/ */
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, }; 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 ImportPlanWarning = { title: string, detail: string, }; /**
* Extra context for an action that would otherwise be indistinguishable from its plain form.
*/
export type ImportPlanReason = "moved_into_ignored_folder";
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
View File
@@ -356,8 +356,14 @@ export type ImportSourceResource = {
importSourceId: string; importSourceId: string;
sourceKey: string; sourceKey: string;
modelType: string; modelType: string;
modelId: string; /**
snapshot: string; * `None` once the user has decided not to import this key
*/
modelId?: string;
/**
* Hash of the resource as last applied or decided from the source, if one was recorded
*/
contentHash?: string;
}; };
export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
+71 -12
View File
@@ -1,7 +1,21 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models"; import type {
Environment,
Folder,
GrpcRequest,
HttpRequest,
WebsocketRequest,
Workspace,
} from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, }; export type BatchUpsertResult = {
workspaces: Array<Workspace>;
environments: Array<Environment>;
folders: Array<Folder>;
httpRequests: Array<HttpRequest>;
grpcRequests: Array<GrpcRequest>;
websocketRequests: Array<WebsocketRequest>;
};
export type ImportConflictResolution = "keep_mine" | "take_source"; export type ImportConflictResolution = "keep_mine" | "take_source";
@@ -11,7 +25,9 @@ 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.
@@ -20,29 +36,72 @@ 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;
destination: ImportDestination;
resources: BatchUpsertResult;
warnings: Array<ImportPlanWarning>;
/** /**
* Stable source key for every model in `resources`, keyed by its planned ID. * Stable source key for every model in `resources`, keyed by its planned ID.
*/ */
sourceKeys: { [key in string]?: string }, sourceKeys: { [key in string]?: string };
/** /**
* One entry per plannable resource; commit applies only the selected ones. * One entry per plannable resource; commit applies only the selected ones.
*/ */
items: Array<ImportPlanItem>, origin?: ImportOrigin, }; 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, export type ImportPlanItem = {
action: ImportPlanAction;
model: ImportResourceType;
modelId: string;
name: string;
/** /**
* Planned parent folder ID for incoming resources; current parent for deletions. * Planned parent folder ID for incoming resources; current parent for deletions.
*/ */
parentId?: string, selected: boolean, resolution?: ImportConflictResolution, }; 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 ImportPlanWarning = { title: string, detail: string, }; /**
* Extra context for an action that would otherwise be indistinguishable from its plain form.
*/
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
* Whether a plan's note is something to know or something to think twice about.
*/
export type ImportPlanWarningLevel = "info" | "warning";
/** /**
* The model types an import plan can contain. * The model types an import plan can contain.
*/ */
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace"; export type ImportResourceType =
| "environment"
| "folder"
| "grpc_request"
| "http_request"
| "websocket_request"
| "workspace";
@@ -0,0 +1,24 @@
-- Replace the per-resource snapshot with a content hash, and let a row exist without a model
-- so a resource the user chose not to import can be remembered.
CREATE TABLE import_source_resources_new
(
model TEXT DEFAULT 'import_source_resource' NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
import_source_id TEXT NOT NULL,
source_key TEXT NOT NULL,
model_type TEXT NOT NULL,
model_id TEXT,
content_hash TEXT,
PRIMARY KEY (import_source_id, source_key)
);
INSERT INTO import_source_resources_new (model, created_at, updated_at, import_source_id,
source_key, model_type, model_id, content_hash)
SELECT model, created_at, updated_at, import_source_id, source_key, model_type, model_id, NULL
FROM import_source_resources;
DROP TABLE import_source_resources;
ALTER TABLE import_source_resources_new
RENAME TO import_source_resources;
+7 -3
View File
@@ -3118,8 +3118,12 @@ pub struct ImportSourceResource {
pub import_source_id: String, pub import_source_id: String,
pub source_key: String, pub source_key: String,
pub model_type: String, pub model_type: String,
pub model_id: String, /// `None` once the user has decided not to import this key
pub snapshot: String, #[ts(optional)]
pub model_id: Option<String>,
/// Hash of the resource as last applied or decided from the source, if one was recorded
#[ts(optional)]
pub content_hash: Option<String>,
} }
impl<'s> TryFrom<&Row<'s>> for ImportSourceResource { impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
@@ -3134,7 +3138,7 @@ impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
source_key: r.get("source_key")?, source_key: r.get("source_key")?,
model_type: r.get("model_type")?, model_type: r.get("model_type")?,
model_id: r.get("model_id")?, model_id: r.get("model_id")?,
snapshot: r.get("snapshot")?, content_hash: r.get("content_hash")?,
}) })
} }
} }
@@ -34,7 +34,7 @@ impl<'a> ClientDb<'a> {
ImportSourceResourceIden::SourceKey, ImportSourceResourceIden::SourceKey,
ImportSourceResourceIden::ModelType, ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId, ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::Snapshot, ImportSourceResourceIden::ContentHash,
]) ])
.values_panic([ .values_panic([
CurrentTimestamp.into(), CurrentTimestamp.into(),
@@ -42,8 +42,8 @@ impl<'a> ClientDb<'a> {
resource.import_source_id.as_str().into(), resource.import_source_id.as_str().into(),
resource.source_key.as_str().into(), resource.source_key.as_str().into(),
resource.model_type.as_str().into(), resource.model_type.as_str().into(),
resource.model_id.as_str().into(), resource.model_id.clone().into(),
resource.snapshot.as_str().into(), resource.content_hash.clone().into(),
]) ])
.on_conflict( .on_conflict(
OnConflict::columns([ OnConflict::columns([
@@ -54,7 +54,7 @@ impl<'a> ClientDb<'a> {
ImportSourceResourceIden::UpdatedAt, ImportSourceResourceIden::UpdatedAt,
ImportSourceResourceIden::ModelType, ImportSourceResourceIden::ModelType,
ImportSourceResourceIden::ModelId, ImportSourceResourceIden::ModelId,
ImportSourceResourceIden::Snapshot, ImportSourceResourceIden::ContentHash,
]) ])
.to_owned(), .to_owned(),
) )
+45
View File
@@ -109,6 +109,36 @@ pub enum ImportDestination {
pub struct ImportPlanWarning { pub struct ImportPlanWarning {
pub title: String, pub title: String,
pub detail: String, pub detail: String,
#[serde(default)]
pub level: ImportPlanWarningLevel,
}
/// Whether a plan's note is something to know or something to think twice about.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanWarningLevel {
#[default]
Info,
Warning,
}
impl ImportPlanWarning {
pub fn info(title: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
title: title.into(),
detail: detail.into(),
level: ImportPlanWarningLevel::Info,
}
}
pub fn warning(title: impl Into<String>, detail: impl Into<String>) -> Self {
Self {
title: title.into(),
detail: detail.into(),
level: ImportPlanWarningLevel::Warning,
}
}
} }
/// Where an import's contents came from, used to link the committed workspace back to it. /// Where an import's contents came from, used to link the committed workspace back to it.
@@ -169,6 +199,16 @@ pub enum ImportPlanAction {
Unchanged, Unchanged,
KeepLocal, KeepLocal,
Conflict, Conflict,
/// Present in the source but previously turned down; selecting it imports it again
Ignored,
}
/// Extra context for an action that would otherwise be indistinguishable from its plain form.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportPlanReason {
MovedIntoIgnoredFolder,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, TS)]
@@ -193,6 +233,11 @@ pub struct ImportPlanItem {
pub selected: bool, pub selected: bool,
#[ts(optional)] #[ts(optional)]
pub resolution: Option<ImportConflictResolution>, pub resolution: Option<ImportConflictResolution>,
#[ts(optional)]
pub reason: Option<ImportPlanReason>,
/// Fields where the source and the local copy disagree, so the preview can say why
#[serde(default)]
pub changed_fields: Vec<String>,
} }
#[derive(Debug, Deserialize, Serialize, TS)] #[derive(Debug, Deserialize, Serialize, TS)]
+13
View File
@@ -11,6 +11,7 @@ export type AnyModel =
| HttpRequest | HttpRequest
| HttpResponse | HttpResponse
| HttpResponseEvent | HttpResponseEvent
| ImportSource
| KeyValue | KeyValue
| Plugin | Plugin
| Settings | Settings
@@ -318,6 +319,18 @@ export type HttpUrlParameter = {
export type HttpVersion = "auto" | "http1" | "http2"; export type HttpVersion = "auto" | "http1" | "http2";
export type ImportSource = {
model: "import_source";
id: string;
createdAt: string;
updatedAt: string;
workspaceId: string;
importer: string;
origin: string;
originLabel: string;
lastImportedAt: string;
};
export type InheritedBoolSetting = { enabled?: boolean; value: boolean }; export type InheritedBoolSetting = { enabled?: boolean; value: boolean };
export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion }; export type InheritedHttpVersionSetting = { enabled?: boolean; value: HttpVersion };
+1
View File
@@ -9,6 +9,7 @@ async-trait = "0.1"
base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport base64 = "0.22.1" # For carrying body chunks over a text-only plugin transport
log = { workspace = true } log = { workspace = true }
md5 = "0.8.0" md5 = "0.8.0"
sha2 = { workspace = true }
chrono = { workspace = true } chrono = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
+857 -122
View File
File diff suppressed because it is too large Load Diff