mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-15 05:02:16 +01:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f8b7bf567 | ||
|
|
096e83a7bd | ||
|
|
89ce84cefe | ||
|
|
d5d2a64c32 |
No files matched your search
@@ -0,0 +1,34 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
npm-production:
|
||||
dependency-type: production
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
npm-development:
|
||||
dependency-type: development
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
|
||||
- package-ecosystem: cargo
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
cargo:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
Generated
+1
@@ -11228,6 +11228,7 @@ dependencies = [
|
||||
"md5 0.8.0",
|
||||
"rusqlite",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tempfile",
|
||||
"thiserror 2.0.17",
|
||||
"tokio",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
<!-- sponsors-premium --><a href="https://github.com/MVST-Solutions"><img src="https://github.com/MVST-Solutions.png" width="80px" alt="User avatar: MVST-Solutions" /></a> <a href="https://github.com/dharsanb"><img src="https://github.com/dharsanb.png" width="80px" alt="User avatar: dharsanb" /></a> <a href="https://github.com/railwayapp"><img src="https://github.com/railwayapp.png" width="80px" alt="User avatar: railwayapp" /></a> <a href="https://github.com/caseyamcl"><img src="https://github.com/caseyamcl.png" width="80px" alt="User avatar: caseyamcl" /></a> <a href="https://github.com/bytebase"><img src="https://github.com/bytebase.png" width="80px" alt="User avatar: bytebase" /></a> <a href="https://github.com/rauchg"><img src="https://github.com/rauchg.png" width="80px" alt="User avatar: rauchg" /></a> <a href="https://github.com/"><img src="https://raw.githubusercontent.com/JamesIves/github-sponsors-readme-action/dev/.github/assets/placeholder.png" width="80px" alt="User avatar: " /></a> <!-- sponsors-premium -->
|
||||
</p>
|
||||
<p align="center">
|
||||
<!-- sponsors-base --><a href="https://github.com/seanwash"><img src="https://github.com/seanwash.png" width="50px" alt="User avatar: seanwash" /></a> <a href="https://github.com/jerath"><img src="https://github.com/jerath.png" width="50px" alt="User avatar: jerath" /></a> <a href="https://github.com/itsa-sh"><img src="https://github.com/itsa-sh.png" width="50px" alt="User avatar: itsa-sh" /></a> <a href="https://github.com/dmmulroy"><img src="https://github.com/dmmulroy.png" width="50px" alt="User avatar: dmmulroy" /></a> <a href="https://github.com/timcole"><img src="https://github.com/timcole.png" width="50px" alt="User avatar: timcole" /></a> <a href="https://github.com/VLZH"><img src="https://github.com/VLZH.png" width="50px" alt="User avatar: VLZH" /></a> <a href="https://github.com/terasaka2k"><img src="https://github.com/terasaka2k.png" width="50px" alt="User avatar: terasaka2k" /></a> <a href="https://github.com/andriyor"><img src="https://github.com/andriyor.png" width="50px" alt="User avatar: andriyor" /></a> <a href="https://github.com/majudhu"><img src="https://github.com/majudhu.png" width="50px" alt="User avatar: majudhu" /></a> <a href="https://github.com/axelrindle"><img src="https://github.com/axelrindle.png" width="50px" alt="User avatar: axelrindle" /></a> <a href="https://github.com/jirizverina"><img src="https://github.com/jirizverina.png" width="50px" alt="User avatar: jirizverina" /></a> <a href="https://github.com/chip-well"><img src="https://github.com/chip-well.png" width="50px" alt="User avatar: chip-well" /></a> <a href="https://github.com/GRAYAH"><img src="https://github.com/GRAYAH.png" width="50px" alt="User avatar: GRAYAH" /></a> <a href="https://github.com/flashblaze"><img src="https://github.com/flashblaze.png" width="50px" alt="User avatar: flashblaze" /></a> <a href="https://github.com/Frostist"><img src="https://github.com/Frostist.png" width="50px" alt="User avatar: Frostist" /></a> <a href="https://github.com/PurplProto"><img src="https://github.com/PurplProto.png" width="50px" alt="User avatar: PurplProto" /></a> <!-- sponsors-base -->
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type ImportSource,
|
||||
type Workspace,
|
||||
} from "@yaakapp-internal/models";
|
||||
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||
import { platform } from "@yaakapp-internal/platform";
|
||||
import classNames from "classnames";
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
@@ -260,14 +260,17 @@ function LoadedImportDataDialog({
|
||||
|
||||
const itemTree = useMemo(() => buildItemTree(items), [items]);
|
||||
|
||||
// A folder row's checkbox aggregates its subtree the way the git commit tree does: creates and
|
||||
// updates toggle together, while removals only ever cascade beneath a removed folder.
|
||||
const toggleNode = (node: CheckboxTreeNode<ImportPlanItem>, checked: boolean) => {
|
||||
const targets = new Set(
|
||||
collectItems(node)
|
||||
.filter((i) => togglesWith(node.data, i))
|
||||
.map((i) => i.modelId),
|
||||
);
|
||||
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
|
||||
// say which of those are destructive. Checking anything also brings back the folders it needs
|
||||
// to live in.
|
||||
const toggleNode = (node: CheckboxTreeNode<TreeRow>, checked: boolean) => {
|
||||
const targets = new Set(togglableItems(node).map((i) => i.modelId));
|
||||
if (checked && node.data.kind === "item") {
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const ancestor of ancestorsOf(node.data.item, byId)) {
|
||||
if (isMissingFolder(ancestor)) targets.add(ancestor.modelId);
|
||||
}
|
||||
}
|
||||
setItems((prev) => prev.map((i) => (targets.has(i.modelId) ? { ...i, selected: checked } : i)));
|
||||
};
|
||||
|
||||
@@ -277,25 +280,16 @@ function LoadedImportDataDialog({
|
||||
);
|
||||
};
|
||||
|
||||
// A row the user can't meaningfully toggle on its own: a planned resource inside a deselected
|
||||
// new folder can't exist, and a removed folder takes its contents with it.
|
||||
// Deleting a folder takes its contents with it, so those rows have nothing left to decide.
|
||||
const disabledIds = useMemo(() => {
|
||||
const disabled = new Set<string>();
|
||||
const byId = new Map(items.map((i) => [i.modelId, i]));
|
||||
for (const item of items) {
|
||||
const seen = new Set<string>();
|
||||
let parentId = item.parentId;
|
||||
while (parentId != null && !seen.has(parentId)) {
|
||||
seen.add(parentId);
|
||||
const parent = byId.get(parentId);
|
||||
if (parent == null || parent.model !== "folder") break;
|
||||
if (parent.action === "create" && !parent.selected && item.action !== "delete") {
|
||||
if (item.action !== "delete") continue;
|
||||
for (const parent of ancestorsOf(item, byId)) {
|
||||
if (parent.action === "delete" && parent.selected) {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
if (parent.action === "delete" && parent.selected && item.action === "delete") {
|
||||
disabled.add(item.modelId);
|
||||
}
|
||||
parentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
return disabled;
|
||||
@@ -314,18 +308,26 @@ function LoadedImportDataDialog({
|
||||
return item.selected;
|
||||
}).length;
|
||||
|
||||
const destinationLabel = (() => {
|
||||
if (plan.destination.type === "new_workspace") return "New workspace";
|
||||
// The row's label carries what kind of destination it is, so the value can just be its name
|
||||
const [destinationLabel, destinationValue] = ((): [string, string] => {
|
||||
if (plan.destination.type === "new_workspace") {
|
||||
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
|
||||
if (names.length === 0) return ["New workspace", "Untitled"];
|
||||
return [pluralize("New workspace", names.length), names.join(", ")];
|
||||
}
|
||||
const { workspaceId, folderId } = plan.destination;
|
||||
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
|
||||
return folderId != null && folderId === selectedFolder?.id
|
||||
? `${name} / ${selectedFolder.name}`
|
||||
: name;
|
||||
return [
|
||||
"Destination",
|
||||
folderId != null && folderId === selectedFolder?.id
|
||||
? `${name} / ${selectedFolder.name}`
|
||||
: name,
|
||||
];
|
||||
})();
|
||||
|
||||
// The destination workspace roots the tree. It is not a plan item — commit always applies
|
||||
// it — so its checkbox only aggregates the subtree.
|
||||
const workspaceRoot: CheckboxTreeNode<ImportPlanItem> = (() => {
|
||||
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
|
||||
const planned = plan.resources.workspaces[0];
|
||||
const planDestination = plan.destination;
|
||||
const existing =
|
||||
@@ -335,11 +337,9 @@ function LoadedImportDataDialog({
|
||||
return {
|
||||
key: existing?.id ?? planned?.id ?? "workspace",
|
||||
data: {
|
||||
action: plan.destination.type === "new_workspace" ? "create" : "unchanged",
|
||||
model: "workspace",
|
||||
modelId: existing?.id ?? planned?.id ?? "workspace",
|
||||
name: existing?.name ?? planned?.name ?? "New workspace",
|
||||
selected: true,
|
||||
kind: "destination",
|
||||
label: existing?.name ?? planned?.name ?? "New workspace",
|
||||
isNew: planDestination.type === "new_workspace",
|
||||
},
|
||||
children: itemTree,
|
||||
};
|
||||
@@ -349,40 +349,42 @@ function LoadedImportDataDialog({
|
||||
<VStack space={4} className="pb-4">
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
<PreviewRow label="Detected format" value={plan.importer} />
|
||||
<PreviewRow label="Destination" value={destinationLabel} />
|
||||
<PreviewRow label={destinationLabel} value={destinationValue} />
|
||||
</div>
|
||||
|
||||
{plan.warnings.map((warning) => (
|
||||
<Banner
|
||||
key={`${warning.title}:${warning.detail}`}
|
||||
color={warning.level === "warning" ? "warning" : "info"}
|
||||
className="flex items-start gap-2.5"
|
||||
>
|
||||
<Icon
|
||||
icon={warning.level === "warning" ? "alert_triangle" : "info"}
|
||||
size="sm"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||
</div>
|
||||
</Banner>
|
||||
))}
|
||||
|
||||
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
|
||||
<CheckboxTree
|
||||
node={workspaceRoot}
|
||||
checked={nodeCheckedStatus}
|
||||
onCheck={toggleNode}
|
||||
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
|
||||
isRelevant={(n) => n.data.model === "workspace" || n.data.action !== "unchanged"}
|
||||
renderRow={(n) => <ImportTreeRow item={n.data} onResolveConflict={resolveConflict} />}
|
||||
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
|
||||
isRelevant={(n) =>
|
||||
n.data.kind === "destination" ||
|
||||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
|
||||
}
|
||||
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{plan.warnings.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-semibold mb-1">Import details</div>
|
||||
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
|
||||
{plan.warnings.map((warning) => (
|
||||
<div
|
||||
key={`${warning.title}:${warning.detail}`}
|
||||
className="flex items-start gap-2.5 px-3 py-2.5"
|
||||
>
|
||||
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">{warning.title}</div>
|
||||
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HStack space={2} alignItems="center" className="mt-3">
|
||||
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
|
||||
<Button
|
||||
@@ -402,7 +404,7 @@ function LoadedImportDataDialog({
|
||||
? "Importing"
|
||||
: changeCount > 0
|
||||
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
|
||||
: "Apply"}
|
||||
: "Done"}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
@@ -545,31 +547,42 @@ function LoadedImportDataDialog({
|
||||
}
|
||||
|
||||
function ImportTreeRow({
|
||||
item,
|
||||
row,
|
||||
onResolveConflict,
|
||||
}: {
|
||||
item: ImportPlanItem;
|
||||
row: TreeRow;
|
||||
onResolveConflict: (modelId: string, resolution: "keep_mine" | "take_source") => void;
|
||||
}) {
|
||||
if (row.kind !== "item") {
|
||||
return (
|
||||
<>
|
||||
<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 (
|
||||
<>
|
||||
{item.model === "workspace" || item.model === "folder" || item.model === "environment" ? (
|
||||
<Icon
|
||||
color="secondary"
|
||||
icon={
|
||||
item.model === "workspace" ? "house" : item.model === "folder" ? "folder" : "variable"
|
||||
}
|
||||
/>
|
||||
{item.model === "folder" || item.model === "environment" ? (
|
||||
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
|
||||
) : (
|
||||
<span aria-hidden className="w-4" />
|
||||
)}
|
||||
<div className="truncate flex-1">{item.name}</div>
|
||||
{item.action === "conflict" ? (
|
||||
<div className="shrink-0 flex items-center gap-1.5">
|
||||
<div className="shrink-0">
|
||||
<SegmentedControl
|
||||
name={`conflict-${item.modelId}`}
|
||||
label={`Resolve conflict for ${item.name}`}
|
||||
hideLabel
|
||||
size="2xs"
|
||||
help={actionHelp(item)}
|
||||
value={item.resolution ?? "keep_mine"}
|
||||
onChange={(v) => onResolveConflict(item.modelId, v)}
|
||||
options={[
|
||||
@@ -577,29 +590,49 @@ function ImportTreeRow({
|
||||
{ value: "take_source", label: "Take source" },
|
||||
]}
|
||||
/>
|
||||
<IconTooltip content={actionHelp(item)} iconSize="sm" />
|
||||
</div>
|
||||
) : (
|
||||
actionLabel(item) && (
|
||||
<InlineCode
|
||||
label != null && (
|
||||
<ActionChip
|
||||
label={label}
|
||||
help={actionHelp(item)}
|
||||
className={classNames(
|
||||
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
|
||||
"inline-flex items-center justify-center gap-1.5",
|
||||
item.action === "create" && "text-success",
|
||||
item.action === "update" && "text-info",
|
||||
item.action === "delete" && "text-danger",
|
||||
item.action === "keep_local" && item.selected && "text-warning",
|
||||
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 {
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
@@ -610,29 +643,68 @@ function actionLabel(item: ImportPlanItem): string | null {
|
||||
return "removed";
|
||||
case "keep_local":
|
||||
return "edited";
|
||||
case "ignored":
|
||||
return "ignored";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function actionHelp(item: ImportPlanItem): string | null {
|
||||
const help = (text: string) =>
|
||||
item.changedFields.length > 0
|
||||
? `${text} · ${item.changedFields.map(fieldLabel).join(", ")}`
|
||||
: text;
|
||||
switch (item.action) {
|
||||
case "create":
|
||||
return "Added since the last import";
|
||||
return "Not in this workspace yet";
|
||||
case "update":
|
||||
return "Changed since the last import";
|
||||
return item.reason === "moved_into_ignored_folder"
|
||||
? "Moved into a folder that isn't imported. Importing that folder brings it along"
|
||||
: help("Changed in the source since the last import");
|
||||
case "delete":
|
||||
return "Deleted since the last import";
|
||||
return "Gone from the source since the last import. Checking it deletes it here";
|
||||
case "keep_local":
|
||||
return "Local edits made since the last import. Importing will revert them if checked";
|
||||
return help("Changed here since the last import. Checking it reverts to the source");
|
||||
case "conflict":
|
||||
return "Changed both here and in the file since the last import";
|
||||
return help("Changed here and in the source since the last import");
|
||||
case "ignored":
|
||||
return "Not in this workspace. Imports leave it alone until you check it";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<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 childrenOf = new Map<string, ImportPlanItem[]>();
|
||||
const roots: ImportPlanItem[] = [];
|
||||
@@ -646,45 +718,66 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<ImportPlanItem
|
||||
}
|
||||
}
|
||||
|
||||
const foldersFirst = (list: ImportPlanItem[]) => [
|
||||
const byKind = (list: ImportPlanItem[]) => [
|
||||
...list.filter((i) => i.model === "environment"),
|
||||
...list.filter((i) => i.model === "folder"),
|
||||
...list.filter((i) => i.model !== "folder"),
|
||||
...list.filter((i) => i.model !== "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,
|
||||
data: item,
|
||||
data: { kind: "item", item },
|
||||
children: seen.has(item.modelId)
|
||||
? []
|
||||
: foldersFirst(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||
: byKind(childrenOf.get(item.modelId) ?? []).map((c) =>
|
||||
toNode(c, new Set([...seen, item.modelId])),
|
||||
),
|
||||
});
|
||||
|
||||
return foldersFirst(roots).map((r) => toNode(r, new Set()));
|
||||
// The workspace's environments have nothing to sit under — a sub-environment is a sibling of
|
||||
// the base one, not its child — so a heading groups them into one thing to turn on and off.
|
||||
const environments = roots.filter((i) => i.model === "environment");
|
||||
const others = byKind(roots.filter((i) => i.model !== "environment"));
|
||||
const nodes = others.map((r) => toNode(r, new Set()));
|
||||
if (environments.length === 0) return nodes;
|
||||
return [
|
||||
{
|
||||
key: "group:environments",
|
||||
data: { kind: "group", label: "Variables", icon: "variable" },
|
||||
children: environments.map((e) => toNode(e, new Set())),
|
||||
},
|
||||
...nodes,
|
||||
];
|
||||
}
|
||||
|
||||
function collectItems(node: CheckboxTreeNode<ImportPlanItem>): ImportPlanItem[] {
|
||||
return [node.data, ...node.children.flatMap(collectItems)];
|
||||
function collectRows(node: CheckboxTreeNode<TreeRow>): TreeRow[] {
|
||||
return [node.data, ...node.children.flatMap(collectRows)];
|
||||
}
|
||||
|
||||
/** A folder that isn't there yet, so anything inside it needs it brought in first. */
|
||||
function isMissingFolder(item: ImportPlanItem): boolean {
|
||||
return item.model === "folder" && (item.action === "create" || item.action === "ignored");
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether toggling `root`'s checkbox also toggles `item` in its subtree. Destructive decisions
|
||||
* (deletions, reverting local edits) never ride along with a parent toggle.
|
||||
* The plan item a row's checkbox decides, if it decides one. An unchanged resource has nothing to
|
||||
* decide and a conflict is decided by its own control, so neither takes a checkbox — nor rides
|
||||
* along with a parent's.
|
||||
*/
|
||||
function togglesWith(root: ImportPlanItem, item: ImportPlanItem): boolean {
|
||||
if (item.model === "workspace") return false;
|
||||
if (root.action === "delete") return item.action === "delete";
|
||||
if (item.action === "keep_local") {
|
||||
return root.modelId === item.modelId && item.model !== "folder";
|
||||
}
|
||||
return item.action === "create" || item.action === "update";
|
||||
function togglableItem(row: TreeRow): ImportPlanItem | null {
|
||||
if (row.kind !== "item") return null;
|
||||
const { item } = row;
|
||||
return item.action === "unchanged" || item.action === "conflict" ? null : item;
|
||||
}
|
||||
|
||||
function nodeCheckedStatus(
|
||||
node: CheckboxTreeNode<ImportPlanItem>,
|
||||
): boolean | "indeterminate" | "hidden" {
|
||||
const covered = collectItems(node).filter((i) => togglesWith(node.data, i));
|
||||
function togglableItems(node: CheckboxTreeNode<TreeRow>): ImportPlanItem[] {
|
||||
return collectRows(node)
|
||||
.map(togglableItem)
|
||||
.filter((i) => i != null);
|
||||
}
|
||||
|
||||
function nodeCheckedStatus(node: CheckboxTreeNode<TreeRow>): boolean | "indeterminate" | "hidden" {
|
||||
const covered = togglableItems(node);
|
||||
if (covered.length === 0) return "hidden";
|
||||
const selected = covered.filter((i) => i.selected).length;
|
||||
if (selected === covered.length) return true;
|
||||
|
||||
@@ -21,6 +21,8 @@ interface Props<T> {
|
||||
isCheckboxDisabled?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
/** An irrelevant row is hidden unless one of its descendants is relevant */
|
||||
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;
|
||||
onSelectRow?: (node: CheckboxTreeNode<T>) => void;
|
||||
canSelectRow?: (node: CheckboxTreeNode<T>) => boolean;
|
||||
@@ -29,7 +31,9 @@ interface Props<T> {
|
||||
|
||||
export function CheckboxTree<T>(props: Props<T>) {
|
||||
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;
|
||||
|
||||
const checked = props.checked(node);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useStateWithDeps } from "../../hooks/useStateWithDeps";
|
||||
import { generateId } from "../../lib/generateId";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||
import { IconTooltip } from "./IconTooltip";
|
||||
import { Label } from "./Label";
|
||||
|
||||
interface Props<T extends string> {
|
||||
@@ -36,11 +37,15 @@ export function SegmentedControl<T extends string>({
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const id = useRef(`input-${generateId()}`);
|
||||
|
||||
// A visually hidden label has nowhere to show the help, so the last option carries it
|
||||
const inlineHelp =
|
||||
hideLabel && help ? <IconTooltip tabIndex={-1} content={help} iconSize="xs" /> : null;
|
||||
|
||||
return (
|
||||
<div className="w-full grid">
|
||||
<Label
|
||||
htmlFor={id.current}
|
||||
help={help}
|
||||
help={hideLabel ? undefined : help}
|
||||
visuallyHidden={hideLabel}
|
||||
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 isActive = value === o.value;
|
||||
const rightSlot = i === options.length - 1 ? inlineHelp : null;
|
||||
if (o.icon == null) {
|
||||
return (
|
||||
<Button
|
||||
@@ -95,6 +101,7 @@ export function SegmentedControl<T extends string>({
|
||||
isActive && "text-text!",
|
||||
"focus:ring-1 focus:ring-border-focus",
|
||||
)}
|
||||
rightSlot={rightSlot}
|
||||
onClick={() => onChange(o.value)}
|
||||
>
|
||||
{o.label}
|
||||
@@ -117,6 +124,7 @@ export function SegmentedControl<T extends string>({
|
||||
)}
|
||||
title={o.label}
|
||||
icon={o.icon}
|
||||
rightSlot={rightSlot}
|
||||
onClick={() => onChange(o.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
---
|
||||
name: use-yaak
|
||||
description: >
|
||||
Build and run HTTP API requests with the Yaak CLI (`yaak`): create workspaces,
|
||||
folders, environments and variables, author HTTP requests, configure
|
||||
authentication (OAuth 2.0, bearer tokens, API keys, basic, JWT, AWS SigV4),
|
||||
send them individually or a whole folder/workspace at once, chain one
|
||||
request's response into the next, and import existing APIs from OpenAPI,
|
||||
Postman, Insomnia, or cURL. Use this skill whenever the user mentions Yaak, a
|
||||
Yaak workspace, or the `yaak` command, and also when they ask to try, hit,
|
||||
call, exercise, or smoke test an HTTP or REST endpoint, to save or organize
|
||||
API requests for reuse, to set up API requests for manual testing, to add auth
|
||||
to a saved request, to turn an OpenAPI or Postman collection into runnable
|
||||
requests, or to run a saved request suite against staging versus production.
|
||||
Prefer this over one-off `curl` commands whenever the requests should be
|
||||
saved, reused, shared, or run as a set.
|
||||
Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or
|
||||
asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API
|
||||
requests for reuse or manual testing, configure auth on saved requests, import
|
||||
an OpenAPI spec, a Postman or Insomnia collection, or a cURL command, or run
|
||||
saved requests across environments. Prefer over one-off `curl` when requests
|
||||
should be saved, reused, shared, or run as a set.
|
||||
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
|
||||
---
|
||||
|
||||
@@ -34,7 +27,7 @@ only for publishing plugins to the Yaak registry.
|
||||
Two consequences worth holding onto. Requests you create are permanent user data
|
||||
in an app they use, not scratch files, so name them the way the user would and
|
||||
clean up anything created just to test. And because the app is right there, the
|
||||
CLI is usually the wrong place to *read* a response in detail; it is the right
|
||||
CLI is usually the wrong place to _read_ a response in detail; it is the right
|
||||
place to build, organize, and run requests.
|
||||
|
||||
## The CLI describes itself
|
||||
|
||||
@@ -113,6 +113,10 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
|
||||
if keep_local > 0 {
|
||||
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);
|
||||
if unchanged > 0 {
|
||||
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 serde_json::Value;
|
||||
use tempfile::TempDir;
|
||||
use yaak_models::util::UpdateSource;
|
||||
|
||||
#[test]
|
||||
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 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;
|
||||
};
|
||||
|
||||
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 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.
|
||||
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";
|
||||
|
||||
@@ -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 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.
|
||||
*/
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string, label: string, };
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
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.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string },
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>, origin?: ImportOrigin, };
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
export type ImportResourceType = "environment" | "folder" | "grpc_request" | "http_request" | "websocket_request" | "workspace";
|
||||
export type ImportResourceType =
|
||||
| "environment"
|
||||
| "folder"
|
||||
| "grpc_request"
|
||||
| "http_request"
|
||||
| "websocket_request"
|
||||
| "workspace";
|
||||
+25
-57
@@ -16,7 +16,6 @@ import {
|
||||
PushResult,
|
||||
} from "./bindings/gen_git";
|
||||
import { showToast } from "@yaakapp/yaak-client/lib/toast";
|
||||
import { shouldRetryAfterFailure } from "./polling";
|
||||
|
||||
export * from "./bindings/gen_git";
|
||||
export * from "./bindings/gen_models";
|
||||
@@ -86,56 +85,26 @@ export function watchGitWorktreeStatus(dir: string, callback: (status: GitWorktr
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function refetchAfterBackoff(query: {
|
||||
state: { fetchFailureCount: number; errorUpdatedAt: number };
|
||||
}) {
|
||||
return shouldRetryAfterFailure(
|
||||
query.state.fetchFailureCount,
|
||||
query.state.errorUpdatedAt,
|
||||
Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `git fetch` shells out — twice, counting the availability probe — so it only runs once
|
||||
* something cheaper has established that `dir` is a repository at all. Callers pass that
|
||||
* verdict in: a sync directory pointed at a plain folder then costs one libgit2 lookup
|
||||
* instead of a pair of subprocesses on every window focus.
|
||||
*/
|
||||
function useGitFetchAll(dir: string, refreshKey: string | undefined, enabled: boolean) {
|
||||
return useQuery<null, string>({
|
||||
enabled,
|
||||
function useGitFetchAll(dir: string, refreshKey?: string) {
|
||||
return useQuery<void, string>({
|
||||
queryKey: ["git", "fetch_all", dir, refreshKey],
|
||||
async queryFn() {
|
||||
await platform.rpc<null>("cmd_git_fetch_all", { dir });
|
||||
// Fetching moves the remote refs, so anything counted against them is now stale
|
||||
await queryClient.invalidateQueries({ queryKey: ["git", "branch_info", dir] });
|
||||
await queryClient.invalidateQueries({ queryKey: ["git", "status", dir] });
|
||||
return null;
|
||||
},
|
||||
queryFn: () => platform.rpc("cmd_git_fetch_all", { dir }),
|
||||
refetchInterval: 10 * 60_000,
|
||||
refetchOnWindowFocus: refetchAfterBackoff,
|
||||
retryOnMount: refetchAfterBackoff,
|
||||
});
|
||||
}
|
||||
|
||||
function useGitBranchInfoQuery(dir: string, refreshKey?: string) {
|
||||
function useGitBranchInfoQuery(dir: string, refreshKey?: string, fetchAllUpdatedAt?: number) {
|
||||
return useQuery<GitBranchInfo, string>({
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: refetchAfterBackoff,
|
||||
// A probe that has only ever failed has no data, so `refetchOnMount` never gets a
|
||||
// say; `retryOnMount` is the knob that paces re-opening a dialog on a broken repo
|
||||
retryOnMount: refetchAfterBackoff,
|
||||
queryKey: ["git", "branch_info", dir, refreshKey],
|
||||
queryKey: ["git", "branch_info", dir, refreshKey, fetchAllUpdatedAt],
|
||||
queryFn: () => platform.rpc("cmd_git_branch_info", { dir }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGitBranchInfo(dir: string, refreshKey?: string) {
|
||||
const branchInfo = useGitBranchInfoQuery(dir, refreshKey);
|
||||
useGitFetchAll(dir, refreshKey, branchInfo.isSuccess);
|
||||
return branchInfo;
|
||||
const fetchAll = useGitFetchAll(dir, refreshKey);
|
||||
return useGitBranchInfoQuery(dir, refreshKey, fetchAll.dataUpdatedAt);
|
||||
}
|
||||
|
||||
export function useGitLog(dir: string, refreshKey?: string, relaPath?: string) {
|
||||
@@ -166,26 +135,25 @@ export function useGitFileDiffForCommit(
|
||||
|
||||
export function useGit(dir: string, callbacks: GitCallbacks, refreshKey?: string) {
|
||||
const mutations = useGitMutations(dir, callbacks);
|
||||
const remotes = useQuery<GitRemote[], string>({
|
||||
queryKey: ["git", "remotes", dir, refreshKey],
|
||||
queryFn: () => getRemotes(dir),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
const log = useGitLog(dir, refreshKey);
|
||||
const status = useQuery<GitStatusSummary, string>({
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: refetchAfterBackoff,
|
||||
retryOnMount: refetchAfterBackoff,
|
||||
queryKey: ["git", "status", dir, refreshKey],
|
||||
queryFn: () => platform.rpc("cmd_git_status", { dir }),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
const fetchAll = useGitFetchAll(dir, refreshKey);
|
||||
|
||||
// Status doubles as the repository probe here, so nothing shells out to `git fetch`
|
||||
// until there is a repository to fetch into
|
||||
useGitFetchAll(dir, refreshKey, status.isSuccess);
|
||||
|
||||
return [{ remotes, log, status }, mutations] as const;
|
||||
return [
|
||||
{
|
||||
remotes: useQuery<GitRemote[], string>({
|
||||
queryKey: ["git", "remotes", dir, refreshKey],
|
||||
queryFn: () => getRemotes(dir),
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
log: useGitLog(dir, refreshKey),
|
||||
status: useQuery<GitStatusSummary, string>({
|
||||
refetchOnMount: true,
|
||||
queryKey: ["git", "status", dir, refreshKey, fetchAll.dataUpdatedAt],
|
||||
queryFn: () => platform.rpc("cmd_git_status", { dir }),
|
||||
placeholderData: (prev) => prev,
|
||||
}),
|
||||
},
|
||||
mutations,
|
||||
] as const;
|
||||
}
|
||||
|
||||
export function useGitMutations(dir: string, callbacks: GitCallbacks) {
|
||||
|
||||
@@ -2,8 +2,5 @@
|
||||
"name": "@yaakapp-internal/git",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
}
|
||||
"main": "index.ts"
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { shouldRetryAfterFailure } from "./polling";
|
||||
|
||||
const NOW = 1_000_000_000;
|
||||
|
||||
describe("shouldRetryAfterFailure", () => {
|
||||
test("a query that has not failed always runs", () => {
|
||||
expect(shouldRetryAfterFailure(0, 0, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
test("waits 30s after the first failure", () => {
|
||||
expect(shouldRetryAfterFailure(1, NOW - 29_999, NOW)).toBe(false);
|
||||
expect(shouldRetryAfterFailure(1, NOW - 30_000, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
test("doubles the wait for each consecutive failure", () => {
|
||||
expect(shouldRetryAfterFailure(2, NOW - 59_999, NOW)).toBe(false);
|
||||
expect(shouldRetryAfterFailure(2, NOW - 60_000, NOW)).toBe(true);
|
||||
expect(shouldRetryAfterFailure(3, NOW - 119_999, NOW)).toBe(false);
|
||||
expect(shouldRetryAfterFailure(3, NOW - 120_000, NOW)).toBe(true);
|
||||
});
|
||||
|
||||
test("caps the wait at ten minutes", () => {
|
||||
expect(shouldRetryAfterFailure(50, NOW - (10 * 60_000 - 1), NOW)).toBe(false);
|
||||
expect(shouldRetryAfterFailure(50, NOW - 10 * 60_000, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,21 +0,0 @@
|
||||
/**
|
||||
* When a git query that failed is allowed to run again.
|
||||
*
|
||||
* Nothing here retries on its own; the git queries are polled by window focus and by a
|
||||
* long interval. The failures worth pacing are the ones that do not clear up on their
|
||||
* own — a sync directory that is not a repository, a repository that was deleted — where
|
||||
* every focus event would otherwise re-run the same doomed command.
|
||||
*/
|
||||
|
||||
const RETRY_BASE_MS = 30_000;
|
||||
const RETRY_MAX_MS = 10 * 60_000;
|
||||
|
||||
export function shouldRetryAfterFailure(
|
||||
failureCount: number,
|
||||
erroredAt: number,
|
||||
now: number,
|
||||
): boolean {
|
||||
if (failureCount <= 0) return true;
|
||||
const wait = Math.min(RETRY_BASE_MS * 2 ** (failureCount - 1), RETRY_MAX_MS);
|
||||
return now - erroredAt >= wait;
|
||||
}
|
||||
Generated
+5
@@ -0,0 +1,5 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type MethodDefinition = { name: string, schema: string, clientStreaming: boolean, serverStreaming: boolean, };
|
||||
|
||||
export type ServiceDefinition = { name: string, methods: Array<MethodDefinition>, };
|
||||
+8
-2
@@ -356,8 +356,14 @@ export type ImportSourceResource = {
|
||||
importSourceId: string;
|
||||
sourceKey: 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 };
|
||||
|
||||
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.
|
||||
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";
|
||||
|
||||
@@ -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 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.
|
||||
*/
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string, label: string, };
|
||||
export type ImportOrigin = {
|
||||
/**
|
||||
* The absolute file path or URL the contents were read from.
|
||||
*/
|
||||
origin: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
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.
|
||||
*/
|
||||
sourceKeys: { [key in string]?: string },
|
||||
/**
|
||||
* One entry per plannable resource; commit applies only the selected ones.
|
||||
*/
|
||||
items: Array<ImportPlanItem>, origin?: ImportOrigin, };
|
||||
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.
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
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 source_key: String,
|
||||
pub model_type: String,
|
||||
pub model_id: String,
|
||||
pub snapshot: String,
|
||||
/// `None` once the user has decided not to import this key
|
||||
#[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 {
|
||||
@@ -3134,7 +3138,7 @@ impl<'s> TryFrom<&Row<'s>> for ImportSourceResource {
|
||||
source_key: r.get("source_key")?,
|
||||
model_type: r.get("model_type")?,
|
||||
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::ModelType,
|
||||
ImportSourceResourceIden::ModelId,
|
||||
ImportSourceResourceIden::Snapshot,
|
||||
ImportSourceResourceIden::ContentHash,
|
||||
])
|
||||
.values_panic([
|
||||
CurrentTimestamp.into(),
|
||||
@@ -42,8 +42,8 @@ impl<'a> ClientDb<'a> {
|
||||
resource.import_source_id.as_str().into(),
|
||||
resource.source_key.as_str().into(),
|
||||
resource.model_type.as_str().into(),
|
||||
resource.model_id.as_str().into(),
|
||||
resource.snapshot.as_str().into(),
|
||||
resource.model_id.clone().into(),
|
||||
resource.content_hash.clone().into(),
|
||||
])
|
||||
.on_conflict(
|
||||
OnConflict::columns([
|
||||
@@ -54,7 +54,7 @@ impl<'a> ClientDb<'a> {
|
||||
ImportSourceResourceIden::UpdatedAt,
|
||||
ImportSourceResourceIden::ModelType,
|
||||
ImportSourceResourceIden::ModelId,
|
||||
ImportSourceResourceIden::Snapshot,
|
||||
ImportSourceResourceIden::ContentHash,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
|
||||
@@ -109,6 +109,36 @@ pub enum ImportDestination {
|
||||
pub struct ImportPlanWarning {
|
||||
pub title: 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.
|
||||
@@ -169,6 +199,16 @@ pub enum ImportPlanAction {
|
||||
Unchanged,
|
||||
KeepLocal,
|
||||
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)]
|
||||
@@ -193,6 +233,11 @@ pub struct ImportPlanItem {
|
||||
pub selected: bool,
|
||||
#[ts(optional)]
|
||||
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)]
|
||||
|
||||
+13
@@ -11,6 +11,7 @@ export type AnyModel =
|
||||
| HttpRequest
|
||||
| HttpResponse
|
||||
| HttpResponseEvent
|
||||
| ImportSource
|
||||
| KeyValue
|
||||
| Plugin
|
||||
| Settings
|
||||
@@ -318,6 +319,18 @@ export type HttpUrlParameter = {
|
||||
|
||||
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 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
|
||||
log = { workspace = true }
|
||||
md5 = "0.8.0"
|
||||
sha2 = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
+949
-126
File diff suppressed because it is too large.
Load diff
Generated
+909
-478
File diff suppressed because it is too large.
Load diff
@@ -135,7 +135,9 @@
|
||||
"yauzl": "^3.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@vitest/mocker": "^4.1.11",
|
||||
"js-yaml": "^4.3.1",
|
||||
"underscore": "^1.13.8",
|
||||
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9"
|
||||
},
|
||||
"packageManager": "npm@11.11.1"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"test": "vp test --run tests"
|
||||
},
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "^10.1.0"
|
||||
"@faker-js/faker": "^10.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.3",
|
||||
|
||||
@@ -142,6 +142,7 @@ exports[`template-function-faker > exports all expected template functions 1`] =
|
||||
"faker.location.longitude",
|
||||
"faker.location.nearbyGPSCoordinate",
|
||||
"faker.location.ordinalDirection",
|
||||
"faker.location.postalAddress",
|
||||
"faker.location.secondaryAddress",
|
||||
"faker.location.state",
|
||||
"faker.location.street",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@hono/mcp": "^0.2.3",
|
||||
"@hono/node-server": "^2.0.10",
|
||||
"@modelcontextprotocol/sdk": "^1.26.0",
|
||||
"hono": "^4.12.34",
|
||||
"hono": "^4.13.5",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"dev": "yaakcli dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"@xmldom/xmldom": "^0.9.12",
|
||||
"xpath": "^0.0.34"
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
"dev": "yaakcli dev"
|
||||
},
|
||||
"dependencies": {
|
||||
"@xmldom/xmldom": "^0.9.10",
|
||||
"@xmldom/xmldom": "^0.9.12",
|
||||
"xpath": "^0.0.34"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user