Compare commits

...
Author SHA1 Message Date
Gregory Schier aa10c44a45 Add Bruno imports and a file-tree importer API (#706) 2026-09-24 14:50:44 -07:00
Gregory Schier 46464cfacf Switch to the imported workspace after a deep link import 2026-09-24 08:01:58 -07:00
Gregory Schier 263a215f7a Fix sidebar ordering for items with equal sort priorities (#705) 2026-09-23 08:00:08 -07:00
Gregory Schier 646d969649 Fix PDF preview worker initialization (#704) 2026-09-23 07:41:55 -07:00
Gregory Schier 4913711968 Fix autocomplete for incomplete template tags (#692) 2026-09-18 09:16:36 -07:00
Gregory Schier 35d4c9d79d Allow six hours for release builds and uploads (#691) 2026-09-17 11:41:35 -07:00
Gregory Schier bbe8d1eef5 fix: retry release uploads and retain artifacts for seven days (#690) 2026-09-17 10:11:23 -07:00
Gregory Schier 1e3dfff01e Fix JSON breadcrumb filters for literal keys (#689) 2026-09-17 08:18:15 -07:00
dependabot[bot] fccff5b7db chore(deps): bump motion from 12.43.0 to 13.2.0 (#683)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-17 07:24:13 -07:00
Vlad Solomon 6ed25b9f42 JSON breadcrumbs for the response viewer (YK-724) (#653) 2026-09-17 06:42:32 -07:00
dependabot[bot] 4a4d3b4301 chore(deps): bump react-pdf from 10.5.0 to 11.0.0 (#687)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 09:02:04 -07:00
dependabot[bot] 25a464546a chore(deps): bump lucide-react from 0.525.0 to 1.45.0 (#688)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 09:01:52 -07:00
Gregory Schier 2c51fc92d6 Create the example workspace in the browser build 2026-09-15 16:32:20 -07:00
Gregory SchierandClaude Opus 5 f827aae46d Share one exporter, and flush the file before saying it is written (#686)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 14:56:23 -07:00
141 changed files with 6145 additions and 1154 deletions

No files matched your search

@@ -0,0 +1,79 @@
// Retain only distributable bundle files, never the target tree or signing material.
import { createHash } from "node:crypto";
import {
copyFileSync,
createReadStream,
existsSync,
mkdirSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { join, relative } from "node:path";
const env = process.env;
const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
const extensions =
/\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
const files = [];
function snapshot(directory) {
if (!existsSync(directory)) return;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const source = join(directory, entry.name);
// Only descend through the bundler's immediate output folders. This excludes
// .app trees and the extracted CEF deb contents (which contain executables).
if (entry.isDirectory() && directory === bundle) {
snapshot(source);
} else if (
entry.isFile() &&
(extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
) {
// Match tauri-action's default macOS updater asset naming exactly.
const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
mkdirSync(destination, { recursive: true });
const retained = join(destination, assetName);
// The second snapshot must not overwrite the original user installer/signature.
if (!existsSync(retained)) copyFileSync(source, retained);
files.push({ source: relative(".", source), assetName });
}
}
}
snapshot(bundle);
if (existsSync(destination)) {
const assets = readdirSync(destination)
.filter((name) => name !== "provenance.json")
.sort();
const checksums = [];
for (const assetName of assets) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
checksums.push({ assetName, sha256: hash.digest("hex") });
}
writeFileSync(
join(destination, "provenance.json"),
JSON.stringify(
{
repository: env.GITHUB_REPOSITORY,
tag: env.GITHUB_REF_NAME,
sha: env.GITHUB_SHA,
runId: env.GITHUB_RUN_ID,
runAttempt: env.GITHUB_RUN_ATTEMPT,
workflowRef: env.GITHUB_WORKFLOW_REF,
workflowSha: env.GITHUB_WORKFLOW_SHA,
tauriAction: "tauri-apps/tauri-action@v0",
platform: env.RELEASE_PLATFORM,
runtime: env.RELEASE_RUNTIME,
arch: env.RELEASE_ARCH,
target: env.RELEASE_TARGET,
args: env.RELEASE_ARGS,
assets: checksums,
},
null,
2,
) + "\n",
);
}
console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
+51 -5
View File
@@ -70,7 +70,7 @@ jobs:
runtime: "wry"
targets: "aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }}
timeout-minutes: 60
timeout-minutes: 360
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
@@ -172,7 +172,8 @@ jobs:
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
- uses: tauri-apps/tauri-action@v0
- name: Build and upload Tauri bundles
uses: tauri-apps/tauri-action@v0
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
@@ -194,6 +195,8 @@ jobs:
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with:
# Also retries builds; upload retries alone cannot clean up starter assets.
retryAttempts: 3
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
tagName: "v__VERSION__"
releaseName: "Release __VERSION__"
@@ -203,6 +206,17 @@ jobs:
projectPath: ./crates-tauri/yaak-app-client
args: "${{ matrix.args }}"
# Snapshot before the per-machine build overwrites the regular NSIS bundle/signature.
- name: Snapshot Tauri bundles
if: ${{ !cancelled() }}
run: node .github/scripts/preserve-release-artifacts.mjs
env:
RELEASE_TARGET: ${{ matrix.targets }}
RELEASE_PLATFORM: ${{ matrix.platform }}
RELEASE_RUNTIME: ${{ matrix.runtime }}
RELEASE_ARCH: ${{ matrix.yaak_arch }}
RELEASE_ARGS: ${{ matrix.args }}
- name: Build and upload CEF tarball from deb (Linux only)
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
env:
@@ -217,7 +231,13 @@ jobs:
mkdir -p "$extract_dir"
dpkg-deb -x "$deb" "$extract_dir"
tar -C "$extract_dir" -czf "$tarball" .
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
for attempt in 1 2 3 4; do
if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then
break
fi
if [ "$attempt" -eq 4 ]; then exit 1; fi
sleep 10
done
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only)
@@ -235,6 +255,7 @@ jobs:
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
Push-Location crates-tauri/yaak-app-client
npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Pop-Location
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig"
@@ -242,5 +263,30 @@ jobs:
$destSig = "$dest.sig"
Copy-Item $setup.FullName $dest
Copy-Item $setupSig $destSig
gh release upload "${{ github.ref_name }}" "$dest" --clobber
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
foreach ($asset in @($dest, $destSig)) {
for ($attempt = 1; $attempt -le 4; $attempt++) {
gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber
if ($LASTEXITCODE -eq 0) { break }
if ($attempt -eq 4) { exit $LASTEXITCODE }
Start-Sleep -Seconds 10
}
}
- name: Snapshot additional release bundles
if: ${{ !cancelled() }}
run: node .github/scripts/preserve-release-artifacts.mjs
env:
RELEASE_TARGET: ${{ matrix.targets }}
RELEASE_PLATFORM: ${{ matrix.platform }}
RELEASE_RUNTIME: ${{ matrix.runtime }}
RELEASE_ARCH: ${{ matrix.yaak_arch }}
RELEASE_ARGS: ${{ matrix.args }}
- name: Retain release bundles for recovery
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/yaak-release-artifacts/
retention-days: 7
if-no-files-found: warn
+2 -1
View File
@@ -3,6 +3,7 @@
"ignorePatterns": [
"**/bindings/**",
"crates/yaak-templates/pkg/**",
"apps/yaak-client/routeTree.gen.ts"
"apps/yaak-client/routeTree.gen.ts",
"plugins/importer-bruno/tests/fixtures/upstream/**"
]
}
@@ -1,12 +1,12 @@
import { gitClone } from "@yaakapp-internal/git";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { useId, useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { promptCredentials } from "./git/credentials";
@@ -29,6 +29,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
const [subdirectory, setSubdirectory] = useState<string>("");
const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const formId = useId();
const repoName = extractRepoName(url);
const sep = getPathSeparator(baseDirectory);
@@ -83,7 +84,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
};
return (
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
<VStack as="form" id={formId} space={3} alignItems="start" onSubmit={handleClone}>
{error && (
<Banner color="danger" className="w-full">
{error}
@@ -132,15 +133,17 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
/>
)}
<Button
type="submit"
color="primary"
className="w-full mt-3"
disabled={!url || !directory || isCloning}
isLoading={isCloning}
>
{isCloning ? "Cloning..." : "Clone Repository"}
</Button>
<DialogFooter
actions={[
{
label: isCloning ? "Cloning..." : "Clone Repository",
color: "primary",
form: formId,
disabled: !url || !directory || isCloning,
isLoading: isCloning,
},
]}
/>
</VStack>
);
}
@@ -1,10 +1,10 @@
import { createWorkspaceModel } from "@yaakapp-internal/models";
import { useState } from "react";
import { useId, useState } from "react";
import { useToggle } from "../hooks/useToggle";
import { ColorIndicator } from "./ColorIndicator";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
import { DialogFooter } from "./core/Dialog";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
@@ -18,9 +18,11 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
const [name, setName] = useState<string>("");
const [color, setColor] = useState<string | null>(null);
const [sharable, toggleSharable] = useToggle(false);
const formId = useId();
return (
<form
className="pb-3 flex flex-col gap-3"
id={formId}
className="flex flex-col gap-3"
onSubmit={async (e) => {
e.preventDefault();
const id = await createWorkspaceModel({
@@ -59,10 +61,17 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
</Label>
<ColorPickerWithThemeColors onChange={setColor} color={color} />
</div>
<Button type="submit" color="secondary" className="mt-3">
{color != null && <ColorIndicator color={color} />}
Create Environment
</Button>
<DialogFooter
inline
actions={[
{
label: "Create Environment",
color: "secondary",
form: formId,
leftSlot: color != null ? <ColorIndicator color={color} /> : undefined,
},
]}
/>
</form>
);
}
@@ -2,13 +2,13 @@ import { gitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { useId, useState } from "react";
import { router } from "../lib/router";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { EncryptionHelp } from "./EncryptionHelp";
@@ -26,12 +26,13 @@ export function CreateWorkspaceDialog({ hide }: Props) {
initGit?: boolean;
}>({ filePath: null, initGit: false });
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
const formId = useId();
return (
<VStack
as="form"
id={formId}
space={3}
alignItems="start"
className="pb-3"
onSubmit={async (e) => {
e.preventDefault();
const workspaceId = await createGlobalModel({ model: "workspace", name });
@@ -89,9 +90,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
title="Enable Encryption"
/>
</div>
<Button type="submit" color="primary" className="w-full mt-3">
Create Workspace
</Button>
<DialogFooter actions={[{ label: "Create Workspace", color: "primary", form: formId }]} />
</VStack>
);
}
@@ -1,6 +1,6 @@
import type { Workspace } from "@yaakapp-internal/models";
import { workspacesAtom } from "@yaakapp-internal/models";
import { HStack, VStack } from "@yaakapp-internal/ui";
import { VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import slugify from "slugify";
@@ -8,9 +8,9 @@ import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize";
import { rpc } from "../lib/rpc";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner";
import { DialogFooter } from "./core/Dialog";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
@@ -87,8 +87,8 @@ function ExportDataDialogContent({
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0;
return (
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
<VStack space={3} className="overflow-auto px-5 pb-6">
<>
<VStack space={3} className="pb-4">
<CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
@@ -141,29 +141,22 @@ function ExportDataDialogContent({
/>
</DetailsBanner>
</VStack>
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
<div>
<DialogFooter
leftSlot={
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
Create Run Button
</Link>
</div>
<HStack space={2} justifyContent="end">
<Button size="sm" className="focus" variant="border" onClick={onHide}>
Cancel
</Button>
<Button
size="sm"
type="submit"
className="focus"
color="primary"
disabled={noneSelected}
onClick={() => handleExport()}
>
Export{" "}
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
</Button>
</HStack>
</footer>
</div>
}
actions={[
{ label: "Cancel", onClick: onHide },
{
label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`,
color: "primary",
disabled: noneSelected,
onClick: () => handleExport(),
},
]}
/>
</>
);
}
@@ -1,3 +1,4 @@
import type { NavigateWorkspace } from "@yaakapp-internal/tauri-client";
import { activeRequestAtom } from "../hooks/useActiveRequest";
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
@@ -31,6 +32,16 @@ export function GlobalHooks() {
fireAndForget(router.navigate({ to: "/", search: { home: true } }));
});
usePlatformEvent<NavigateWorkspace>("navigate_workspace", ({ workspaceId, environmentId }) => {
fireAndForget(
router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId },
search: { environment_id: environmentId },
}),
);
});
useHotKey(
"request.rename",
async () => {
@@ -239,6 +239,7 @@ export function GrpcEditor({
showDialog({
title: "Configure Schema",
size: "md",
className: "h-[30rem]",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
@@ -1,11 +1,11 @@
import type { GrpcRequest } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { useGrpc } from "../hooks/useGrpc";
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
import { pluralizeCount } from "../lib/pluralize";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { DialogFooter } from "./core/Dialog";
import { PathList } from "./core/PathList";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
@@ -20,7 +20,10 @@ export function GrpcProtoSelectionDialog(props: Props) {
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
}
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
function GrpcProtoSelectionDialogWithRequest({
request,
onDone,
}: Props & { request: GrpcRequest }) {
const protoFilesKv = useGrpcProtoFiles(request.id);
const protoFiles = protoFilesKv.value ?? [];
const grpc = useGrpc(request, null, protoFiles);
@@ -38,54 +41,8 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
}
return (
<VStack className="flex-col-reverse mb-3" space={3}>
{/* Buttons on top so they get focus first */}
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
<Button
color="primary"
variant="border"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
}}
>
Add Proto Files
</Button>
<Button
variant="border"
color="primary"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
}}
>
Add Import Folders
</Button>
<Button
isLoading={grpc.reflect.isFetching}
disabled={grpc.reflect.isFetching}
variant="border"
color="secondary"
onClick={() => grpc.reflect.refetch()}
>
Refresh Schema
</Button>
</HStack>
<VStack space={5}>
<>
<VStack space={5} className="pb-4">
{reflectError && (
<Banner color="warning">
<h1 className="font-bold">
@@ -128,46 +85,47 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
</Banner>
)}
{protoFiles.length > 0 && (
<table className="w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="text-text-subtlest" colSpan={3}>
Added File Paths
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{protoFiles.map((f, i) => {
const parts = f.split("/");
// oxlint-disable-next-line no-array-index-key -- none
return (
<tr key={f + i} className="group">
<td>
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
</td>
<td className="pl-1 font-mono text-sm" title={f}>
{parts.length > 3 && ".../"}
{parts.slice(-3).join("/")}
</td>
<td className="w-0 py-0.5">
<IconButton
title="Remove file"
variant="border"
size="xs"
icon="trash"
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100"
onClick={async () => {
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
)}
<PathList
items={protoFiles.map((path) => ({
path,
icon: path.endsWith(".proto") ? "file_code" : "folder_code",
}))}
onRemove={async (path) => {
await protoFilesKv.set(protoFiles.filter((p) => p !== path));
await grpc.reflect.refetch();
}}
addLabel="Add proto files or an import folder"
addItems={[
{
label: "Proto files",
icon: "file_code",
onSelect: async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
},
},
{
label: "Import folder",
icon: "folder_code",
onSelect: async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
},
},
]}
/>
{reflectionUnimplemented && protoFiles.length === 0 && (
<Banner>
<InlineCode>{request.url}</InlineCode> doesn&apos;t implement{" "}
@@ -178,6 +136,17 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
</Banner>
)}
</VStack>
</VStack>
<DialogFooter
actions={[
{
label: "Refresh Schema",
isLoading: grpc.reflect.isFetching,
disabled: grpc.reflect.isFetching,
onClick: () => grpc.reflect.refetch(),
},
{ label: "Done", color: "primary", onClick: onDone },
]}
/>
</>
);
}
+307 -339
View File
@@ -1,33 +1,36 @@
import {
type Folder,
type ImportDestination,
type ImportPlan,
type ImportPlanItem,
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import { Banner, FormattedError, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { formatDistanceToNowStrict } from "date-fns";
import { useEffect, useMemo, useRef, useState } from "react";
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { errorMessage } from "../lib/errorMessage";
import { pluralize } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import {
ImportSourceList,
type ImportSourceDetection,
type ImportSourcePath,
InlineButton,
toSourcePath,
} from "./ImportSourceList";
import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree";
import { Chip } from "./core/Chip";
import { DialogFooter } from "./core/Dialog";
import { IconTooltip } from "./core/IconTooltip";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
interface Props {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise<ImportPlan>;
detectSource: (source: ImportSourcePath) => Promise<string>;
listSources: (workspaceId: string) => Promise<ImportSource[]>;
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
commit: (plan: ImportPlan) => Promise<void>;
@@ -35,25 +38,6 @@ interface Props {
onError: (err: unknown) => void;
}
/**
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
*/
function isFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
/**
* Loads the current workspace's linked sources before rendering the dialog, so the inner
* component can construct its initial state (prefilled path, destination) in one pass instead of
@@ -82,30 +66,19 @@ export function ImportDataDialog(props: Props) {
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
}
function latestSource(sources: ImportSource[]): ImportSource | null {
return sources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
}
function LoadedImportDataDialog({
currentWorkspace,
workspaces,
selectedFolder,
planFile,
planUrl,
listSources,
planSources,
detectSource,
findSourcesForOrigin,
commit,
cancel,
onError,
initialSources,
}: Props & { initialSources: ImportSource[] }) {
// A workspace with a linked source is probably being re-imported, so start from that source
const prefill = latestSource(initialSources);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [sourceError, setSourceError] = useState<unknown>(null);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [items, setItems] = useState<ImportPlanItem[]>([]);
// null means no explicit choice yet, so the default below applies
@@ -113,65 +86,82 @@ function LoadedImportDataDialog({
null,
);
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
prefill != null ? initialSources : [],
const [originSources, setOriginSources] = useState<ImportSource[]>(initialSources);
const initialPaths = useMemo(
() => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath),
[initialSources],
);
const [originSources, setOriginSources] = useState<ImportSource[]>(
prefill != null ? [prefill] : [],
const [sources, setSources] = useState<ImportSourcePath[]>(initialPaths);
const knownPaths = useRef(new Set(initialPaths.map((source) => source.path)));
const [detections, setDetections] = useState<Record<string, ImportSourceDetection>>({});
const detect = useCallback(
(added: ImportSourcePath[]) => {
setDetections((prev) => ({
...prev,
...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])),
}));
for (const source of added) {
const settle = (detection: ImportSourceDetection) =>
setDetections((prev) =>
source.path in prev ? { ...prev, [source.path]: detection } : prev,
);
detectSource(source).then(
(importer) => settle({ status: "ok", importer }),
(err) => settle({ status: "error", message: errorMessage(err) }),
);
}
},
[detectSource],
);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
const [isHovering, setIsHovering] = useState<boolean>(false);
const ref = useRef<HTMLDivElement>(null);
const trimmedSource = source?.trim() ?? "";
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
const allDetected = sources.every((source) => detections[source.path]?.status === "ok");
useEffect(() => {
detect(initialPaths);
}, [detect, initialPaths]);
const selectSource = (value: string) => {
setSource(value);
// Remount the input so it shows the path of the newly-picked file
setForceUpdateKey((k) => k + 1);
const addSources = useCallback(
(added: ImportSourcePath[]) => {
setSourceError(null);
const fresh = added.filter(({ path }) => {
if (knownPaths.current.has(path)) return false;
knownPaths.current.add(path);
return true;
});
if (fresh.length === 0) return;
setSources((existing) => [...existing, ...fresh]);
detect(fresh);
},
[detect],
);
const removeSource = (path: string) => {
setSourceError(null);
knownPaths.current.delete(path);
setSources((current) => current.filter((source) => source.path !== path));
setDetections(({ [path]: _removed, ...rest }) => rest);
};
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
useEffect(() => {
return platform.window.onDragDrop((event) => {
if (event.type === "over") {
const p = event.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
} else if (event.type === "drop" && isHovering) {
const p = event.paths[0];
if (p) selectSource(p);
setIsHovering(false);
} else {
setIsHovering(false);
}
});
}, [isHovering, setSource]);
useEffect(() => {
if (trimmedSource === "") {
let cancelled = false;
if (sources.length === 0) {
setOriginSources([]);
return;
}
let cancelled = false;
const timeout = setTimeout(() => {
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
.then((sources) => {
if (!cancelled) setOriginSources(sources);
})
.catch(() => setOriginSources([]));
}, 300);
Promise.all(
sources.map(({ path, kind }) =>
findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }),
),
)
.then((found) => {
if (!cancelled) setOriginSources(found.flat());
})
.catch(() => {
if (!cancelled) setOriginSources([]);
});
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [trimmedSource, filePath, findSourcesForOrigin]);
}, [sources, findSourcesForOrigin]);
// The one workspace this file is linked to, if there is exactly one.
// The one workspace these sources are linked to, if there is exactly one.
const linkedWorkspace = useMemo(() => {
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
if (ids.length !== 1) return null;
@@ -193,32 +183,6 @@ function LoadedImportDataDialog({
? otherWorkspaceId
: null;
useEffect(() => {
if (destinationWorkspaceId == null) {
setLinkedSources([]);
return;
}
let cancelled = false;
listSources(destinationWorkspaceId)
.then((sources) => {
if (!cancelled) setLinkedSources(sources);
})
.catch(() => setLinkedSources([]));
return () => {
cancelled = true;
};
}, [destinationWorkspaceId, listSources]);
const handleSelectFile = async () => {
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
if (selected == null) return;
selectSource(selected);
};
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
const destination = (): ImportDestination => {
if (destinationWorkspaceId == null) {
return { type: "new_workspace" };
@@ -226,21 +190,18 @@ function LoadedImportDataDialog({
return {
type: "existing_workspace",
workspaceId: destinationWorkspaceId,
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
};
};
const handlePreview = async () => {
setIsLoading(true);
setSourceError(null);
try {
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
const nextPlan = await planSources(sources, destination());
setPlan(nextPlan);
setItems(nextPlan.items);
} catch (err) {
onError(err);
setSourceError(err);
} finally {
setIsLoading(false);
}
@@ -258,8 +219,6 @@ function LoadedImportDataDialog({
}
};
const itemTree = useMemo(() => buildItemTree(items), [items]);
// 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.
@@ -313,196 +272,215 @@ function LoadedImportDataDialog({
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(", ")];
return [
names.length === 1 ? "New workspace" : `${names.length} new workspaces`,
names.join(", "),
];
}
const { workspaceId, folderId } = plan.destination;
const { workspaceId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return [
"Destination",
folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name,
];
return ["Destination", 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<TreeRow> = (() => {
const planned = plan.resources.workspaces[0];
const workspaceRoots: CheckboxTreeNode<TreeRow>[] = (() => {
const planDestination = plan.destination;
const existing =
planDestination.type === "existing_workspace"
? workspaces.find((w) => w.id === planDestination.workspaceId)
: null;
return {
key: existing?.id ?? planned?.id ?? "workspace",
const destinations = existing ? [existing] : plan.resources.workspaces;
if (destinations.length === 1) {
const workspace = destinations[0]!;
return [
{
key: workspace.id,
data: {
kind: "destination",
label: workspace.name,
isNew: planDestination.type === "new_workspace",
},
children: buildItemTree(items),
},
];
}
const workspaceById = new Map(
[
...plan.resources.environments,
...plan.resources.folders,
...plan.resources.httpRequests,
...plan.resources.grpcRequests,
...plan.resources.websocketRequests,
].map((resource) => [resource.id, resource.workspaceId]),
);
return destinations.map((workspace) => ({
key: workspace.id,
data: {
kind: "destination",
label: existing?.name ?? planned?.name ?? "New workspace",
label: workspace.name,
isNew: planDestination.type === "new_workspace",
},
children: itemTree,
};
children: buildItemTree(
items.filter((item) => workspaceById.get(item.modelId) === workspace.id),
),
}));
})();
return (
<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={destinationLabel} value={destinationValue} />
</div>
<>
<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={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>
{plan.warnings
.filter(
(warning, index, warnings) =>
warnings.findIndex(
(other) =>
other.title === warning.title &&
other.detail === warning.detail &&
other.level === warning.level,
) === index,
)
.map((warning) => (
<Banner
key={`${warning.level}:${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>
))}
{workspaceRoots.map((root) => (
<div key={root.key} className="rounded-lg border border-border-subtle px-3 py-2">
<CheckboxTree
node={root}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) =>
(n.data.kind === "destination" && workspaceRoots.length > 1) ||
(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>
</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)}
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>
<HStack space={2} alignItems="center" className="mt-3">
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
<Button
className="ml-auto"
color="secondary"
variant="border"
disabled={isLoading}
onClick={() => {
setPlan(null);
setItems([]);
}}
>
Back
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done"}
</Button>
</HStack>
</VStack>
))}
</VStack>
<DialogFooter
leftSlot={
footerNote !== "" ? (
<div className="text-xs text-text-subtle">{footerNote}</div>
) : undefined
}
actions={[
{
label: "Back",
disabled: isLoading,
onClick: () => {
setPlan(null);
setItems([]);
},
},
{
label: isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done",
color: "primary",
isLoading,
onClick: handleCommit,
},
]}
/>
</>
);
}
const lastImported =
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
linkedSources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<button
type="button"
onClick={handleSelectFile}
className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center",
isHovering
? "border-notice bg-surface-highlight"
: "border-border hover:border-text-subtle",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
{/* Fixed height so the region doesn't resize between the empty and selected states */}
<div className="h-6 w-full flex items-center justify-center">
{filePath == null ? (
<div className="text-text">
<strong className="font-semibold">Choose a file</strong> or drag it here
</div>
) : (
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
{fileName(filePath)}
</div>
)}
</div>
<div className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, curl, and Yaak exports
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
const destinationPicker = (
<HStack
space={2}
alignItems="center"
className={classNames(workspaces.length === 0 && "hidden")}
>
<Select
name="import-destination-kind"
label="Import into"
labelPosition="left"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
value={destinationKind}
onChange={setDestinationChoice}
options={[
{ value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
/>
<VStack space={2} className={classNames(workspaces.length === 0 && "hidden")}>
{destinationKind === "other" && (
<Select
name="import-destination-kind"
label="Import location"
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={destinationKind}
onChange={setDestinationChoice}
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
{destinationKind === "other" && (
<Select
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
)}
</HStack>
);
return (
<>
<VStack space={4} className="h-full pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<ImportSourceList
sources={sources}
detections={detections}
disabled={isLoading}
onAdd={addSources}
onRemove={removeSource}
onError={setSourceError}
/>
{sourceError != null && (
<Banner color="warning">
<FormattedError>{errorMessage(sourceError)}</FormattedError>
</Banner>
)}
{lastImported != null && destinationWorkspaceId != null ? (
{linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
<div className="text-xs text-text-subtle">
Last imported from {lastImported.originLabel} ·{" "}
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
</div>
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
<div className="text-xs text-text-subtle">
This file was last imported into{" "}
<button
type="button"
className="underline hocus:text-text"
{sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "}
<InlineButton
onClick={() => {
if (linkedWorkspace.id === currentWorkspace?.id) {
setDestinationChoice("current");
@@ -513,36 +491,28 @@ function LoadedImportDataDialog({
}}
>
{linkedWorkspace.name}
</button>
</InlineButton>
</div>
) : null}
{canTargetSelectedFolder && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}”`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button
color="primary"
disabled={
trimmedSource === "" ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null)
}
isLoading={isLoading}
onClick={handlePreview}
>
{isLoading ? "Analyzing" : "Preview Import"}
</Button>
</HStack>
</VStack>
<DialogFooter
leftSlot={destinationPicker}
actions={[
{ label: "Cancel", disabled: isLoading, onClick: cancel },
{
label: isLoading ? "Analyzing" : "Preview Import",
color: "primary",
disabled:
sources.length === 0 ||
!allDetected ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null),
isLoading,
onClick: handlePreview,
},
]}
/>
</>
);
}
@@ -556,10 +526,12 @@ function ImportTreeRow({
if (row.kind !== "item") {
return (
<>
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
{row.kind === "destination" && (
<Icon icon="box" size="sm" className="shrink-0 text-text-subtle" />
)}
<div className="truncate flex-1">{row.label}</div>
{row.kind === "destination" && row.isNew && (
<ActionChip label="new" help="Created by this import" className="text-success" />
<ActionChip label="new" help="Created by this import" color="success" />
)}
</>
);
@@ -569,11 +541,6 @@ function ImportTreeRow({
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" />
)}
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0">
@@ -593,17 +560,7 @@ function ImportTreeRow({
</div>
) : (
label != null && (
<ActionChip
label={label}
help={actionHelp(item)}
className={classNames(
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",
)}
/>
<ActionChip label={label} help={actionHelp(item)} color={actionColor(item)} />
)
)}
</>
@@ -613,26 +570,37 @@ function ImportTreeRow({
function ActionChip({
label,
help,
className,
color,
}: {
label: string;
help: string | null;
className?: string;
color: ComponentProps<typeof Chip>["color"];
}) {
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,
)}
>
<Chip color={color}>
{label}
{help != null && <IconTooltip content={help} iconSize="xs" />}
</InlineCode>
</Chip>
);
}
function actionColor(item: ImportPlanItem): ComponentProps<typeof Chip>["color"] {
switch (item.action) {
case "create":
return "success";
case "update":
return "info";
case "delete":
return "danger";
case "keep_local":
return item.selected ? "warning" : "default";
case "unchanged":
case "conflict":
case "ignored":
return "default";
}
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
@@ -701,7 +669,7 @@ function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): I
*/
type TreeRow =
| { kind: "destination"; label: string; isNew: boolean }
| { kind: "group"; label: string; icon: IconProps["icon"] }
| { kind: "group"; label: string }
| { kind: "item"; item: ImportPlanItem };
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
@@ -743,7 +711,7 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
return [
{
key: "group:environments",
data: { kind: "group", label: "Variables", icon: "variable" },
data: { kind: "group", label: "Variables" },
children: environments.map((e) => toNode(e, new Set())),
},
...nodes,
@@ -0,0 +1,271 @@
import { platform } from "@yaakapp-internal/platform";
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { PathList, splitPath } from "./core/PathList";
import { PlainInput } from "./core/PlainInput";
export interface ImportSourcePath {
path: string;
kind?: "file" | "folder" | "url";
}
export type ImportSourceDetection =
| { status: "loading" }
| { status: "ok"; importer: string }
| { status: "error"; message: string };
interface Props {
sources: ImportSourcePath[];
detections: Record<string, ImportSourceDetection>;
disabled?: boolean;
onAdd: (sources: ImportSourcePath[]) => void;
onRemove: (path: string) => void;
onError: (err: unknown) => void;
}
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
export function isImportFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
export function toSourcePath(path: string): ImportSourcePath {
return { path, kind: isImportFilePath(path) ? undefined : "url" };
}
export function ImportSourceList({
sources,
detections,
disabled,
onAdd,
onRemove,
onError,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [isHovering, setIsHovering] = useState(false);
const [pathInput, setPathInput] = useState("");
const [showPathInput, setShowPathInput] = useState(false);
const trimmedPath = pathInput.trim();
const closePathInput = () => {
setPathInput("");
setShowPathInput(false);
};
const addPath = () => {
if (!trimmedPath || disabled) return;
onAdd([toSourcePath(trimmedPath)]);
closePathInput();
};
useEffect(() => {
if (disabled) return;
return platform.window.onDragDrop((event) => {
if (event.type === "leave") {
setIsHovering(false);
return;
}
const rect = ref.current?.getBoundingClientRect();
const { x, y } = event.position;
const isOver =
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (event.type === "drop") {
if (isOver) onAdd(event.paths.map((path) => ({ path })));
setIsHovering(false);
} else {
setIsHovering(isOver);
}
});
}, [disabled, onAdd]);
const pick = async (directory: boolean) => {
try {
const selected = await platform.dialog.open({
title: directory ? "Select Import Folder" : "Select Import Files",
multiple: true,
directory,
});
if (selected == null) return;
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
} catch (err) {
onError(err);
}
};
const handlePathKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
addPath();
} else if (event.key === "Escape") {
event.stopPropagation();
closePathInput();
}
};
const pathInputEl = (
<PlainInput
label="URL or file path"
hideLabel
size="sm"
autoFocus
disabled={disabled}
placeholder="Paste a URL or a file path"
onChange={setPathInput}
onKeyDownCapture={handlePathKeyDown}
rightSlot={
<div className="flex items-center gap-1 my-1 mr-1">
{trimmedPath !== "" && (
<Button size="xs" variant="border" onClick={addPath}>
Add
</Button>
)}
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
</div>
}
/>
);
if (sources.length === 0) {
return (
<div className="flex-1 flex flex-col gap-2">
<div
ref={ref}
className={classNames(
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8!" />
{isHovering ? (
<span>Drop to add</span>
) : (
<span>
Choose{" "}
<InlineButton disabled={disabled} onClick={() => pick(false)}>
files
</InlineButton>
, a{" "}
<InlineButton disabled={disabled} onClick={() => pick(true)}>
folder
</InlineButton>
, or a{" "}
<InlineButton
disabled={disabled || showPathInput}
onClick={() => setShowPathInput(true)}
>
URL
</InlineButton>
, or drag them here
</span>
)}
<span className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
</span>
</div>
{showPathInput && pathInputEl}
</div>
);
}
return (
<PathList
ref={ref}
hovering={isHovering}
disabled={disabled}
onRemove={onRemove}
addLabel="Add files, a folder, or a URL"
addHint="or drop them here"
addItems={[
{ label: "Files", icon: "file", onSelect: () => pick(false) },
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
{
label: "URL or file path",
icon: "globe",
disabled: showPathInput,
onSelect: () => setShowPathInput(true),
},
]}
items={sources.map((source) => {
const detection = detections[source.path];
return {
path: source.path,
icon: sourceIcon(source),
failed: detection?.status === "error",
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
};
})}
>
{showPathInput && pathInputEl}
</PathList>
);
}
function DetectionSubtitle({
path,
detection,
}: {
path: string;
detection: ImportSourceDetection | undefined;
}) {
const { parent } = splitPath(path);
if (detection?.status === "loading") {
return (
<>
<LoadingIcon size="xs" />
Detecting format
</>
);
}
if (detection?.status === "error") return detection.message;
return (
<>
{detection?.status === "ok" && <span>{detection.importer}</span>}
{detection?.status === "ok" && parent !== "" && <span>·</span>}
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
</>
);
}
function sourceIcon({ kind }: ImportSourcePath) {
switch (kind) {
case "url":
return "globe";
case "folder":
return "folder";
case "file":
return "file";
case undefined:
return "import";
}
}
export function InlineButton({
children,
disabled,
onClick,
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
className="text-text underline underline-offset-2 hocus:text-primary disabled:opacity-disabled"
onClick={onClick}
>
{children}
</button>
);
}
+74 -59
View File
@@ -21,6 +21,9 @@ import { Button } from "./core/Button";
/** Shown instead of a workspace when there are none. */
export function Onboarding() {
const settings = useAtomValue(settingsAtom);
const { capabilities } = platform;
const canImport = capabilities.plugins;
const showAlreadyUsing = capabilities.localFiles || capabilities.git || canImport;
const [busy, setBusy] = useState<OnboardingChoice | null>(null);
const choose = (choice: OnboardingChoice, run: () => Promise<void> | void) => async () => {
@@ -55,20 +58,24 @@ export function Onboarding() {
<div className="flex flex-col gap-1.5">
<Heading>How would you like to get started?</Heading>
<p className="text-text-subtle">
Bring over existing work, try a real API, or start fresh.
{canImport
? "Bring over existing work, try a real API, or start fresh."
: "Try a real API, or start fresh."}
</p>
</div>
<div className="flex flex-col gap-2">
<StartOption
color="primary"
icon="folder_input"
title="Migrate from another tool"
description="Postman, Insomnia, OpenAPI, or curl"
busy={busy === "import"}
disabled={busy != null}
onClick={choose("import", () => importData.mutateAsync())}
/>
{canImport && (
<StartOption
color="primary"
icon="folder_input"
title="Migrate from another tool"
description="Postman, Insomnia, Bruno, OpenAPI, or curl"
busy={busy === "import"}
disabled={busy != null}
onClick={choose("import", () => importData.mutateAsync())}
/>
)}
<StartOption
color="info"
icon="flask"
@@ -89,56 +96,64 @@ export function Onboarding() {
/>
</div>
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
<span className="text-sm text-text-subtle">Already using Yaak?</span>
<div className="flex flex-wrap gap-2">
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="folder_open" size="sm" />}
disabled={busy != null}
onClick={choose("open_folder", async () => {
const dir = await platform.dialog.open({
title: "Select Workspace Directory",
directory: true,
multiple: false,
});
if (dir == null) return;
await openWorkspaceFromSyncDir.mutateAsync(dir);
})}
>
Open folder
</Button>
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="git_branch" size="sm" />}
disabled={busy != null}
onClick={choose("clone_git", () => {
showDialog({
id: "clone-git-repository",
size: "md",
title: "Clone Git Repository",
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
});
})}
>
Clone repository
</Button>
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="import" size="sm" />}
disabled={busy != null}
onClick={choose("import_yaak", () => importData.mutateAsync())}
>
Import
</Button>
{showAlreadyUsing && (
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
<span className="text-sm text-text-subtle">Already using Yaak?</span>
<div className="flex flex-wrap gap-2">
{capabilities.localFiles && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="folder_open" size="sm" />}
disabled={busy != null}
onClick={choose("open_folder", async () => {
const dir = await platform.dialog.open({
title: "Select Workspace Directory",
directory: true,
multiple: false,
});
if (dir == null) return;
await openWorkspaceFromSyncDir.mutateAsync(dir);
})}
>
Open folder
</Button>
)}
{capabilities.git && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="git_branch" size="sm" />}
disabled={busy != null}
onClick={choose("clone_git", () => {
showDialog({
id: "clone-git-repository",
size: "md",
title: "Clone Git Repository",
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
});
})}
>
Clone repository
</Button>
)}
{canImport && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="import" size="sm" />}
disabled={busy != null}
onClick={choose("import_yaak", () => importData.mutateAsync())}
>
Import
</Button>
)}
</div>
</div>
</div>
)}
</div>
</div>
</div>
@@ -31,6 +31,10 @@ export function RedirectToLatestWorkspace() {
request_id: requestId,
};
// Whatever created the first workspace may have already navigated into it
const currentId = router.history.location.pathname.match(/^\/workspaces\/([^/]+)/)?.[1];
if (workspaces.some((w) => w.id === currentId)) return;
console.log("Redirecting to workspace", params, search);
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
})(),
+7 -3
View File
@@ -941,10 +941,14 @@ const sidebarTreeAtom = atom<
if (node.children != null) {
childItems.sort((a, b) => {
if (a.sortPriority === b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1;
if (a.sortPriority !== b.sortPriority) {
return a.sortPriority - b.sortPriority;
}
return a.sortPriority - b.sortPriority;
// Keep equal-priority items in a stable order when they are edited.
if (a.createdAt !== b.createdAt) {
return a.createdAt > b.createdAt ? 1 : -1;
}
return a.id === b.id ? 0 : a.id > b.id ? 1 : -1;
});
for (const item of childItems) {
@@ -1,11 +1,11 @@
import type { Workspace } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { switchWorkspace } from "../commands/switchWorkspace";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
interface Props {
hide: () => void;
@@ -16,45 +16,33 @@ export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
const settings = useAtomValue(settingsAtom);
const [remember, setRemember] = useState<boolean>(false);
const open = async (inNewWindow: boolean) => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: inNewWindow });
}
};
return (
<VStack space={3}>
<p>
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
</p>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
className="focus"
color="primary"
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: false });
}
}}
>
This Window
</Button>
<Button
className="focus"
color="secondary"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: true });
}
}}
>
New Window
</Button>
</HStack>
{settings && (
<HStack justifyContent="end">
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
</HStack>
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
)}
<DialogFooter
inline
actions={[
{
label: "New Window",
rightSlot: <Icon icon="external_link" />,
onClick: () => open(true),
},
{ label: "This Window", color: "primary", autoFocus: true, onClick: () => open(false) },
]}
/>
</VStack>
);
}
@@ -11,7 +11,7 @@ import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { parseTemplate } from "@yaakapp-internal/templates";
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
@@ -24,7 +24,7 @@ import { showDialog } from "../lib/dialog";
import { convertTemplateToInsecure } from "../lib/encryption";
import { jotaiStore } from "../lib/jotai";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { Button } from "./core/Button";
import { DialogFooter } from "./core/Dialog";
import { collectArgumentValues } from "./core/Editor/twig/util";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
@@ -91,6 +91,7 @@ function InitializedTemplateFunctionDialog({
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
const formId = useId();
const tokens: Tokens = useMemo(() => {
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
@@ -160,13 +161,14 @@ function InitializedTemplateFunctionDialog({
return (
<form
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
id={formId}
className="grid grid-rows-[minmax(0,1fr)_auto] h-full"
onSubmit={(e) => {
e.preventDefault();
handleDone();
}}
>
<div className="overflow-y-auto h-full px-6">
<div className="overflow-y-auto h-full">
{name === "secure" ? (
<PlainInput
required
@@ -188,75 +190,71 @@ function InitializedTemplateFunctionDialog({
/>
)}
</div>
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isLoading && <LoadingIcon size="xs" />}
</HStack>
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto] border-t border-t-border pt-3">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isLoading && <LoadingIcon size="xs" />}
</HStack>
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
/>
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
/>
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton
size="xs"
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
/>
</div>
</div>
</div>
) : (
<span />
)}
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
{templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)}
<Button type="submit" color="primary">
Save
</Button>
</div>
</div>
) : (
<span />
)}
<DialogFooter
actions={[
...(templateFunction.data.name === "secure"
? [{ label: "Reveal Encryption Key", onClick: setupOrConfigureEncryption }]
: []),
{ label: "Save", color: "primary", form: formId },
]}
/>
</form>
);
}
@@ -272,7 +270,6 @@ TemplateFunctionDialog.show = (
id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md",
className: "h-240",
noPadding: true,
title: <InlineCode>{fn.name}(…)</InlineCode>,
description: fn.description,
render: ({ hide }) => {
+32
View File
@@ -0,0 +1,32 @@
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
color?: Color | "default";
className?: string;
children: ReactNode;
}
/** A small tinted label, like a Gmail or Trello label */
export function Chip({ color = "default", className, children }: Props) {
return (
<span
className={classNames(
className,
"text-xs font-medium rounded-md px-2 py-0.5 bg-current/10 border border-current/10 shrink-0 whitespace-nowrap",
"inline-flex items-center gap-1",
color === "default" && "text-text-subtle",
color === "primary" && "text-primary",
color === "secondary" && "text-secondary",
color === "info" && "text-info",
color === "success" && "text-success",
color === "notice" && "text-notice",
color === "warning" && "text-warning",
color === "danger" && "text-danger",
)}
>
{children}
</span>
);
}
+17 -12
View File
@@ -1,9 +1,8 @@
import type { Color } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import type { FormEvent } from "react";
import { useState } from "react";
import { useId, useState } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Button } from "./Button";
import { DialogFooter } from "./Dialog";
import { PlainInput } from "./PlainInput";
export interface ConfirmProps {
@@ -22,6 +21,7 @@ export function Confirm({
color = "primary",
}: ConfirmProps) {
const [confirm, setConfirm] = useState<string>("");
const formId = useId();
const handleHide = () => {
onResult(false);
onHide();
@@ -38,7 +38,7 @@ export function Confirm({
};
return (
<form className="flex flex-col" onSubmit={handleSuccess}>
<form id={formId} className="flex flex-col" onSubmit={handleSuccess}>
{requireTyping && (
<PlainInput
autoFocus
@@ -61,14 +61,19 @@ export function Confirm({
}
/>
)}
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
<Button autoFocus={!requireTyping} type="submit" color={color} disabled={!didConfirm}>
{confirmText ?? "Confirm"}
</Button>
<Button onClick={handleHide} variant="border">
Cancel
</Button>
</HStack>
<DialogFooter
inline
actions={[
{ label: "Cancel", onClick: handleHide },
{
label: confirmText ?? "Confirm",
color,
form: formId,
disabled: !didConfirm,
autoFocus: !requireTyping,
},
]}
/>
</form>
);
}
+73 -4
View File
@@ -1,9 +1,11 @@
import type { DialogSize } from "@yaakapp-internal/plugins";
import { Heading, Overlay } from "@yaakapp-internal/ui";
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { useMemo } from "react";
import { createContext, useContext, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Button, type ButtonProps } from "./Button";
import { IconButton } from "./IconButton";
export interface DialogProps {
@@ -38,6 +40,7 @@ export function Dialog({
vAlign = "center",
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const descriptionId = useMemo(
() => (description ? Math.random().toString(36).slice(2) : undefined),
[description],
@@ -71,7 +74,7 @@ export function Dialog({
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
"grid grid-rows-[auto_auto_minmax(0,1fr)]",
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
"grid-cols-1", // must be here for inline code blocks to correctly break words
"relative bg-surface pointer-events-auto",
"rounded-lg",
@@ -108,9 +111,11 @@ export function Dialog({
!noScroll && "overflow-y-auto overflow-x-hidden",
)}
>
{children}
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
</div>
<div ref={setFooterEl} className="contents" />
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
@@ -129,3 +134,67 @@ export function Dialog({
</Overlay>
);
}
const DialogFooterSlot = createContext<HTMLDivElement | null>(null);
export interface DialogFooterAction {
label: string;
onClick?: () => void;
/** Submit the form with this id, which can be anywhere in the dialog body */
form?: string;
color?: ButtonProps["color"];
variant?: ButtonProps["variant"];
disabled?: boolean;
isLoading?: boolean;
autoFocus?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
/**
* The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is
* placed below the scrolling body, outside its padding. `inline` drops the divider for small
* dialogs whose body never scrolls.
*/
export function DialogFooter({
actions,
leftSlot,
inline,
}: {
actions: DialogFooterAction[];
leftSlot?: ReactNode;
inline?: boolean;
}) {
const slot = useContext(DialogFooterSlot);
const footer = (
<footer
className={classNames(
"px-6 flex items-center gap-3",
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
)}
>
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
{actions.map((action, i) => (
<Button
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
key={i}
type={action.form != null ? "submit" : "button"}
form={action.form}
color={action.color ?? "secondary"}
variant={action.variant ?? (action.color == null ? "border" : "solid")}
disabled={action.disabled}
isLoading={action.isLoading}
autoFocus={action.autoFocus}
leftSlot={action.leftSlot}
rightSlot={action.rightSlot}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</HStack>
</footer>
);
return slot == null ? footer : createPortal(footer, slot);
}
@@ -0,0 +1,41 @@
import { syntaxTree } from "@codemirror/language";
import type { Extension } from "@codemirror/state";
import type { EditorView, ViewUpdate } from "@codemirror/view";
import { ViewPlugin } from "@codemirror/view";
import type { JsonPathSegment } from "./jsonPath";
import { jsonPathSegmentsAt } from "./jsonPath";
export interface BreadcrumbUpdate {
/** Path to the cursor, or `null` when the document isn't JSON. */
segments: JsonPathSegment[] | null;
}
/**
* Reports the JSON path under the cursor when the editor is created (it may restore a
* cached selection), and whenever the selection, document, or parsed tree changes. The
* tree matters because large documents parse in the background, so a path read early
* can be wrong until the parser reaches the cursor.
*
* The callback is not fired on blur: moving focus to the filter box (by clicking
* a crumb) must not erase the breadcrumb that was just acted on.
*/
export function jsonBreadcrumbExtension(onUpdate: (update: BreadcrumbUpdate) => void): Extension {
const report = (view: EditorView) => {
onUpdate({ segments: jsonPathSegmentsAt(view.state, view.state.selection.main.head) });
};
return ViewPlugin.define((view) => {
report(view);
return {
update(update: ViewUpdate) {
if (
update.selectionSet ||
update.docChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)
) {
report(update.view);
}
},
};
});
}
@@ -0,0 +1,136 @@
import { EditorState } from "@codemirror/state";
import { jsonc } from "@shopify/lang-jsonc";
import { forceParsing } from "@codemirror/language";
import { describe, expect, test } from "vite-plus/test";
import { jsonPathSegmentsAt, jsonPathToSegments, segmentsToJsonPath } from "./jsonPath";
function stateFor(doc: string): EditorState {
const state = EditorState.create({ doc, extensions: [jsonc()] });
// Ensure the whole doc is parsed so resolveInner sees a complete tree.
forceParsing({ state } as never, doc.length, 5000);
return state;
}
/** JSONPath produced when the cursor sits just after the first occurrence of `needle`. */
function pathAt(doc: string, needle: string): string {
const pos = doc.indexOf(needle) + needle.length;
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
if (segs == null) throw new Error("no JSON tree");
return segmentsToJsonPath(segs);
}
/** JSONPath produced when the cursor sits just before the first occurrence of `needle`. */
function pathBefore(doc: string, needle: string): string {
const pos = doc.indexOf(needle);
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
if (segs == null) throw new Error("no JSON tree");
return segmentsToJsonPath(segs);
}
describe("jsonPathSegmentsAt", () => {
test("nested object keys", () => {
const doc = `{ "address": { "geo": { "lat": "-37" } } }`;
expect(pathAt(doc, `-37`)).toBe(`$.address.geo.lat`);
});
test("array element index", () => {
const doc = `{ "featured": [ { "id": 1 }, { "id": 2 } ] }`;
expect(pathAt(doc, `2`)).toBe(`$.featured[1].id`);
});
test("root array element", () => {
const doc = `[ { "id": 1 }, { "id": 2 } ]`;
expect(pathAt(doc, `"id": 2`)).toBe(`$[1].id`);
});
test("key containing a dot uses bracket-quote notation", () => {
const doc = `{ "sort": { "link": { "filter": { "category.id": [ "100" ] } } } }`;
expect(pathAt(doc, `"100"`)).toBe(`$.sort.link.filter["category.id"][0]`);
});
test("cursor on a property name resolves that key", () => {
const doc = `{ "outer": { "inner": 5 } }`;
expect(pathAt(doc, `"inner"`)).toBe(`$.outer.inner`);
});
test("deeply nested arrays and objects", () => {
const doc = `{ "a": [ { "b": [ 10, 20, 30 ] } ] }`;
expect(pathAt(doc, `30`)).toBe(`$.a[0].b[2]`);
});
test("root scalar yields the root path", () => {
expect(pathAt(`"hello"`, `hello`)).toBe(`$`);
});
test("caret just before a key still resolves that key", () => {
// The character to the left is the object's whitespace, not the property.
const doc = `[\n {\n "userId": 1,\n "body": "x"\n }\n]`;
expect(pathBefore(doc, `"body"`)).toBe(`$[0].body`);
});
test("caret in a line's indentation resolves that line's member", () => {
// Caret in the blank indentation of the `title` line (structurally the
// object's whitespace) reads as being on `title`, not a neighbour.
const doc = `[\n {\n "userId": 1,\n "id": 1,\n "title": "x"\n }\n]`;
const pos = doc.indexOf(`"title"`) - 2; // two spaces into the title line
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0].title`);
});
test("caret at the end of an array element keeps the element index", () => {
// The caret sits after `}` where the left token is the object's close brace.
const doc = `[\n { "id": 1 },\n { "id": 2 }\n]`;
const pos = doc.indexOf(`},`) + 1;
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0]`);
});
test("non-JSON documents return null", () => {
const state = EditorState.create({ doc: `<xml/>` });
expect(jsonPathSegmentsAt(state, 3)).toBeNull();
});
});
describe("segmentsToJsonPath", () => {
test("empty segment list is the root", () => {
expect(segmentsToJsonPath([])).toBe(`$`);
});
test("partial path via count", () => {
const segs = [
{ kind: "key", key: "a" },
{ kind: "index", index: 2 },
{ kind: "key", key: "b" },
] as const;
expect(segmentsToJsonPath([...segs], 2)).toBe(`$.a[2]`);
});
});
describe("jsonPathToSegments", () => {
test("round-trips paths built by segmentsToJsonPath", () => {
for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) {
const segments = jsonPathToSegments(path);
expect(segments).not.toBeNull();
expect(segmentsToJsonPath(segments ?? [])).toBe(path);
}
});
test("returns null for paths that don't name a single location", () => {
for (const path of [
"$..id",
"$[*]",
"$.items[?(@.id)]",
"$[0:2]",
"items[0]",
"$.a b",
'$["\\q"]',
'$["unterminated]',
'$["line\nbreak"]',
"$[01]",
"$[9007199254740993]",
"$.$",
]) {
expect(jsonPathToSegments(path)).toBeNull();
}
});
});
@@ -0,0 +1,102 @@
import { syntaxTree } from "@codemirror/language";
import type { EditorState } from "@codemirror/state";
import type { SyntaxNode } from "@lezer/common";
import type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
export { jsonPathToSegments } from "@yaakapp-internal/lib/jsonPath";
export type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
// Lezer node names from the JSONC grammar (@shopify/lang-jsonc). A JSON value is
// exactly one of these; everything else in the tree is punctuation or a comment.
const VALUE_NODES = new Set(["Object", "Array", "String", "Number", "True", "False", "Null"]);
// The nodes with children that a caret can land "between" — its whitespace and
// punctuation belong to the container, not to any value inside it.
const CONTAINER_NODES = new Set(["Object", "Array", "JsoncText"]);
/** Read a `PropertyName` (a quoted JSON string) back to its raw key. */
function keyFromPropertyName(node: SyntaxNode, state: EditorState): string {
const raw = state.doc.sliceString(node.from, node.to);
try {
return JSON.parse(raw) as string;
} catch {
// A half-typed or malformed key still deserves a best-effort label.
return raw.replace(/^"|"$/g, "");
}
}
/** Index of `child` among the value elements of its parent `Array` node. */
function indexInArray(array: SyntaxNode, child: SyntaxNode): number {
let idx = 0;
for (let c = array.firstChild; c != null; c = c.nextSibling) {
if (c.from === child.from && c.to === child.to) return idx;
if (VALUE_NODES.has(c.name)) idx++;
}
return idx;
}
/**
* The path from the document root to the member on the caret's line, or `null`
* when the document has no JSON tree (a different language, or empty). An empty
* array means the caret sits at the root value itself.
*
* A caret exactly on a token uses that token. A caret in a line's whitespace
* resolves to the enclosing object or array, so it's re-anchored to the first
* non-whitespace character on that line — in pretty-printed JSON each member
* starts its own line, so that's the member's own token. This tracks "the line
* I'm on" without guessing between neighbouring keys.
*
* From the anchor, each ancestor contributes a segment: a `Property` its key, a
* value directly inside an `Array` its index. Collected deepest-first, reversed.
*/
export function jsonPathSegmentsAt(state: EditorState, pos: number): JsonPathSegment[] | null {
const tree = syntaxTree(state);
if (tree.topNode.name !== "JsoncText") return null;
let node: SyntaxNode = tree.resolveInner(pos, -1);
if (CONTAINER_NODES.has(node.name)) {
const line = state.doc.lineAt(pos);
const indent = line.text.length - line.text.trimStart().length;
if (indent < line.text.length) {
node = tree.resolveInner(line.from + indent, 1);
}
}
const segments: JsonPathSegment[] = [];
let cur: SyntaxNode | null = node;
while (cur != null) {
const parent: SyntaxNode | null = cur.parent;
if (cur.name === "Property") {
const nameNode = cur.getChild("PropertyName");
if (nameNode != null) {
segments.push({ kind: "key", key: keyFromPropertyName(nameNode, state) });
}
} else if (parent != null && parent.name === "Array" && VALUE_NODES.has(cur.name)) {
segments.push({ kind: "index", index: indexInArray(parent, cur) });
}
cur = parent;
}
segments.reverse();
return segments;
}
const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
/** A single segment as JSONPath, choosing dot vs. bracket form for a key. */
function segmentToJsonPath(segment: JsonPathSegment): string {
if (segment.kind === "index") return `[${segment.index}]`;
// jsonpath-plus interprets a bare `$` as its root operator.
if (BARE_KEY.test(segment.key) && segment.key !== "$") return `.${segment.key}`;
// Anything with a dot, space, quote, etc. must be a quoted bracket accessor.
// JSON.stringify gives correct double-quoting and escaping, matching the
// convention the JSONPath filter box already displays.
return `[${JSON.stringify(segment.key)}]`;
}
/**
* Build a JSONPath expression for the first `count` segments (all of them by
* default). `segmentsToJsonPath([])` is the root, `$`.
*/
export function segmentsToJsonPath(segments: JsonPathSegment[], count = segments.length): string {
return "$" + segments.slice(0, count).map(segmentToJsonPath).join("");
}
@@ -0,0 +1,155 @@
/* oxlint-disable no-template-curly-in-string */
import { CompletionContext } from "@codemirror/autocomplete";
import { json } from "@codemirror/lang-json";
import { EditorState, type Extension, type TransactionSpec } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { describe, expect, test } from "vite-plus/test";
import { singleLineExtensions } from "../singleLine";
import { twigCompletion, type TwigCompletionOption } from "./completion";
import { parser } from "./twig";
const options: TwigCompletionOption[] = [
{ name: "someVar", label: "someVar", type: "variable", value: "value", onClick: () => {} },
{ name: "uuid.v4", label: "uuid.v4", type: "function", args: [], value: null, onClick: () => {} },
];
const source = twigCompletion({ options });
// Both keyboard acceptance and clicking a suggestion invoke Completion.apply.
function complete(input: string, label: string, extensions: Extension = []) {
const cursor = input.indexOf("|");
if (cursor < 0) throw new Error("Missing cursor marker");
let state = EditorState.create({
doc: input.slice(0, cursor) + input.slice(cursor + 1),
selection: { anchor: cursor },
extensions,
});
const result = source(new CompletionContext(state, cursor, false));
const completion = result?.options.find((option) => option.label === label);
expect(completion).toBeDefined();
const view = {
get state() {
return state;
},
dispatch(spec: TransactionSpec) {
state = state.update(spec).state;
},
} as EditorView;
if (typeof completion?.apply !== "function" || !result) throw new Error("Missing apply");
completion.apply(view, completion, result.from, cursor);
return state;
}
function expectValidTag(state: EditorState, expected: string) {
expect(state.doc.toString()).toBe(expected);
const tags: string[] = [];
parser.parse(expected).iterate({
enter(node) {
expect(node.type.isError).toBe(false);
if (node.name === "Tag") tags.push(expected.slice(node.from, node.to));
},
});
expect(tags).toHaveLength(1);
}
describe("twig completion", () => {
test.each(["${[\nsome|", "${[ \n\n\tsome|", "${[\nsome|\n ]}", "${[\n |"])(
"completes across multiline whitespace: %s",
(input) => expectValidTag(complete(input, "someVar"), "${[ someVar ]}"),
);
test.each(["${[\nuuid.v|", "${[\n uuid.v|()\n ]}"])(
"completes a function across multiline whitespace: %s",
(input) => expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}"),
);
test("preserves surrounding lines and literal pipes", () => {
expect(complete("before\n${[\nsome|\n]}\na | b", "someVar").doc.toString()).toBe(
"before\n${[ someVar ]}\na | b",
);
});
test("does not search past non-whitespace for an opener or closer", () => {
expect(complete("prefix\nsome|\nother ]}", "someVar").doc.toString()).toBe(
"prefix\n${[ someVar ]}\nother ]}",
);
expect(complete("${[some|\nother ]}", "someVar").doc.toString()).toBe(
"${[ someVar ]}\nother ]}",
);
});
test.each(["${[|", "${[ |", "${[ |"])(
"offers completion immediately after a template opener: %s",
(input) => {
expectValidTag(complete(input, "someVar"), "${[ someVar ]}");
},
);
test.each(["$", "{", "${", '{"value": "'])(
"does not automatically offer empty-name completions after %s",
(doc) => {
const state = EditorState.create({ doc });
expect(source(new CompletionContext(state, doc.length, false))).toBeNull();
},
);
test.each(["uuid|", "${[uuid|", "${[ uuid| ]}"])(
"completes a namespace before wrapping the final function: %s",
(input) => {
const namespace = complete(input, "uuid.*");
expect(namespace.doc.toString()).toBe(input.replace("uuid|", "uuid."));
const cursor = namespace.selection.main.head;
const next = namespace.sliceDoc(0, cursor) + "|" + namespace.sliceDoc(cursor);
expectValidTag(complete(next, "uuid.v4"), "${[ uuid.v4() ]}");
},
);
test.each(["some|", "${[some|", "${[ some|", "${[ someVar|", "${[\tsome|"])(
"completes a variable in a single-line value: %s",
(input) => {
const state = complete(input, "someVar", singleLineExtensions());
expectValidTag(state, "${[ someVar ]}");
expect(state.selection.main.head).toBe(state.doc.length);
},
);
test.each(["${[some|]}", "${[ some| ]}", "${[someVar|]}"])(
"reuses closing delimiters: %s",
(input) => {
expectValidTag(complete(input, "someVar"), "${[ someVar ]}");
},
);
test.each(["uuid.v|", "${[uuid.v|", "${[ uuid.v| ]}", "${[uuid.v|()]}"])(
"completes a function: %s",
(input) => {
expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}");
},
);
test("preserves surrounding JSON and its closing punctuation", () => {
expectValidTag(
complete('{"value":"${[some|","other":1}', "someVar", json()),
'{"value":"${[ someVar ]}","other":1}',
);
});
test("preserves surrounding text and other complete tags", () => {
expect(complete("${[ someVar ]} ${[some|]} trailing", "someVar").doc.toString()).toBe(
"${[ someVar ]} ${[ someVar ]} trailing",
);
});
test("does not consume closing punctuation without a template opener", () => {
expect(complete("[some|]}", "someVar").doc.toString()).toBe("[${[ someVar ]}]}");
});
test("invalidates cached suggestions when typing delimiters or a namespace separator", () => {
const state = EditorState.create({ doc: "some" });
const result = source(new CompletionContext(state, 4, false));
expect(result?.validFor).toBeInstanceOf(RegExp);
const validFor = result?.validFor as RegExp;
expect(validFor.test("someVar")).toBe(true);
expect(validFor.test("some ${[some")).toBe(false);
expect(validFor.test("uuid.")).toBe(false);
});
});
@@ -1,5 +1,6 @@
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import { startCompletion } from "@codemirror/autocomplete";
import type { EditorState } from "@codemirror/state";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
const openTag = "${[ ";
@@ -36,6 +37,22 @@ export interface TwigCompletionConfig {
const MIN_MATCH_NAME = 1;
// Only inspect adjacent whitespace, including newlines, rather than copying the
// entire document to look for a delimiter in large request bodies.
function skipWhitespace(state: EditorState, pos: number, direction: -1 | 1) {
while (direction === -1 ? pos > 0 : pos < state.doc.length) {
const from = direction === -1 ? pos - 1 : pos;
if (!/\s/.test(state.sliceDoc(from, from + 1))) break;
pos += direction;
}
return pos;
}
function findOpenTag(state: EditorState, from: number) {
const end = skipWhitespace(state, from, -1);
return end >= 3 && state.sliceDoc(end - 3, end) === "${[" ? end - 3 : null;
}
export function twigCompletion({ options }: TwigCompletionConfig) {
return function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/[\w_.]*/);
@@ -44,7 +61,8 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
if (toMatch === null) return null;
const matchLen = toMatch.to - toMatch.from;
if (!context.explicit && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
const hasOpenTag = findOpenTag(context.state, toMatch.from) !== null;
if (!context.explicit && !hasOpenTag && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
return null;
}
@@ -83,6 +101,16 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
detail: o.type,
type: o.type === "variable" ? "variable" : "function",
apply: (view, _completion, from, to) => {
// Keep the completion range name-only for filtering, but replace a manually
// typed opener when accepting (through either the keyboard or the menu).
const opener = findOpenTag(view.state, from);
if (opener !== null) {
from = opener;
const hasParentheses =
o.type === "function" && view.state.sliceDoc(to, to + 2) === "()";
const closer = skipWhitespace(view.state, to + (hasParentheses ? 2 : 0), 1);
if (view.state.sliceDoc(closer, closer + 2) === "]}") to = closer + 2;
}
const insert = openTag + inner + closeTag;
view.dispatch({
changes: { from, to, insert: insert },
@@ -103,7 +131,8 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
return {
matchLen,
validFor: () => true, // Not really sure why this is all it needs
// Delimiters change the replacement range; dots change namespace options.
validFor: /^\w*$/,
from: toMatch.from,
options: sortedCompletions,
};
@@ -0,0 +1,123 @@
import { Icon, type IconProps } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { forwardRef, type ReactNode } from "react";
import { Dropdown, type DropdownItem } from "./Dropdown";
import { IconButton } from "./IconButton";
export interface PathListItem {
path: string;
icon: IconProps["icon"];
/** Replaces the parent-path line under the name */
subtitle?: ReactNode;
failed?: boolean;
}
interface Props {
items: PathListItem[];
disabled?: boolean;
onRemove: (path: string) => void;
addItems: DropdownItem[];
addLabel: string;
addHint?: string;
hovering?: boolean;
/** Rows rendered between the items and the add row, such as an inline input */
children?: ReactNode;
}
/** Selected files, folders, or URLs as cards, with a dashed add row that opens a menu. */
export const PathList = forwardRef<HTMLDivElement, Props>(function PathList(
{ items, disabled, onRemove, addItems, addLabel, addHint, hovering, children },
ref,
) {
return (
<div
ref={ref}
className={classNames(
"flex flex-col gap-1.5 rounded-lg -m-1.5 p-1.5",
hovering && "bg-surface-highlight",
)}
>
{items.map((item) => {
const { name, parent } = splitPath(item.path);
return (
<div
key={item.path}
className={classNames(
"flex items-center gap-3 rounded-md pl-3 pr-1.5 py-2",
item.failed ? "bg-danger/10" : "bg-surface-highlight/50",
)}
>
<Icon
icon={item.icon}
className={classNames("shrink-0", item.failed ? "text-danger" : "text-text-subtle")}
/>
<div className="min-w-0 flex-1" title={item.path}>
<div className="truncate text-sm font-semibold">{name}</div>
<div
className={classNames(
"flex items-center gap-1.5 truncate text-xs",
item.failed ? "text-danger" : "text-text-subtlest",
)}
>
{item.subtitle ??
(parent !== "" && <span className="truncate font-mono">{parent}</span>)}
</div>
</div>
<IconButton
size="xs"
icon="x"
title={`Remove ${name}`}
disabled={disabled}
className="shrink-0"
onClick={() => onRemove(item.path)}
/>
</div>
);
})}
{children}
<Dropdown items={addItems}>
<button
type="button"
disabled={disabled}
className={classNames(
"h-sm w-full flex items-center gap-2 rounded-md border border-dashed px-3 text-sm text-text-subtle",
"hocus:border-border-focus hocus:text-text disabled:opacity-disabled",
hovering ? "border-notice" : "border-border",
)}
>
<Icon icon="plus" size="sm" className="text-text-subtlest" />
{hovering ? (
"Drop to add"
) : (
<>
<span>{addLabel}</span>
{addHint != null && (
<span className="ml-auto text-xs text-text-subtlest">{addHint}</span>
)}
</>
)}
</button>
</Dropdown>
</div>
);
});
/** The last segment as the name and everything before it, shortened, as the parent. */
export function splitPath(path: string): { name: string; parent: string } {
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) {
try {
const url = new URL(path);
const segments = url.pathname.split("/").filter(Boolean);
const name = segments.at(-1) ?? url.host;
const parent = segments.length > 0 ? `${url.host}/${segments.slice(0, -1).join("/")}` : "";
return { name, parent: parent.replace(/\/$/, "") };
} catch {
return { name: path, parent: "" };
}
}
const parts = path.split(/[/\\]/).filter(Boolean);
const name = parts.at(-1) ?? path;
const dirs = parts.slice(0, -1);
const parent = dirs.length > 3 ? `…/${dirs.slice(-3).join("/")}` : dirs.join("/");
return { name, parent };
}
+9 -14
View File
@@ -1,10 +1,9 @@
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import type { FormEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { generateId } from "../../lib/generateId";
import { DynamicForm } from "../DynamicForm";
import { Button } from "./Button";
import { DialogFooter } from "./Dialog";
export interface PromptProps {
inputs: FormInput[];
@@ -48,19 +47,15 @@ export function Prompt({
const id = `prompt.form.${useRef(generateId()).current}`;
return (
<form
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
onSubmit={handleSubmit}
>
<form id={id} className="grid grid-cols-[minmax(0,1fr)] mb-2" onSubmit={handleSubmit}>
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
{cancelText || "Cancel"}
</Button>
<Button type="submit" color="primary">
{confirmText || "Done"}
</Button>
</HStack>
<DialogFooter
inline
actions={[
{ label: cancelText || "Cancel", onClick: onCancel },
{ label: confirmText || "Done", color: "primary", form: id },
]}
/>
</form>
);
}
@@ -1,43 +0,0 @@
import { HStack, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { Button } from "../core/Button";
import { Select } from "../core/Select";
interface Props {
branches: string[];
onCancel: () => void;
onSelect: (branch: string) => void;
selectText: string;
}
export function BranchSelectionDialog({ branches, onCancel, onSelect, selectText }: Props) {
const [branch, setBranch] = useState<string>("__NONE__");
return (
<VStack
className="mb-4"
as="form"
space={4}
onSubmit={(e) => {
e.preventDefault();
onSelect(branch);
}}
>
<Select
name="branch"
hideLabel
label="Branch"
value={branch}
options={branches.map((b) => ({ label: b, value: b }))}
onChange={setBranch}
/>
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
Cancel
</Button>
<Button type="submit" color="primary">
{selectText}
</Button>
</HStack>
</VStack>
);
}
@@ -1,4 +1,4 @@
import type { GitStatusEntry } from "@yaakapp-internal/git";
import type { GitStatus, GitStatusEntry } from "@yaakapp-internal/git";
import { useGit } from "@yaakapp-internal/git";
import type {
Environment,
@@ -9,8 +9,7 @@ import type {
Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useCallback, useMemo, useState } from "react";
import { type ComponentProps, useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml";
import { trackFeatureUsage } from "../../lib/featureFeedback";
import { resolvedModelName } from "../../lib/resolvedModelName";
@@ -19,6 +18,7 @@ import { showErrorToast } from "../../lib/toast";
import { sync } from "../../init/sync";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button";
import { Chip } from "../core/Chip";
import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox";
import type { CheckboxTreeNode } from "../core/CheckboxTree";
@@ -333,16 +333,7 @@ function CommitTreeRow({ node }: { node: CommitTreeNode }) {
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
>
{node.status.status}
</InlineCode>
<Chip color={statusColor(node.status.status)}>{node.status.status}</Chip>
)}
</>
);
@@ -376,16 +367,9 @@ function ExternalTreeNode({
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
<Icon color="secondary" icon="file_code" />
<div className="truncate">{displayPath}</div>
<InlineCode
className={classNames(
"py-0 ml-auto bg-transparent w-24 text-center",
entry.status === "modified" && "text-info",
entry.status === "untracked" && "text-success",
entry.status === "removed" && "text-danger",
)}
>
<Chip className="ml-auto" color={statusColor(entry.status)}>
{entry.status}
</InlineCode>
</Chip>
</div>
}
/>
@@ -479,3 +463,20 @@ function DiffPanel({
</div>
);
}
function statusColor(status: GitStatus): ComponentProps<typeof Chip>["color"] {
switch (status) {
case "modified":
return "info";
case "untracked":
return "success";
case "removed":
return "danger";
case "conflict":
return "warning";
case "current":
case "renamed":
case "type_change":
return "default";
}
}
+15 -15
View File
@@ -1,8 +1,8 @@
import type { DivergedStrategy } from "@yaakapp-internal/git";
import { HStack, InlineCode } from "@yaakapp-internal/ui";
import { InlineCode } from "@yaakapp-internal/ui";
import { useState } from "react";
import { showDialog } from "../../lib/dialog";
import { Button } from "../core/Button";
import { DialogFooter } from "../core/Dialog";
import { RadioCards } from "../core/RadioCards";
type Resolution = "force_reset" | "merge";
@@ -34,7 +34,7 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
};
return (
<div className="flex flex-col gap-4 mb-4">
<div className="flex flex-col gap-4">
<p className="text-text-subtle">
Your local branch has diverged from{" "}
<InlineCode>
@@ -59,18 +59,18 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
},
]}
/>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
color={selected === "force_reset" ? "danger" : "primary"}
disabled={selected == null}
onClick={handleSubmit}
>
{selected != null ? resolutionLabel[selected] : "Select an option"}
</Button>
<Button variant="border" onClick={handleCancel}>
Cancel
</Button>
</HStack>
<DialogFooter
inline
actions={[
{ label: "Cancel", onClick: handleCancel },
{
label: selected != null ? resolutionLabel[selected] : "Select an option",
color: selected === "force_reset" ? "danger" : "primary",
disabled: selected == null,
onClick: handleSubmit,
},
]}
/>
</div>
);
}
@@ -3,18 +3,15 @@ import "react-pdf/dist/Page/AnnotationLayer.css";
import "./PdfViewer.css";
import type { PDFDocumentProxy } from "pdfjs-dist";
import { useMemo, useRef, useState } from "react";
import { Document, Page } from "react-pdf";
import { Document, Page, pdfjs } from "react-pdf";
import { useContainerSize } from "@yaakapp-internal/ui";
import { fireAndForget } from "../../lib/fireAndForget";
fireAndForget(
import("react-pdf").then(({ pdfjs }) => {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
}),
);
// Document can start loading during render, so configure its worker synchronously.
// Vite bundles the installed PDF.js worker as a local asset, including offline builds.
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
interface Props {
/** A URL for the body the host already stored. */
@@ -59,6 +56,8 @@ export function PdfViewer({ bodyUrl, data }: Props) {
return (
<div ref={containerRef} className="w-full h-full overflow-y-auto">
<Document
// Keep the copied data stable: suspending the initial mount discards useMemo.
suspense={false}
file={src}
options={options}
onLoadSuccess={onDocumentLoadSuccess}
@@ -0,0 +1,192 @@
import { Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { JsonPathSegment } from "../core/Editor/json/jsonPath";
import { IconButton } from "../core/IconButton";
import { Tooltip } from "../core/Tooltip";
interface Props {
segments: JsonPathSegment[];
appliedFilter: string | null;
/**
* How many leading segments make up the applied filter. `null` when unfiltered, or
* when the filter isn't a plain path and is shown as raw text instead.
*/
appliedDepth: number | null;
filterError: boolean;
/** Called with the number of leading segments to filter to. `0` clears the filter. */
onSelect: (count: number) => void;
}
/**
* Shows where the cursor sits inside a response (`$ > sort > [0] > id`). An applied
* plain-path filter is the start of the trail, so any crumb moves it. While filtered,
* the bar is tinted so a lingering filter is hard to miss.
*/
export function ResponseBreadcrumbBar({
segments,
appliedFilter,
appliedDepth,
filterError,
onSelect,
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const [overflow, setOverflow] = useState({ left: false, right: false });
const measure = useCallback(() => {
const el = scrollRef.current;
if (el == null) return;
const maxScroll = el.scrollWidth - el.clientWidth;
setOverflow({
left: el.scrollLeft > 1,
right: el.scrollLeft < maxScroll - 1,
});
}, []);
// Keep the deepest crumb (where the cursor is) in view as the path changes.
useLayoutEffect(() => {
const el = scrollRef.current;
if (el == null) return;
el.scrollLeft = el.scrollWidth;
measure();
}, [segments, measure]);
useEffect(() => {
const el = scrollRef.current;
if (el == null) return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [measure]);
const scrollBy = useCallback((direction: -1 | 1) => {
const el = scrollRef.current;
if (el == null) return;
el.scrollBy({ left: direction * Math.max(120, el.clientWidth * 0.6), behavior: "smooth" });
}, []);
const isFiltered = appliedFilter != null;
return (
<div className="rounded bg-surface shadow pointer-events-auto">
<div
className={classNames(
"flex items-stretch h-6 rounded border overflow-hidden font-mono text-xs text-text-subtle select-none",
!isFiltered && "border-border-subtle",
isFiltered && !filterError && "border-primary/40 bg-primary/10",
filterError && "border-danger/40 bg-danger/10",
)}
>
{isFiltered && appliedDepth == null ? (
<span
className={classNames(
"flex items-center min-w-0 truncate px-2",
filterError ? "text-danger" : "text-primary",
)}
>
{appliedFilter}
</span>
) : (
<Crumb
label="$"
tooltip="Clear filter"
isApplied={isFiltered}
onClick={appliedDepth != null && appliedDepth > 0 ? () => onSelect(0) : null}
/>
)}
{overflow.left && (
<IconButton
size="xs"
icon="chevron_left"
title="Scroll breadcrumbs left"
iconColor="secondary"
onClick={() => scrollBy(-1)}
className="shrink-0 h-auto!"
/>
)}
<div
ref={scrollRef}
onScroll={measure}
className="flex items-stretch min-w-0 overflow-x-auto hide-scrollbars whitespace-nowrap"
>
{segments.map((segment, i) => {
const count = i + 1;
const isApplied = appliedDepth != null && count <= appliedDepth;
const isClickable = (!isFiltered || appliedDepth != null) && count !== appliedDepth;
return (
<div key={i} className="flex items-stretch shrink-0">
<Icon
icon="chevron_right"
size="xs"
className="text-text-subtlest shrink-0 self-center"
/>
<Crumb
label={segment.kind === "index" ? `[${segment.index}]` : segment.key}
tooltip={
segment.kind === "index"
? `Filter to element ${segment.index}`
: `Filter to ${segment.key}`
}
isApplied={isApplied}
onClick={isClickable ? () => onSelect(count) : null}
/>
</div>
);
})}
</div>
{overflow.right && (
<IconButton
size="xs"
icon="chevron_right"
title="Scroll breadcrumbs right"
iconColor="secondary"
onClick={() => scrollBy(1)}
className="shrink-0 h-auto!"
/>
)}
</div>
</div>
);
}
function Crumb({
label,
tooltip,
isApplied,
onClick,
}: {
label: string;
tooltip: string;
isApplied: boolean;
onClick: (() => void) | null;
}) {
const outerClassName = classNames("flex items-center px-0.5 py-0.5", isApplied && "text-primary");
const innerClassName = "flex items-center h-full px-1 rounded-sm";
if (onClick == null) {
return (
<span className={classNames(outerClassName, "shrink-0")}>
<span className={innerClassName}>{label}</span>
</span>
);
}
return (
<Tooltip content={tooltip} className="shrink-0 items-stretch!">
<button
type="button"
onClick={onClick}
className={classNames(outerClassName, "group/crumb focus-visible:outline-none")}
>
<span
className={classNames(
innerClassName,
"transition-colors group-hover/crumb:text-text group-hover/crumb:bg-surface-highlight",
"group-focus-visible/crumb:ring-1 group-focus-visible/crumb:ring-border-focus",
)}
>
{label}
</span>
</button>
</Tooltip>
);
}
@@ -1,18 +1,18 @@
import type { ReactNode } from "react";
import { Children, useCallback, useMemo } from "react";
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { Children, useCallback, useMemo, useState } from "react";
import { useFormatText } from "../../hooks/useFormatText";
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
import { Button } from "../core/Button";
import type { EditorProps } from "../core/Editor/Editor";
import { jsonBreadcrumbExtension } from "../core/Editor/json/breadcrumbExtension";
import type { JsonPathSegment } from "../core/Editor/json/jsonPath";
import { jsonPathToSegments, segmentsToJsonPath } from "../core/Editor/json/jsonPath";
import { hyperlink } from "../core/Editor/hyperlink/extension";
import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import { Input } from "../core/Input";
import { ResponseBreadcrumbBar } from "./ResponseBreadcrumbBar";
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
const extraExtensions = [hyperlink];
interface Props {
text: string;
language: EditorProps["language"];
@@ -38,12 +38,43 @@ export function TextViewer({
filter,
filterResult,
}: Props) {
// Track the JSON path under the cursor to drive the breadcrumb bar. Selection
// works even in this read-only editor, so it updates as the user clicks around.
const [breadcrumbSegments, setBreadcrumbSegments] = useState<JsonPathSegment[]>([]);
const handleBreadcrumbUpdate = useCallback(
({ segments }: { segments: JsonPathSegment[] | null }) =>
setBreadcrumbSegments((prev) => {
const next = segments ?? [];
return segmentsToJsonPath(prev) === segmentsToJsonPath(next) ? prev : next;
}),
[],
);
const extraExtensions = useMemo(
() =>
language === "json"
? [hyperlink, jsonBreadcrumbExtension(handleBreadcrumbUpdate)]
: [hyperlink],
[language, handleBreadcrumbUpdate],
);
const canFilter =
filter != null && (language === "json" || language === "xml" || language === "html");
const isSearching = filter?.isSearching ?? false;
const appliedFilter = filter?.appliedFilter ?? null;
const resultError = filterResult?.error ?? false;
// Filter output is always an array of matches, so under a plain-path filter the
// cursor's leading index is that single match and the rest extends the filter.
const appliedSegments =
appliedFilter != null && language === "json" ? jsonPathToSegments(appliedFilter) : null;
const pathSegments =
appliedSegments != null
? [...appliedSegments, ...breadcrumbSegments.slice(1)]
: breadcrumbSegments;
const showBreadcrumbs =
filter != null &&
(appliedFilter != null || (language === "json" && breadcrumbSegments.length > 0));
const handleFilterKeyDown = useCallback(
(e: KeyboardEvent) => {
if (filter == null) return;
@@ -155,16 +186,7 @@ export function TextViewer({
}
return (
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
{appliedFilter && filter != null ? (
<AppliedFilterBar
filter={appliedFilter}
error={resultError}
onClear={() => filter.replaceFilter("")}
/>
) : (
<span />
)}
<div className="relative h-full w-full">
<Editor
readOnly
className={className}
@@ -174,45 +196,23 @@ export function TextViewer({
extraExtensions={extraExtensions}
stateKey={stateKey}
/>
{showBreadcrumbs && (
<div className="absolute top-0 right-3 max-w-[70%] pointer-events-none">
<ResponseBreadcrumbBar
segments={language === "json" ? pathSegments : []}
appliedFilter={appliedFilter}
appliedDepth={appliedSegments?.length ?? null}
filterError={resultError}
onSelect={(count) =>
filter.replaceFilter(count === 0 ? "" : segmentsToJsonPath(pathSegments, count))
}
/>
</div>
)}
</div>
);
}
/**
* Shows what's actually filtering the body, which the filter box below can't convey
* once it holds an edited expression that hasn't been applied yet.
*/
function AppliedFilterBar({
filter,
error,
onClear,
}: {
filter: string;
error: boolean;
onClear: () => void;
}) {
return (
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
<HStack space={2} className="min-w-0">
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
<span className="truncate min-w-0" title={filter}>
Response filtered by <InlineCode>{filter}</InlineCode>
{error && " (invalid expression)"}
</span>
<Button
size="2xs"
variant="border"
color={error ? "danger" : "info"}
className="ml-auto shrink-0"
onClick={onClear}
>
Clear
</Button>
</HStack>
</Banner>
);
}
/** Convert \uXXXX to actual Unicode characters */
function decodeUnicodeLiterals(text: string): string {
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
-1
View File
@@ -23,7 +23,6 @@ export function useExportData() {
id: "export-data",
title: "Export Data",
size: "md",
noPadding: true,
render: ({ hide }) => (
<ExportDataDialog
onHide={hide}
+3
View File
@@ -0,0 +1,3 @@
export function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+1 -4
View File
@@ -1,4 +1,5 @@
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { errorMessage } from "./errorMessage";
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
@@ -45,7 +46,3 @@ export function tryBuildIntrospectionFromFile(
};
}
}
function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
+15 -9
View File
@@ -8,7 +8,7 @@ import {
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import type { ImportSourcePath } from "../components/ImportSourceList";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert";
@@ -20,10 +20,17 @@ import { rpc } from "./rpc";
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
// fetches) every time the dialog container re-renders.
const planFile = (filePath: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
const planUrl = (url: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_url", { url, destination });
const planSources = (sources: ImportSourcePath[], destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", {
filePaths: sources.filter((source) => source.kind !== "url").map((source) => source.path),
urls: sources.filter((source) => source.kind === "url").map((source) => source.path),
destination,
});
const detectSource = (source: ImportSourcePath) =>
rpc<string>(
"cmd_detect_import_source",
source.kind === "url" ? { url: source.path } : { filePath: source.path },
);
const listSources = (workspaceId: string) =>
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
@@ -43,11 +50,11 @@ export const importData = createFastMutation({
return new Promise<void>((resolve, reject) => {
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
const workspaces = jotaiStore.get(workspacesAtom);
const selectedFolder = jotaiStore.get(activeFolderAtom);
showDialog({
id: "import",
title: "Import Data",
size: "lg",
className: "h-[36rem]",
disableClose: true,
render: ({ hide }) => {
const cancel = () => {
@@ -68,9 +75,8 @@ export const importData = createFastMutation({
<ImportDataDialog
currentWorkspace={currentWorkspace}
workspaces={workspaces}
selectedFolder={selectedFolder}
planFile={planFile}
planUrl={planUrl}
planSources={planSources}
detectSource={detectSource}
listSources={listSources}
findSourcesForOrigin={findSourcesForOrigin}
commit={commit}
+3 -3
View File
@@ -54,9 +54,9 @@
"jotai-family": "^1.0.1",
"js-md5": "^0.9.2",
"jsonc-parser": "^3.3.1",
"lucide-react": "^0.525.0",
"lucide-react": "^1.45.0",
"mime": "^4.0.4",
"motion": "^12.4.7",
"motion": "^13.2.0",
"nanoid": "^6.0.1",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
@@ -64,7 +64,7 @@
"react-colorful": "^5.6.1",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-pdf": "^10.0.1",
"react-pdf": "^11.0.0",
"react-syntax-highlighter": "^16.1.0",
"react-use": "^17.6.1",
"rehype-stringify": "^10.0.1",
+1 -1
View File
@@ -19,7 +19,7 @@
"classnames": "^2.5.1",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"motion": "^12.4.7",
"motion": "^13.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
+4 -4
View File
@@ -4,7 +4,7 @@ description: >
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
an OpenAPI spec, a Postman, Insomnia, or Bruno 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:*)
@@ -98,9 +98,9 @@ re-run it after acting.
## Core workflows
**Start from a spec when one exists.** `yaak import <file>` auto-detects OpenAPI,
Swagger, Postman, Insomnia, cURL, and Yaak exports, and beats authoring requests
by hand every time.
**Start from a spec when one exists.** `yaak import <path>` auto-detects OpenAPI,
Swagger, Postman, Insomnia, Bruno, cURL, and Yaak exports, and beats authoring requests
by hand every time. Bruno collections can also be imported from a ZIP or directory.
**Make the host swappable.** Put the base URL in a base-environment variable,
reference it as `${[ base_url ]}`, then add a sub-environment per deployment
+2 -2
View File
@@ -45,7 +45,7 @@ pub enum Commands {
/// Authentication commands
Auth(AuthArgs),
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Swagger, or cURL
/// Import API data from Yaak, OpenAPI, Postman, Insomnia, Bruno, Swagger, or cURL
Import(ImportArgs),
/// Export Yaak workspace data
@@ -202,7 +202,7 @@ pub struct SendArgs {
#[derive(Args)]
pub struct ImportArgs {
/// Path to the file to import
/// Path to the file, ZIP archive, or directory to import
pub file: PathBuf,
/// Existing workspace ID to import into when supported by the importer
@@ -2,13 +2,12 @@ use crate::cli::{ExportArgs, ImportArgs};
use crate::context::CliContext;
use crate::utils::workspace::resolve_workspace_id;
use std::fs;
use std::io::ErrorKind;
use yaak::export::{self, ExportDataParams};
use yaak::import;
use yaak_models::export::{self, ExportDataParams};
use yaak_models::util::{
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
};
use yaak_plugins::events::{ImportResources, PluginContext};
use yaak_plugins::events::{ImportRequest, ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
@@ -51,11 +50,12 @@ async fn import(
.map_err(|e| format!("Failed to get workspace '{workspace_id}': {e}"))?;
}
let file_contents = read_import_file(&args.file)?;
let input = ImportRequest::from_path(&args.file)
.map_err(|err| format!("Unable to read import source {}: {err}", args.file.display()))?;
let plugin_context = PluginContext::new(None, args.workspace_id.clone());
let plugin_manager = ctx.plugin_manager();
let import_result = plugin_manager
.import_data(&plugin_context, &file_contents)
.import_input(&plugin_context, &input)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let importer = import_result.importer;
@@ -127,6 +127,46 @@ fn format_skipped(items: &[ImportPlanItem]) -> Option<String> {
if parts.is_empty() { None } else { Some(parts.join(", ")) }
}
/// Write a file so that afterwards it is either the old one or the whole new one.
///
/// Writing in place would truncate the previous export before the new one existed, so an
/// interrupted run loses both. Instead the bytes go to a neighbouring file, are flushed, and
/// take the target's name in one step. The directory is flushed too: `sync_all` on a file
/// promises its contents, not that the name it just gained will survive a power cut.
fn write_durably(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write;
let dir = match path.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => std::path::Path::new("."),
};
// Alongside the target, because a rename is only atomic within one filesystem.
let mut name = path.file_name().unwrap_or_default().to_os_string();
name.push(format!(".tmp{}", std::process::id()));
let tmp = dir.join(name);
let write = |tmp: &std::path::Path| -> std::io::Result<()> {
let mut file = std::fs::File::create(tmp)?;
file.write_all(bytes)?;
file.sync_all()
};
if let Err(e) = write(&tmp) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
if let Err(e) = std::fs::rename(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
return Err(e);
}
// Only Unix lets a directory be opened for this. Elsewhere the rename is as much as
// the platform offers, and the file itself is already flushed.
#[cfg(unix)]
std::fs::File::open(dir)?.sync_all()?;
Ok(())
}
fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
let workspace_ids = resolve_export_workspace_ids(ctx, args.workspace_ids, args.all)?;
let workspace_id_refs: Vec<&str> = workspace_ids.iter().map(String::as_str).collect();
@@ -137,7 +177,10 @@ fn export(ctx: &CliContext, args: ExportArgs) -> CommandResult<usize> {
include_private_environments: args.include_private_environments,
})
.map_err(|e| format!("Failed to export data: {e}"))?;
std::fs::write(&args.file, document)
// Flushed before reporting success: an export is a backup, and a backup that the
// command called done while it was still only in the page cache is the one case
// where saying nothing went wrong is worst.
write_durably(&args.file, document.as_bytes())
.map_err(|e| format!("Failed to write {}: {e}", args.file.display()))?;
Ok(workspace_ids.len())
@@ -169,19 +212,6 @@ fn resolve_export_workspace_ids(
Ok(workspace_ids)
}
fn read_import_file(path: &std::path::Path) -> CommandResult<String> {
fs::read_to_string(path).map_err(|err| {
if err.kind() == ErrorKind::InvalidData {
format!(
"Import file must be UTF-8 text; binary files are not supported: {}",
path.display()
)
} else {
format!("Unable to read import file {}: {err}", path.display())
}
})
}
fn resources_need_current_workspace(resources: &ImportResources) -> bool {
resources.workspaces.iter().any(|w| w.id == "CURRENT_WORKSPACE")
|| resources.environments.iter().any(|e| {
+2
View File
@@ -1,5 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type NavigateWorkspace = { workspaceId: string; environmentId: string | null };
export type PluginUpdateInfo = { name: string; currentVersion: string; latestVersion: string };
export type PluginUpdateNotification = { updateCount: number; plugins: Array<PluginUpdateInfo> };
+91 -33
View File
@@ -1,46 +1,89 @@
use crate::PluginContextExt;
use crate::error::{Error, Result};
use crate::models_ext::QueryManagerExt;
use std::fs::read_to_string;
use std::io::ErrorKind;
use std::path::Path;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, PlanImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlan};
use yaak_plugins::MAX_IMPORT_BYTES;
use yaak_plugins::events::ImportRequest;
pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
origin: Option<ImportOrigin>,
) -> Result<BatchUpsertResult> {
let contents = read_import_file(file_path)?;
let input = read_import_file(file_path)?;
let plan =
plan_import_contents(window, &contents, ImportDestination::NewWorkspace, origin).await?;
plan_import_contents(window, &input, ImportDestination::NewWorkspace, origin).await?;
commit_import(window, plan)
}
pub(crate) async fn plan_import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
file_paths: &[String],
urls: &[String],
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = read_import_file(file_path)?;
plan_import_contents(window, &contents, destination, Some(file_origin(file_path))).await
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
let plugin_context = window.plugin_context();
let mut seen = std::collections::BTreeSet::new();
let mut inputs = Vec::new();
for path in file_paths {
let origin = file_origin(path);
if !seen.insert(origin.origin.clone()) {
continue;
}
let input = read_import_file(path)?;
let response = plugin_manager
.import_input(&plugin_context, &input)
.await
.map_err(|err| Error::GenericError(format!("Unable to import {path}: {err}")))?;
inputs.push((response, origin));
}
for url in urls {
let url = normalize_import_url(url)?;
if !seen.insert(url.clone()) {
continue;
}
let input = fetch_import_url(window, &url).await?;
let response = plugin_manager
.import_input(&plugin_context, &input)
.await
.map_err(|err| Error::GenericError(format!("Unable to import {url}: {err}")))?;
inputs.push((response, url_origin(&url)));
}
Ok(import::plan_import_batch_resources(&window.db_manager(), destination, inputs)?)
}
pub(crate) async fn plan_import_url<R: Runtime>(
/// Which importer claims a source, without planning it. Backs the per-source status in the
/// import dialog so unsupported files are flagged before preview.
pub(crate) async fn detect_import_source<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let url = normalize_import_url(url)?;
let contents = fetch_import_url(window, &url).await?;
plan_import_contents(window, &contents, destination, Some(url_origin(&url))).await
file_path: Option<String>,
url: Option<String>,
) -> Result<String> {
let input = match (file_path, url) {
(Some(path), _) => read_import_file(&path)?,
(None, Some(url)) => fetch_import_url(window, &normalize_import_url(&url)?).await?,
(None, None) => return Err(Error::GenericError("Nothing to detect".into())),
};
let plugin_manager = crate::plugins_ext::plugin_manager(window).await?;
let response = plugin_manager.import_input(&window.plugin_context(), &input).await.map_err(
|err| match err {
yaak_plugins::error::Error::PluginErr(msg) if msg.starts_with("No importers found") => {
Error::GenericError("Not a supported format".into())
}
err => Error::GenericError(err.to_string()),
},
)?;
Ok(response.importer)
}
async fn plan_import_contents<R: Runtime>(
window: &WebviewWindow<R>,
contents: &str,
input: &ImportRequest,
destination: ImportDestination,
origin: Option<ImportOrigin>,
) -> Result<ImportPlan> {
@@ -53,7 +96,7 @@ async fn plan_import_contents<R: Runtime>(
plugin_manager: &plugin_manager,
plugin_context: &plugin_context,
destination,
contents,
input,
origin,
})
.await?)
@@ -88,7 +131,10 @@ pub(crate) fn commit_import<R: Runtime>(
/// This uses Yaak's own API client, which follows the OS proxy but not the workspace's proxy,
/// client certificate, or certificate-validation settings. Requests are unauthenticated, so
/// specs behind auth must still be downloaded manually and imported as a file.
async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> Result<String> {
async fn fetch_import_url<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
) -> Result<ImportRequest> {
let url = normalize_import_url(url)?;
let app_version = window.app_handle().package_info().version.to_string();
let response = yaak_api_client(ApiClientKind::App, &app_version)?
@@ -104,10 +150,27 @@ async fn fetch_import_url<R: Runtime>(window: &WebviewWindow<R>, url: &str) -> R
return Err(Error::GenericError(format!("Failed to fetch {url}: responded with {status}")));
}
response
.text()
// Apply the same input-size limit to downloads before allocating the whole body.
let mut response = response;
let mut bytes = Vec::new();
while let Some(chunk) = response
.chunk()
.await
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))
.map_err(|err| Error::GenericError(format!("Failed to read response from {url}: {err}")))?
{
if (bytes.len() + chunk.len()) as u64 > MAX_IMPORT_BYTES {
return Err(Error::GenericError("Import file exceeds the 64 MiB limit".into()));
}
bytes.extend_from_slice(&chunk);
}
let name = reqwest::Url::parse(&url)
.ok()
.and_then(|url| {
url.path_segments().and_then(|mut segments| segments.next_back().map(str::to_owned))
})
.filter(|name| !name.is_empty())
.unwrap_or_else(|| "download".into());
ImportRequest::from_bytes(&name, &bytes).map_err(|err| Error::GenericError(err.to_string()))
}
pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
@@ -128,15 +191,9 @@ pub(crate) fn normalize_import_url(url: &str) -> Result<String> {
}
}
fn read_import_file(file_path: &str) -> Result<String> {
read_to_string(file_path).map_err(|err| {
if err.kind() == ErrorKind::InvalidData {
Error::GenericError(format!(
"Import file must be UTF-8 text; binary files are not supported: {file_path}"
))
} else {
Error::GenericError(format!("Unable to read import file {file_path}: {err}"))
}
fn read_import_file(file_path: &str) -> Result<ImportRequest> {
ImportRequest::from_path(Path::new(file_path)).map_err(|err| {
Error::GenericError(format!("Unable to read import source {file_path}: {err}"))
})
}
@@ -147,7 +204,7 @@ mod tests {
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn read_import_file_returns_error_for_binary_file() {
fn read_import_file_accepts_binary_file() {
let path = std::env::temp_dir().join(format!(
"yaak-import-binary-{}.pftrace",
SystemTime::now()
@@ -157,10 +214,11 @@ mod tests {
));
write(&path, [0xff, 0xfe, 0xfd]).expect("write binary fixture");
let err = read_import_file(path.to_str().expect("temp path is utf-8"))
.expect_err("binary import should return an error");
let input = read_import_file(path.to_str().expect("temp path is utf-8"))
.expect("binary import should preserve bytes");
assert!(err.to_string().contains("binary files are not supported"));
assert!(input.source.is_some());
assert!(input.content.is_empty());
remove_file(path).expect("remove binary fixture");
}
+4 -11
View File
@@ -4,7 +4,7 @@ use crate::error::Error::GenericError;
use crate::error::Result;
use crate::grpc::{build_metadata, metadata_to_map};
use crate::http_request::send_http_request;
use crate::import::{commit_import, plan_import_data, plan_import_url};
use crate::import::{commit_import, plan_import_data};
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
@@ -1040,18 +1040,11 @@ async fn cmd_get_sse_events<R: Runtime>(
async fn cmd_import_data<R: Runtime>(
window: WebviewWindow<R>,
file_path: &str,
file_paths: &[String],
urls: &[String],
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_data(&window, file_path, destination).await
}
async fn cmd_import_url<R: Runtime>(
window: WebviewWindow<R>,
url: &str,
destination: ImportDestination,
) -> YaakResult<ImportPlan> {
plan_import_url(&window, url, destination).await
plan_import_data(&window, file_paths, urls, destination).await
}
async fn cmd_commit_import<R: Runtime>(
+9 -5
View File
@@ -517,11 +517,8 @@ async fn cmd_import_data<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdImportDataReq,
) -> Result<ImportPlan> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path, req.destination).await?)
}
async fn cmd_import_url<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportUrlReq) -> Result<ImportPlan> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url, req.destination).await?)
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_paths, &req.urls, req.destination)
.await?)
}
async fn cmd_commit_import<R: Runtime>(
@@ -539,6 +536,13 @@ async fn cmd_list_import_sources<R: Runtime>(
Ok(ctx.window.db().list_import_sources(&req.workspace_id)?)
}
async fn cmd_detect_import_source<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdDetectImportSourceReq,
) -> Result<String> {
Ok(crate::import::detect_import_source(&ctx.window, req.file_path, req.url).await?)
}
async fn cmd_import_sources_for_origin<R: Runtime>(
ctx: ClientCtx<R>,
req: CmdImportSourcesForOriginReq,
@@ -3,16 +3,26 @@ use crate::error::Result;
use crate::import::{file_origin, import_data, url_origin};
use crate::models_ext::QueryManagerExt;
use log::{info, warn};
use serde::Serialize;
use std::collections::HashMap;
use std::fs;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager, Runtime, Url};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
use ts_rs::TS;
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_models::util::generate_id;
use yaak_plugins::events::{Color, ShowToastRequest};
use yaak_plugins::install::download_and_install;
#[derive(Debug, Clone, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "index.ts")]
pub struct NavigateWorkspace {
workspace_id: String,
environment_id: Option<String>,
}
pub(crate) async fn handle_deep_link<R: Runtime>(
app_handle: &AppHandle<R>,
url: &Url,
@@ -129,6 +139,15 @@ pub(crate) async fn handle_deep_link<R: Runtime>(
timeout: Some(5000),
},
)?;
if let Some(workspace) = results.workspaces.first() {
window.emit(
"navigate_workspace",
NavigateWorkspace {
workspace_id: workspace.id.clone(),
environment_id: results.environments.first().map(|e| e.id.clone()),
},
)?;
}
}
_ => {
warn!("Unknown deep link command: {command}");
+4 -4
View File
@@ -45,6 +45,8 @@ export type CmdDeleteAllHttpResponsesReq = { requestId: string, };
export type CmdDeleteSendHistoryReq = { workspaceId: string, };
export type CmdDetectImportSourceReq = { filePath?: string, url?: string, };
export type CmdDisableEncryptionReq = { workspaceId: string, };
export type CmdDismissNotificationReq = { notificationId: string, };
@@ -145,12 +147,10 @@ export type CmdHttpResponseBodyPathReq = { responseId: string, };
export type CmdHttpResponseBodyReq = { responseId: string, filter: string | null, };
export type CmdImportDataReq = { filePath: string, destination: ImportDestination, };
export type CmdImportDataReq = { filePaths: Array<string>, urls: Array<string>, destination: ImportDestination, };
export type CmdImportSourcesForOriginReq = { filePath?: string, url?: string, };
export type CmdImportUrlReq = { url: string, destination: ImportDestination, };
export type CmdListImportSourcesReq = { workspaceId: string, };
export type CmdMetadataReq = Record<string, never>;
@@ -256,6 +256,6 @@ export type ModelsWebsocketEventsReq = { connectionId: string, };
export type ModelsWorkspaceModelsReq = { workspaceId: string | null, };
export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array<ServiceDefinition>], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_import_url: [CmdImportUrlReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array<GetThemesResponse>], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfo], cmd_git_workLine truncated
export type RpcSchema = { cmd_metadata: [CmdMetadataReq, AppMetaData], cmd_template_tokens_to_string: [CmdTemplateTokensToStringReq, string], cmd_render_template: [CmdRenderTemplateReq, string], cmd_send_feedback: [CmdSendFeedbackReq, null], cmd_dismiss_notification: [CmdDismissNotificationReq, null], cmd_grpc_reflect: [CmdGrpcReflectReq, Array<ServiceDefinition>], cmd_grpc_go: [CmdGrpcGoReq, string], cmd_restart: [CmdRestartReq, null], cmd_send_ephemeral_request: [CmdSendEphemeralRequestReq, EphemeralHttpResponse], cmd_format_json: [CmdFormatJsonReq, string], cmd_format_graphql: [CmdFormatGraphqlReq, string], cmd_http_response_body: [CmdHttpResponseBodyReq, FilterResponse], cmd_http_response_body_path: [CmdHttpResponseBodyPathReq, string | null], cmd_http_request_body: [CmdHttpRequestBodyReq, Array<number> | null], cmd_get_sse_events: [CmdGetSseEventsReq, Array<ServerSentEvent>], cmd_get_http_response_events: [CmdGetHttpResponseEventsReq, Array<HttpResponseEvent>], cmd_import_data: [CmdImportDataReq, ImportPlan], cmd_commit_import: [CmdCommitImportReq, BatchUpsertResult], cmd_list_import_sources: [CmdListImportSourcesReq, Array<ImportSource>], cmd_import_sources_for_origin: [CmdImportSourcesForOriginReq, Array<ImportSource>], cmd_detect_import_source: [CmdDetectImportSourceReq, string], cmd_http_request_actions: [CmdHttpRequestActionsReq, Array<GetHttpRequestActionsResponse>], cmd_websocket_request_actions: [CmdWebsocketRequestActionsReq, Array<GetWebsocketRequestActionsResponse>], cmd_call_websocket_request_action: [CmdCallWebsocketRequestActionReq, null], cmd_workspace_actions: [CmdWorkspaceActionsReq, Array<GetWorkspaceActionsResponse>], cmd_call_workspace_action: [CmdCallWorkspaceActionReq, null], cmd_folder_actions: [CmdFolderActionsReq, Array<GetFolderActionsResponse>], cmd_call_folder_action: [CmdCallFolderActionReq, null], cmd_grpc_request_actions: [CmdGrpcRequestActionsReq, Array<GetGrpcRequestActionsResponse>], cmd_template_function_summaries: [CmdTemplateFunctionSummariesReq, Array<GetTemplateFunctionSummaryResponse>], cmd_template_function_config: [CmdTemplateFunctionConfigReq, GetTemplateFunctionConfigResponse], cmd_get_http_authentication_summaries: [CmdGetHttpAuthenticationSummariesReq, Array<GetHttpAuthenticationSummaryResponse>], cmd_get_http_authentication_config: [CmdGetHttpAuthenticationConfigReq, GetHttpAuthenticationConfigResponse], cmd_call_http_request_action: [CmdCallHttpRequestActionReq, null], cmd_call_grpc_request_action: [CmdCallGrpcRequestActionReq, null], cmd_call_http_authentication_action: [CmdCallHttpAuthenticationActionReq, null], cmd_curl_to_request: [CmdCurlToRequestReq, HttpRequest], cmd_export_data: [CmdExportDataReq, string], cmd_create_example_workspace: [CmdCreateExampleWorkspaceReq, BatchUpsertResult], cmd_save_base64_to_binary: [CmdSaveBase64ToBinaryReq, null], cmd_save_response: [CmdSaveResponseReq, null], cmd_send_http_request: [CmdSendHttpRequestReq, HttpResponse], cmd_reload_plugins: [CmdReloadPluginsReq, Array<[string, string]>], cmd_plugin_info: [CmdPluginInfoReq, PluginMetadata], cmd_delete_all_grpc_connections: [CmdDeleteAllGrpcConnectionsReq, null], cmd_delete_send_history: [CmdDeleteSendHistoryReq, null], cmd_delete_all_http_responses: [CmdDeleteAllHttpResponsesReq, null], cmd_get_workspace_meta: [CmdGetWorkspaceMetaReq, WorkspaceMeta], cmd_new_child_window: [CmdNewChildWindowReq, null], cmd_new_main_window: [CmdNewMainWindowReq, null], cmd_check_for_updates: [CmdCheckForUpdatesReq, boolean], cmd_decrypt_template: [CmdDecryptTemplateReq, string], cmd_secure_template: [CmdSecureTemplateReq, string], cmd_get_themes: [CmdGetThemesReq, Array<GetThemesResponse>], cmd_enable_encryption: [CmdEnableEncryptionReq, null], cmd_reveal_workspace_key: [CmdRevealWorkspaceKeyReq, string], cmd_set_workspace_key: [CmdSetWorkspaceKeyReq, null], cmd_disable_encryption: [CmdDisableEncryptionReq, null], cmd_default_headers: [CmdDefaultHeadersReq, Array<HttpRequestHeader>], models_upsert: [ModelsUpsertReq, string], models_delete: [ModelsDeleteReq, string], models_duplicate: [ModelsDuplicateReq, string], models_websocket_events: [ModelsWebsocketEventsReq, Array<WebsocketEvent>], models_grpc_events: [ModelsGrpcEventsReq, Array<GrpcEvent>], models_get_settings: [ModelsGetSettingsReq, Settings], models_get_graphql_introspection: [ModelsGetGraphqlIntrospectionReq, GraphQlIntrospection | null], models_upsert_graphql_introspection: [ModelsUpsertGraphqlIntrospectionReq, GraphQlIntrospection], models_workspace_models: [ModelsWorkspaceModelsReq, string], cmd_git_checkout: [CmdGitCheckoutReq, string], cmd_git_branch: [CmdGitBranchReq, null], cmd_git_delete_branch: [CmdGitDeleteBranchReq, BranchDeleteResult], cmd_git_delete_remote_branch: [CmdGitDeleteRemoteBranchReq, null], cmd_git_merge_branch: [CmdGitMergeBranchReq, null], cmd_git_rename_branch: [CmdGitRenameBranchReq, null], cmd_git_status: [CmdGitStatusReq, GitStatusSummary], cmd_git_branch_info: [CmdGitBranchInfoReq, GitBranchInfoLine truncated
export type WatchResult = { unlistenEvent: string, };
+12
View File
@@ -54,6 +54,10 @@ export type ImportPlan = {
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
/**
* Individual origins in a combined import, each retaining its own reimport history.
*/
sources: Array<ImportPlanSource>;
};
export type ImportPlanAction =
@@ -88,6 +92,14 @@ export type ImportPlanItem = {
*/
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanSource = {
importer: string;
origin: ImportOrigin;
workspaceId: string;
sourceKeys: { [key in string]?: string };
linkedSourceId?: string;
};
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
+13 -10
View File
@@ -228,15 +228,8 @@ pub struct CmdGetHttpResponseEventsReq {
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportDataReq {
pub file_path: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportUrlReq {
pub url: String,
pub file_paths: Vec<String>,
pub urls: Vec<String>,
pub destination: ImportDestination,
}
@@ -264,6 +257,16 @@ pub struct CmdImportSourcesForOriginReq {
pub url: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdDetectImportSourceReq {
#[ts(optional)]
pub file_path: Option<String>,
#[ts(optional)]
pub url: Option<String>,
}
#[derive(Debug, Deserialize, TS)]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdHttpRequestActionsReq {}
@@ -939,10 +942,10 @@ macro_rules! with_commands {
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> ImportPlan,
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
cmd_list_import_sources(CmdListImportSourcesReq) -> Vec<ImportSource>,
cmd_import_sources_for_origin(CmdImportSourcesForOriginReq) -> Vec<ImportSource>,
cmd_detect_import_source(CmdDetectImportSourceReq) -> String,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
+2 -2
View File
@@ -2,8 +2,8 @@
use crate::error::Result;
use crate::host::Host;
use yaak::example::create_example_workspace;
use yaak::export::{self, ExportDataParams};
use yaak_models::example::create_example_workspace;
use yaak_models::export::{self, ExportDataParams};
use yaak_models::util::BatchUpsertResult;
use yaak_rpc_schema::*;
use yaak_templates::format_json::format_json;
+12
View File
@@ -54,6 +54,10 @@ export type ImportPlan = {
*/
items: Array<ImportPlanItem>;
origin?: ImportOrigin;
/**
* Individual origins in a combined import, each retaining its own reimport history.
*/
sources: Array<ImportPlanSource>;
};
export type ImportPlanAction =
@@ -88,6 +92,14 @@ export type ImportPlanItem = {
*/
export type ImportPlanReason = "moved_into_ignored_folder";
export type ImportPlanSource = {
importer: string;
origin: ImportOrigin;
workspaceId: string;
sourceKeys: { [key in string]?: string };
linkedSourceId?: string;
};
export type ImportPlanWarning = { title: string; detail: string; level: ImportPlanWarningLevel };
/**
@@ -4,10 +4,10 @@
//! other (the chaining example names another request by id). Every placeholder
//! gets a fresh id per creation, so the example can be created more than once.
use crate::Result;
use yaak_models::models::{Environment, Folder, HttpRequest, UpsertModelInfo, Workspace};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, UpdateSource};
use crate::error::Result;
use crate::models::{Environment, Folder, HttpRequest, UpsertModelInfo, Workspace};
use crate::query_manager::QueryManager;
use crate::util::{BatchUpsertResult, UpdateSource};
const EXAMPLE_JSON: &str = include_str!("example_workspace.json");
@@ -67,7 +67,7 @@ fn example_resources() -> Result<BatchUpsertResult> {
#[cfg(test)]
mod tests {
use super::*;
use yaak_models::init_in_memory;
use crate::init_in_memory;
#[test]
fn every_placeholder_is_mapped() {
@@ -1,6 +1,12 @@
use crate::Result;
use yaak_models::query_manager::QueryManager;
use yaak_models::util::get_workspace_export_resources;
//! Building an export document.
//!
//! Here rather than in `yaak` because the whole of it is this crate: a connection, the
//! resources, and JSON. Putting it where the browser can reach it too is what keeps a
//! tab's export from being a second implementation that drifts.
use crate::error::Result;
use crate::query_manager::QueryManager;
use crate::util::get_workspace_export_resources;
pub struct ExportDataParams<'a> {
pub query_manager: &'a QueryManager,
@@ -12,8 +18,8 @@ pub struct ExportDataParams<'a> {
/// The export document, as JSON.
///
/// Returned rather than written: where an export goes is the host's to decide, and a browser
/// tab has no path to be handed. The desktop hands the bytes to its save dialog; a tab hands
/// them to a download. Neither needs this function to know which.
/// tab has no path to be handed. The desktop hands the bytes to its save dialog, the CLI
/// writes them, a tab downloads them. None of that is this function's business.
pub fn export_data(params: ExportDataParams<'_>) -> Result<String> {
let db = params.query_manager.connect();
let export_data = get_workspace_export_resources(
+2
View File
@@ -13,6 +13,8 @@ pub mod client_db;
mod connection_or_tx;
pub mod cookies;
pub mod error;
pub mod example;
pub mod export;
pub mod migrate;
pub mod models;
pub mod models_ops;
+18 -1
View File
@@ -103,7 +103,7 @@ pub enum ImportDestination {
},
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanWarning {
@@ -232,6 +232,19 @@ pub struct ImportPlanItem {
pub changed_fields: Vec<String>,
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanSource {
pub importer: String,
pub origin: ImportOrigin,
pub workspace_id: String,
pub source_keys: BTreeMap<String, String>,
#[serde(default)]
#[ts(optional)]
pub linked_source_id: Option<String>,
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
@@ -251,6 +264,10 @@ pub struct ImportPlan {
#[serde(default)]
#[ts(optional)]
pub origin: Option<ImportOrigin>,
/// Individual origins in a combined import, each retaining its own reimport history.
#[serde(default)]
pub sources: Vec<ImportPlanSource>,
}
pub fn get_workspace_export_resources(
+10 -1
View File
@@ -470,7 +470,16 @@ export type HttpRequestAction = { label: string, icon?: Icon, };
export type Icon = "alert_triangle" | "check" | "check_circle" | "chevron_down" | "copy" | "info" | "pin" | "search" | "trash" | "_unknown";
export type ImportRequest = { content: string, };
/**
* Transport descriptor; plugins see a scoped ImportFiles API, not this representation.
*/
export type ImportFileSource = { "type": "file", name: string, base64: string, } | { "type": "directory", path: string, };
export type ImportRequest = {
/**
* Kept for text-only importers and older runtimes.
*/
content: string, source?: ImportFileSource, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
+13
View File
@@ -240,7 +240,20 @@ pub struct ReloadResponse {
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ImportRequest {
/// Kept for text-only importers and older runtimes.
pub content: String,
#[ts(optional)]
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<ImportFileSource>,
}
/// Transport descriptor; plugins see a scoped ImportFiles API, not this representation.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_events.ts")]
pub enum ImportFileSource {
File { name: String, base64: String },
Directory { path: String },
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, TS)]
+79
View File
@@ -0,0 +1,79 @@
use crate::events::{ImportFileSource, ImportRequest};
use base64::Engine;
use base64::engine::general_purpose::STANDARD;
use std::fs::{self, File};
use std::io::{self, Read};
use std::path::Path;
pub const MAX_IMPORT_BYTES: u64 = 64 * 1024 * 1024;
impl ImportRequest {
pub fn from_text(content: &str) -> Self {
Self { content: content.to_string(), source: None }
}
pub fn from_bytes(name: &str, bytes: &[u8]) -> io::Result<Self> {
if bytes.len() as u64 > MAX_IMPORT_BYTES {
return Err(io::Error::other("Import file exceeds the 64 MiB limit"));
}
Ok(Self {
content: std::str::from_utf8(bytes).unwrap_or_default().to_string(),
source: Some(ImportFileSource::File {
name: name.to_string(),
base64: STANDARD.encode(bytes),
}),
})
}
pub fn from_path(path: &Path) -> io::Result<Self> {
let path = fs::canonicalize(path)?;
let metadata = fs::metadata(&path)?;
if metadata.is_dir() {
return Ok(Self {
content: String::new(),
source: Some(ImportFileSource::Directory {
path: path
.to_str()
.ok_or_else(|| io::Error::other("Import path is not UTF-8"))?
.to_string(),
}),
});
}
if !metadata.is_file() {
return Err(io::Error::other("Import source must be a file or directory"));
}
let mut bytes = Vec::new();
File::open(&path)?.take(MAX_IMPORT_BYTES + 1).read_to_end(&mut bytes)?;
Self::from_bytes(path.file_name().and_then(|v| v.to_str()).unwrap_or("input"), &bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_and_binary_inputs_preserve_bytes() {
let input = ImportRequest::from_bytes("api.yml", "café".as_bytes()).unwrap();
assert_eq!(input.content, "café");
let input = ImportRequest::from_bytes("data.zip", &[0x50, 0x4b, 0xff]).unwrap();
assert_eq!(input.content, "");
let Some(ImportFileSource::File { name, base64 }) = input.source else {
panic!("file input")
};
assert_eq!(name, "data.zip");
assert_eq!(STANDARD.decode(base64).unwrap(), vec![0x50, 0x4b, 0xff]);
assert!(ImportRequest::from_text("text").source.is_none());
}
#[test]
fn directory_input_is_lazy_and_canonical() {
let input = ImportRequest::from_path(Path::new(".")).unwrap();
let Some(ImportFileSource::Directory { path }) = input.source else {
panic!("directory input")
};
assert_eq!(Path::new(&path), fs::canonicalize(".").unwrap());
assert!(input.content.is_empty());
assert!(ImportRequest::from_path(Path::new("does-not-exist-yaak-import")).is_err());
}
}
+2
View File
@@ -10,6 +10,8 @@ pub mod api;
mod checksum;
pub mod error;
pub mod events;
mod import_input;
pub use import_input::MAX_IMPORT_BYTES;
pub mod install;
pub mod manager;
pub mod native_template_functions;
+19 -4
View File
@@ -1091,18 +1091,32 @@ impl PluginManager {
&self,
plugin_context: &PluginContext,
content: &str,
) -> Result<ImportResponse> {
self.import_input(plugin_context, &ImportRequest::from_text(content)).await
}
pub async fn import_input(
&self,
plugin_context: &PluginContext,
input: &ImportRequest,
) -> Result<ImportResponse> {
let reply_events = self
.send_and_wait(
plugin_context,
&InternalEventPayload::ImportRequest(ImportRequest {
content: content.to_string(),
}),
&InternalEventPayload::ImportRequest(input.clone()),
Duration::from_secs(60),
)
.await?;
// TODO: Don't just return the first valid response
let errors = reply_events
.iter()
.filter_map(|e| match &e.payload {
InternalEventPayload::ErrorResponse(error) => Some(error.error.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("; ");
let result = reply_events.into_iter().find_map(|e| match e {
InternalEvent {
plugin_name,
@@ -1120,7 +1134,8 @@ impl PluginManager {
});
match result {
None => Err(PluginErr("No importers found for file contents".to_string())),
None if !errors.is_empty() => Err(PluginErr(errors)),
None => Err(PluginErr("No importers found for this input".to_string())),
Some(resp) => Ok(resp),
}
}
+19 -19
View File
@@ -512,7 +512,7 @@ export function __wbg_new_typed_c072c4ce9a2a0cdf(arg0, arg1) {
const a = state0.a;
state0.a = 0;
try {
return wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined_______true_(a, state0.b, arg0, arg1);
return wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81(a, state0.b, arg0, arg1);
} finally {
state0.a = a;
}
@@ -697,23 +697,23 @@ export function __wbg_warn_b6f36cac66fc96a4(arg0, arg1) {
console.warn(arg0, arg1);
}
export function __wbindgen_cast_0000000000000001(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1115, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___wasm_bindgen_9b5275515258a0f8___JsValue__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsError___true_);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 1131, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23);
return ret;
}
export function __wbindgen_cast_0000000000000002(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 207, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 214, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h54297d6cc284a2e5);
return ret;
}
export function __wbindgen_cast_0000000000000003(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 172, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_IdbVersionChangeEvent__IdbVersionChangeEvent__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsValue___true_);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("IDBVersionChangeEvent")], shim_idx: 177, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562);
return ret;
}
export function __wbindgen_cast_0000000000000004(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 209, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_);
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 216, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h5977a2970909c8b8);
return ret;
}
export function __wbindgen_cast_0000000000000005(arg0) {
@@ -750,30 +750,30 @@ export function __wbindgen_init_externref_table() {
table.set(offset + 2, true);
table.set(offset + 3, false);
}
function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_(arg0, arg1) {
wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_(arg0, arg1);
function wasm_bindgen__convert__closures_____invoke__h5977a2970909c8b8(arg0, arg1) {
wasm.wasm_bindgen__convert__closures_____invoke__h5977a2970909c8b8(arg0, arg1);
}
function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_(arg0, arg1, arg2) {
wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h54297d6cc284a2e5(arg0, arg1, arg2) {
wasm.wasm_bindgen__convert__closures_____invoke__h54297d6cc284a2e5(arg0, arg1, arg2);
}
function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___wasm_bindgen_9b5275515258a0f8___JsValue__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsError___true_(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___wasm_bindgen_9b5275515258a0f8___JsValue__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsError___true_(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_IdbVersionChangeEvent__IdbVersionChangeEvent__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsValue___true_(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_IdbVersionChangeEvent__IdbVersionChangeEvent__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsValue___true_(arg0, arg1, arg2);
function wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562(arg0, arg1, arg2) {
const ret = wasm.wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562(arg0, arg1, arg2);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
function wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined_______true_(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined_______true_(arg0, arg1, arg2, arg3);
function wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81(arg0, arg1, arg2, arg3) {
wasm.wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81(arg0, arg1, arg2, arg3);
}
Binary file not shown.
+5 -5
View File
@@ -17,11 +17,11 @@ export const rust_sqlite_wasm_malloc: (a: number) => number;
export const rust_sqlite_wasm_realloc: (a: number, b: number) => number;
export const sqlite3_os_end: () => number;
export const sqlite3_os_init: () => number;
export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___wasm_bindgen_9b5275515258a0f8___JsValue__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsError___true_: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_IdbVersionChangeEvent__IdbVersionChangeEvent__core_f0fd674eaa06beef___result__Result_____wasm_bindgen_9b5275515258a0f8___JsValue___true_: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined___js_sys_434c4d6d72aa7d1b___Function_fn_wasm_bindgen_9b5275515258a0f8___JsValue_____wasm_bindgen_9b5275515258a0f8___sys__Undefined_______true_: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke___web_sys_e1a11cd1518a8b4d___features__gen_Event__Event______true_: (a: number, b: number, c: any) => void;
export const wasm_bindgen_9b5275515258a0f8___convert__closures_____invoke_______true_: (a: number, b: number) => void;
export const wasm_bindgen__convert__closures_____invoke__h2c4f0a20127a8a23: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h804afe9d352bd562: (a: number, b: number, c: any) => [number, number];
export const wasm_bindgen__convert__closures_____invoke__h7cced28f7815df81: (a: number, b: number, c: any, d: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h54297d6cc284a2e5: (a: number, b: number, c: any) => void;
export const wasm_bindgen__convert__closures_____invoke__h5977a2970909c8b8: (a: number, b: number) => void;
export const __wbindgen_malloc: (a: number, b: number) => number;
export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
export const __wbindgen_exn_store: (a: number) => void;
+16 -11
View File
@@ -416,22 +416,27 @@ fn dispatch(
to_json(())
}
// The export document, built by the same `yaak-models` helper the desktop and the CLI
// build it with. Nothing about what an export *is* is decided here — this host only
// differs in what happens to the bytes afterwards, which is the tab's business.
// The same `export_data` the desktop and the CLI call, not a second copy of its
// steps. Nothing about what an export *is* is decided here; this host differs only
// in what happens to the bytes afterwards, which is the tab's business.
"cmd_export_data" => {
let req: ExportDataReq = from_js(payload)?;
let db = host.queries.connect();
let export = yaak_models::util::get_workspace_export_resources(
&db,
EXPORT_VERSION,
req.workspace_ids.iter().map(|s| s.as_str()).collect(),
req.include_private_environments,
to_json(
yaak_models::export::export_data(yaak_models::export::ExportDataParams {
query_manager: &host.queries,
yaak_version: EXPORT_VERSION,
workspace_ids: req.workspace_ids.iter().map(|s| s.as_str()).collect(),
include_private_environments: req.include_private_environments,
})
.map_err(js_error)?,
)
.map_err(js_error)?;
to_json(serde_json::to_string_pretty(&export).map_err(js_error)?)
}
"cmd_create_example_workspace" => to_json(
yaak_models::example::create_example_workspace(&host.queries, source)
.map_err(js_error)?,
),
"cmd_get_workspace_meta" => {
let req: WorkspaceIdReq = from_js(payload)?;
let workspace =
+626 -32
View File
@@ -12,10 +12,10 @@ use yaak_models::models::{
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{
BatchUpsertResult, ImportConflictResolution, ImportDestination, ImportOrigin, ImportPlan,
ImportPlanAction, ImportPlanItem, ImportPlanReason, ImportPlanWarning, ImportResourceType,
UpdateSource,
ImportPlanAction, ImportPlanItem, ImportPlanReason, ImportPlanSource, ImportPlanWarning,
ImportResourceType, UpdateSource,
};
use yaak_plugins::events::{ImportResources, PluginContext};
use yaak_plugins::events::{ImportRequest, ImportResources, ImportResponse, PluginContext};
use yaak_plugins::manager::PluginManager;
pub struct PlanImportDataParams<'a> {
@@ -23,14 +23,14 @@ pub struct PlanImportDataParams<'a> {
pub plugin_manager: &'a PluginManager,
pub plugin_context: &'a PluginContext,
pub destination: ImportDestination,
pub contents: &'a str,
pub input: &'a ImportRequest,
pub origin: Option<ImportOrigin>,
}
/// Parse importer output and turn it into a commit-ready plan without mutating the database.
pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<ImportPlan> {
let import_result =
params.plugin_manager.import_data(params.plugin_context, params.contents).await?;
params.plugin_manager.import_input(params.plugin_context, params.input).await?;
plan_import_resources(
params.query_manager,
@@ -42,6 +42,157 @@ pub async fn plan_import_data(params: PlanImportDataParams<'_>) -> Result<Import
)
}
/// Plan each input independently so importer keys and linked-source matching remain scoped to
/// that input, then combine the results for one preview and one atomic commit.
pub fn plan_import_batch_resources(
query_manager: &QueryManager,
destination: ImportDestination,
inputs: Vec<(ImportResponse, ImportOrigin)>,
) -> Result<ImportPlan> {
let mut plans = Vec::new();
for (response, origin) in inputs {
let has_workspace = !response.resources.workspaces.is_empty();
let plan = plan_import_resources(
query_manager,
response.importer,
destination.clone(),
response.resources,
response.source_keys,
Some(origin),
)?;
plans.push((plan, has_workspace));
}
if plans.len() == 1 {
return Ok(plans.remove(0).0);
}
if plans.is_empty() {
return Err(yaak_models::error::Error::GenericError(
"Choose at least one import source".into(),
)
.into());
}
// Standalone environments/requests accompany the first actual collection, regardless of
// picker order. If every input is standalone, they share the first generated workspace.
let primary = plans.iter().position(|(_, has_workspace)| *has_workspace).unwrap_or(0);
let shared_workspace = plans[primary].0.resources.workspaces.first().cloned();
let mut taken_names =
query_manager.connect().list_workspaces()?.into_iter().map(|w| w.name).collect::<Vec<_>>();
let mut seen_items = BTreeSet::new();
let mut seen_linked_sources = BTreeSet::new();
let mut importers = Vec::new();
let mut combined = ImportPlan {
importer: String::new(),
destination,
resources: BatchUpsertResult::default(),
warnings: Vec::new(),
source_keys: BTreeMap::new(),
items: Vec::new(),
origin: None,
sources: Vec::new(),
};
for (index, (mut plan, has_workspace)) in plans.into_iter().enumerate() {
if let Some(workspace) = &shared_workspace
&& !has_workspace
&& index != primary
{
for old in plan.resources.workspaces.drain(..) {
plan.source_keys.remove(&old.id);
}
for v in &mut plan.resources.environments {
v.workspace_id = workspace.id.clone();
if v.parent_model == "workspace" {
v.parent_model = "environment".into();
v.name = format!("{} (Imported)", v.name);
}
}
for v in &mut plan.resources.folders {
v.workspace_id = workspace.id.clone();
}
for v in &mut plan.resources.http_requests {
v.workspace_id = workspace.id.clone();
}
for v in &mut plan.resources.grpc_requests {
v.workspace_id = workspace.id.clone();
}
for v in &mut plan.resources.websocket_requests {
v.workspace_id = workspace.id.clone();
}
plan.items = create_only_items(&plan);
}
for workspace in &mut plan.resources.workspaces {
workspace.name = unique_name(&workspace.name, &taken_names);
taken_names.push(workspace.name.clone());
}
for item in &plan.items {
if !seen_items.insert(item.model_id.clone()) {
return Err(yaak_models::error::Error::GenericError(
format!("More than one import source changes {}. Import these sources separately to resolve the overlap.", item.name)
).into());
}
}
if !importers.contains(&plan.importer) {
importers.push(plan.importer.clone());
}
let workspace_id = match &plan.destination {
ImportDestination::ExistingWorkspace { workspace_id, .. } => workspace_id.clone(),
ImportDestination::NewWorkspace => plan
.resources
.workspaces
.first()
.or(shared_workspace.as_ref())
.expect("planned workspace")
.id
.clone(),
};
if let Some(origin) = plan.origin {
let linked_source_id = match &plan.destination {
ImportDestination::NewWorkspace => None,
ImportDestination::ExistingWorkspace { .. } => match resolve_linked_source(
&query_manager.connect(),
&workspace_id,
&plan.importer,
&origin,
&plan.source_keys.values().cloned().collect(),
)? {
LinkedSource::Linked(source) => Some(source.id),
LinkedSource::Ambiguous(_) | LinkedSource::Unlinked => None,
},
};
if let Some(id) = &linked_source_id
&& !seen_linked_sources.insert(id.clone())
{
return Err(yaak_models::error::Error::GenericError(
"More than one input matches the same linked import. Keep only one version of that source.".into()
).into());
}
combined.sources.push(ImportPlanSource {
importer: plan.importer,
origin,
workspace_id,
source_keys: plan.source_keys.clone(),
linked_source_id,
});
}
combined.resources.workspaces.extend(plan.resources.workspaces);
combined.resources.environments.extend(plan.resources.environments);
combined.resources.folders.extend(plan.resources.folders);
combined.resources.http_requests.extend(plan.resources.http_requests);
combined.resources.grpc_requests.extend(plan.resources.grpc_requests);
combined.resources.websocket_requests.extend(plan.resources.websocket_requests);
combined.items.extend(plan.items);
combined.source_keys.extend(plan.source_keys);
for warning in plan.warnings {
if !combined.warnings.contains(&warning) {
combined.warnings.push(warning);
}
}
}
combined.importer = importers.join(", ");
validate_plan(&combined)?;
Ok(combined)
}
/// Remap parsed importer resources into their selected destination.
///
/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an
@@ -294,32 +445,31 @@ pub fn plan_import_resources(
warnings,
items: Vec::new(),
origin,
sources: Vec::new(),
};
merge_with_linked_source(query_manager, &mut plan, &original)?;
warn_if_imported_elsewhere(&query_manager.connect(), &mut plan)?;
Ok(plan)
}
/// A document that already produced a workspace can be merged back into it. Importing it anywhere
/// else copies it instead, which is worth saying before the user finds two of everything.
/// Warn only when this importer has already read the exact same origin elsewhere. Similar
/// resource names are not evidence of a previous import; overlap matching belongs to merging
/// into an explicitly selected workspace.
fn warn_if_imported_elsewhere(db: &ClientDb, plan: &mut ImportPlan) -> Result<()> {
let Some(origin) = &plan.origin else {
return Ok(());
};
let destination_id = match &plan.destination {
ImportDestination::ExistingWorkspace { workspace_id, .. } => Some(workspace_id.as_str()),
ImportDestination::NewWorkspace => None,
};
let incoming = plan.source_keys.values().collect::<BTreeSet<_>>();
let mut names = BTreeSet::new();
for workspace in db.list_workspaces()? {
if Some(workspace.id.as_str()) == destination_id {
continue;
}
for source in db.list_import_sources(&workspace.id)? {
let overlaps = db
.list_import_source_resources(&source.id)?
.iter()
.any(|row| incoming.contains(&row.source_key));
if overlaps {
if source.importer == plan.importer && source.origin == origin.origin {
names.insert(workspace.name.clone());
break;
}
@@ -508,10 +658,15 @@ fn record_import_source(
items: &BTreeMap<String, ImportPlanItem>,
upserted: &BatchUpsertResult,
) -> Result<()> {
if !plan.sources.is_empty() {
for source in &plan.sources {
record_import_plan_source(db, plan, source, items, upserted)?;
}
return Ok(());
}
let Some(origin) = &plan.origin else {
return Ok(());
};
let workspace_id = match &plan.destination {
ImportDestination::ExistingWorkspace { workspace_id, .. } => workspace_id.clone(),
ImportDestination::NewWorkspace => match upserted.workspaces.first() {
@@ -519,20 +674,50 @@ fn record_import_source(
None => return Ok(()),
},
};
record_import_plan_source(
db,
plan,
&ImportPlanSource {
importer: plan.importer.clone(),
origin: origin.clone(),
workspace_id,
source_keys: plan.source_keys.clone(),
linked_source_id: None,
},
items,
upserted,
)
}
let incoming_keys: BTreeSet<String> = plan.source_keys.values().cloned().collect();
let existing =
match resolve_linked_source(db, &workspace_id, &plan.importer, origin, &incoming_keys)? {
LinkedSource::Linked(source) => Some(source),
fn record_import_plan_source(
db: &WriteDb,
plan: &ImportPlan,
source: &ImportPlanSource,
items: &BTreeMap<String, ImportPlanItem>,
upserted: &BatchUpsertResult,
) -> Result<()> {
let incoming_keys: BTreeSet<String> = source.source_keys.values().cloned().collect();
let source_id = if plan.sources.is_empty() {
match resolve_linked_source(
db,
&source.workspace_id,
&source.importer,
&source.origin,
&incoming_keys,
)? {
LinkedSource::Linked(source) => Some(source.id),
LinkedSource::Ambiguous(_) | LinkedSource::Unlinked => None,
};
}
} else {
source.linked_source_id.clone()
};
let import_source = db.upsert_import_source(
&ImportSource {
id: existing.map(|s| s.id).unwrap_or_default(),
workspace_id,
importer: plan.importer.clone(),
origin: origin.origin.clone(),
origin_label: origin.label.clone(),
id: source_id.unwrap_or_default(),
workspace_id: source.workspace_id.clone(),
importer: source.importer.clone(),
origin: source.origin.origin.clone(),
origin_label: source.origin.label.clone(),
last_imported_at: Utc::now().naive_utc(),
..Default::default()
},
@@ -560,7 +745,7 @@ fn record_import_source(
resource: ImportResourceType,
incoming: &dyn Fn() -> Result<Value>|
-> Result<()> {
let Some(source_key) = plan.source_keys.get(model_id) else {
let Some(source_key) = source.source_keys.get(model_id) else {
return Ok(());
};
let item = items.get(model_id);
@@ -919,7 +1104,7 @@ fn merge_with_linked_source(
let incoming = comparable(serde_json::to_value(&any)?);
let current = comparable(current_models.get(&planned_id).cloned().unwrap_or_default());
let (source_changed, local_changed) = match recorded_hash(row) {
let (source_changed, local_changed) = match recorded_hash(row, &incoming) {
Some(hash) => {
(hash_comparable(&incoming) != hash, hash_comparable(&current) != hash)
}
@@ -1087,6 +1272,12 @@ enum KeyStatus<'a> {
fn comparable(value: Value) -> Value {
let mut value = strip_ids(value);
if let Some(object) = value.as_object_mut() {
// Folder environment names are derived by upsert_environment, not source content.
if object.get("model").and_then(Value::as_str) == Some("environment")
&& object.get("parentModel").and_then(Value::as_str) == Some("folder")
{
object.remove("name");
}
for field in [
"model",
"workspaceId",
@@ -1118,7 +1309,11 @@ fn strip_ids(value: Value) -> Value {
}
}
const CONTENT_HASH_VERSION: &str = "v1:";
fn content_hash_version(value: &Value) -> &'static str {
// Only folder environments changed comparison semantics. Older hashes for these fall back
// to comparing the two sides until the next import records a hash without the derived name.
if value.get("parentModel").and_then(Value::as_str) == Some("folder") { "v2:" } else { "v1:" }
}
/// Identifies a resource's content well enough to tell "changed since the last import" from
/// "unchanged", without keeping a copy of every imported resource around.
@@ -1128,7 +1323,7 @@ fn content_hash(value: Value) -> String {
fn hash_comparable(value: &Value) -> String {
let canonical = serde_json::to_string(&sorted_keys(value.clone())).unwrap_or_default();
format!("{CONTENT_HASH_VERSION}{:x}", Sha256::digest(canonical.as_bytes()))
format!("{}{:x}", content_hash_version(value), Sha256::digest(canonical.as_bytes()))
}
/// The fields two comparable forms disagree on, so a preview row can explain itself.
@@ -1147,9 +1342,9 @@ fn changed_fields(incoming: &Value, current: &Value) -> Vec<String> {
}
/// A hash written by a version this build doesn't understand says nothing about the resource.
fn recorded_hash(row: &ImportSourceResource) -> Option<&str> {
fn recorded_hash<'a>(row: &'a ImportSourceResource, incoming: &Value) -> Option<&'a str> {
let hash = row.content_hash.as_deref()?;
hash.starts_with(CONTENT_HASH_VERSION).then_some(hash)
hash.starts_with(content_hash_version(incoming)).then_some(hash)
}
fn sorted_keys(value: Value) -> Value {
@@ -1312,7 +1507,15 @@ fn unique_name(name: &str, taken: &[String]) -> String {
if name.is_empty() || !taken.iter().any(|t| t == name) {
return name.to_string();
}
let mut n = 2;
// A suffix this function generated continues the sequence instead of nesting. A name that
// merely ends in a number, like "Release (2024)", has no taken base and keeps its name.
let (name, mut n) = match name.rsplit_once(" (") {
Some((base, rest)) => match rest.strip_suffix(')').and_then(|n| n.parse::<u32>().ok()) {
Some(n) if taken.iter().any(|t| t == base) => (base, n + 1),
_ => (name, 2),
},
None => (name, 2),
};
loop {
let candidate = format!("{name} ({n})");
if !taken.iter().any(|t| *t == candidate) {
@@ -1607,6 +1810,235 @@ mod tests {
}
}
fn batch_input(
importer: &str,
path: &str,
resources: ImportResources,
) -> (ImportResponse, ImportOrigin) {
(
ImportResponse { importer: importer.into(), resources, source_keys: None },
ImportOrigin { origin: path.into(), label: path.into() },
)
}
fn standalone_environment(name: &str) -> ImportResources {
ImportResources {
environments: vec![Environment {
id: "ev_export".into(),
model: "environment".into(),
workspace_id: "CURRENT_WORKSPACE".into(),
name: name.into(),
parent_model: "environment".into(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "host".into(),
value: "example.com".into(),
id: None,
}],
..Default::default()
}],
..Default::default()
}
}
fn collection_and_environments() -> Vec<(ImportResponse, ImportOrigin)> {
vec![
batch_input("Postman Environment", "/local.json", standalone_environment("Local")),
batch_input("Postman", "/collection.json", imported_resources()),
batch_input(
"Postman Environment",
"/production.json",
standalone_environment("Production"),
),
]
}
#[test]
fn batch_collection_and_environments_share_one_workspace_in_either_order() {
for reverse in [false, true] {
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
let mut inputs = collection_and_environments();
if reverse {
inputs.reverse();
}
let plan =
plan_import_batch_resources(&manager, ImportDestination::NewWorkspace, inputs)
.unwrap();
assert!(manager.connect().list_workspaces().unwrap().is_empty());
assert_eq!(plan.resources.workspaces.len(), 1);
let workspace_id = plan.resources.workspaces[0].id.clone();
assert_eq!(plan.resources.workspaces[0].name, "Imported");
assert_eq!(plan.resources.environments.len(), 3);
assert!(plan.resources.environments.iter().all(|e| e.workspace_id == workspace_id));
assert_eq!(plan.sources.len(), 3);
// The plan crosses IPC and is returned with the user's item selections.
let plan = serde_json::from_value(serde_json::to_value(plan).unwrap()).unwrap();
let committed = commit_import_plan(&manager, plan).unwrap();
assert_eq!(committed.workspaces.len(), 1);
assert_eq!(committed.environments.len(), 3);
assert_eq!(manager.connect().list_import_sources(&workspace_id).unwrap().len(), 3);
}
}
#[test]
fn batch_reimport_preserves_ids_and_scopes_deletions_to_each_source() {
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
let first = plan_import_batch_resources(
&manager,
ImportDestination::NewWorkspace,
collection_and_environments(),
)
.unwrap();
let workspace_id = first.resources.workspaces[0].id.clone();
let ids =
first.resources.environments.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>();
commit_import_plan(&manager, first).unwrap();
let destination = ImportDestination::ExistingWorkspace {
workspace_id: workspace_id.clone(),
folder_id: None,
};
let unchanged = plan_import_batch_resources(
&manager,
destination.clone(),
collection_and_environments(),
)
.unwrap();
assert!(unchanged.items.iter().all(|i| i.action == ImportPlanAction::Unchanged));
assert_eq!(
unchanged.resources.environments.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>(),
ids
);
commit_import_plan(&manager, unchanged).unwrap();
let mut inputs = collection_and_environments();
inputs[1].0.resources.http_requests.remove(0);
let changed = plan_import_batch_resources(&manager, destination.clone(), inputs).unwrap();
let deletes = changed
.items
.iter()
.filter(|i| i.action == ImportPlanAction::Delete)
.collect::<Vec<_>>();
assert_eq!(deletes.len(), 1);
assert_eq!(deletes[0].name, "Root Request");
commit_import_plan(&manager, changed).unwrap();
assert_eq!(
manager
.connect()
.list_environments(&workspace_id)
.unwrap()
.iter()
.map(|v| v.id.clone())
.collect::<BTreeSet<_>>(),
ids
);
assert_eq!(manager.connect().list_import_sources(&workspace_id).unwrap().len(), 3);
// A companion file can still be reimported on its own after the batch.
let single = plan_import_batch_resources(
&manager,
destination,
vec![collection_and_environments().remove(0)],
)
.unwrap();
assert_eq!(single.items.len(), 1);
assert_eq!(single.items[0].action, ImportPlanAction::Unchanged);
}
#[test]
fn batch_same_named_environments_keep_distinct_source_links() {
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
let inputs = || {
vec![
batch_input("Postman Environment", "/one.json", standalone_environment("Local")),
batch_input("Postman Environment", "/two.json", standalone_environment("Local")),
]
};
let first =
plan_import_batch_resources(&manager, ImportDestination::NewWorkspace, inputs())
.unwrap();
assert_eq!(first.resources.workspaces.len(), 1);
let workspace_id = first.resources.workspaces[0].id.clone();
commit_import_plan(&manager, first).unwrap();
assert_eq!(manager.connect().list_import_sources(&workspace_id).unwrap().len(), 2);
let next = plan_import_batch_resources(
&manager,
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
inputs(),
)
.unwrap();
assert_eq!(next.items.len(), 2);
assert!(next.items.iter().all(|i| i.action == ImportPlanAction::Unchanged));
}
#[test]
fn batch_separate_collections_keep_their_workspaces_and_unique_names() {
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
let plan = plan_import_batch_resources(
&manager,
ImportDestination::NewWorkspace,
vec![
batch_input("Bruno", "/one", imported_resources()),
batch_input("Bruno", "/two", imported_resources()),
],
)
.unwrap();
assert_eq!(
plan.resources.workspaces.iter().map(|w| w.name.as_str()).collect::<Vec<_>>(),
vec!["Imported", "Imported (2)"]
);
let committed = commit_import_plan(&manager, plan).unwrap();
for workspace in committed.workspaces {
assert_eq!(manager.connect().list_http_requests(&workspace.id).unwrap().len(), 2);
assert_eq!(manager.connect().list_import_sources(&workspace.id).unwrap().len(), 1);
}
}
#[test]
fn unique_name_continues_an_existing_suffix() {
let taken = vec!["Imported".to_string(), "Imported (2)".to_string()];
assert_eq!(unique_name("Imported", &taken), "Imported (3)");
assert_eq!(unique_name("Imported (2)", &taken), "Imported (3)");
assert_eq!(unique_name("Other (2)", &taken), "Other (2)");
let taken = vec!["Release (2024)".to_string()];
assert_eq!(unique_name("Release (2024)", &taken), "Release (2024) (2)");
}
#[test]
fn batch_rejects_two_versions_of_the_same_linked_collection() {
let (manager, _blobs, _rx) = yaak_models::init_in_memory().unwrap();
let input = || batch_input("Postman", "/collection.json", imported_resources());
let first =
plan_import_batch_resources(&manager, ImportDestination::NewWorkspace, vec![input()])
.unwrap();
let workspace_id = first.resources.workspaces[0].id.clone();
commit_import_plan(&manager, first).unwrap();
let result = plan_import_batch_resources(
&manager,
ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
vec![input(), input()],
);
assert!(result.unwrap_err().to_string().contains("More than one import source"));
}
#[test]
fn batch_commit_rolls_back_all_sources_if_linking_a_later_source_fails() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("models.sqlite");
let (manager, _blobs, _rx) =
yaak_models::init_standalone(&db_path, &dir.path().join("blobs.sqlite")).unwrap();
let plan = plan_import_batch_resources(
&manager,
ImportDestination::NewWorkspace,
collection_and_environments(),
)
.unwrap();
let workspace_id = plan.resources.workspaces[0].id.clone();
let connection = rusqlite::Connection::open(&db_path).unwrap();
connection.execute_batch("CREATE TRIGGER fail_source BEFORE INSERT ON import_sources WHEN NEW.origin = '/production.json' BEGIN SELECT RAISE(FAIL, 'forced failure'); END;").unwrap();
drop(connection);
assert!(commit_import_plan(&manager, plan).is_err());
assert!(manager.connect().list_workspaces().unwrap().is_empty());
assert!(manager.connect().list_import_sources(&workspace_id).unwrap().is_empty());
}
#[test]
fn existing_workspace_plan_does_not_mutate_and_preserves_workspace_settings() {
let (query_manager, _blob_manager, _rx) =
@@ -1795,6 +2227,66 @@ mod tests {
);
}
#[test]
fn duplicate_import_warning_requires_the_same_importer_and_origin() {
for origin in [
"/tmp/api.yaml",
"/tmp/bruno-collection",
"https://example.com/api.yaml",
] {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let original =
ImportOrigin { origin: origin.to_string(), label: "Original".to_string() };
let first = plan_import_resources(
&query_manager,
"Bruno".to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
Some(importer_keys()),
Some(original.clone()),
)
.expect("plan first import");
commit_import_plan(&query_manager, first).expect("commit first import");
// Every case has the same resource keys, but only an exact origin/importer match
// proves this source was imported before. Display labels do not establish identity.
for (importer, incoming_origin, should_warn) in [
(
"Bruno",
Some(ImportOrigin { label: "Different label".to_string(), ..original.clone() }),
true,
),
(
"Bruno",
Some(ImportOrigin { origin: format!("{origin}-other"), ..original.clone() }),
false,
),
("OpenAPI", Some(original.clone()), false),
("Bruno", None, false),
] {
let plan = plan_import_resources(
&query_manager,
importer.to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
Some(importer_keys()),
incoming_origin.clone(),
)
.expect("plan another workspace");
assert_eq!(
plan.warnings.iter().any(|w| w.title.starts_with("Already imported")),
should_warn,
"importer {importer}, origin {incoming_origin:?}",
);
assert!(
plan.items.iter().all(|i| i.action == ImportPlanAction::Create),
"a new workspace never merges into an older import"
);
}
}
}
#[test]
fn environment_collisions_are_explicit_and_do_not_overwrite() {
let (query_manager, _blob_manager, _rx) =
@@ -2839,6 +3331,108 @@ mod tests {
assert_eq!(root.resolution, Some(ImportConflictResolution::KeepMine));
}
#[test]
fn folder_environment_names_do_not_cause_reimport_updates() {
for legacy_hash in [false, true] {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut resources = imported_resources();
resources.environments.push(Environment {
id: "ev_source_folder".to_string(),
name: "Folder Environment".to_string(),
parent_model: "folder".to_string(),
parent_id: Some("fl_source".to_string()),
..resources.environments[0].clone()
});
let committed = first_import_with(&query_manager, resources.clone());
let workspace_id = &committed.workspaces[0].id;
let environment = committed
.environments
.iter()
.find(|e| e.parent_model == "folder")
.expect("folder environment");
assert_eq!(environment.name, "Imported Folder Environment");
if legacy_hash {
// Reproduce the pre-fix hash, which included the name generated on save.
let mut value = comparable(serde_json::to_value(environment).unwrap());
value["name"] = json!(environment.name);
let canonical = serde_json::to_string(&sorted_keys(value)).unwrap();
let hash = format!("v1:{:x}", Sha256::digest(canonical.as_bytes()));
let row = source_rows(&query_manager, workspace_id)
.into_iter()
.find(|r| r.model_id.as_deref() == Some(environment.id.as_str()))
.unwrap();
query_manager
.with_tx(|db| {
db.upsert_import_source_resource(&ImportSourceResource {
content_hash: Some(hash),
..row
})
})
.expect("write legacy hash");
let mut changed = resources.clone();
changed.environments[1].variables[0].value = "changed".to_string();
let plan = replan(&query_manager, workspace_id, changed);
assert_eq!(
item_by_name(&plan, "Folder Environment").action,
ImportPlanAction::Conflict,
"an old hash cannot attribute a real difference"
);
}
let plan = replan(&query_manager, workspace_id, resources.clone());
let item = item_by_name(&plan, "Folder Environment");
assert_eq!(item.action, ImportPlanAction::Unchanged);
assert!(item.changed_fields.is_empty());
commit_import_plan(&query_manager, plan).expect("record name-independent hash");
let mut changed = resources.clone();
changed.environments[1].variables[0].value = "changed".to_string();
let plan = replan(&query_manager, workspace_id, changed.clone());
let item = item_by_name(&plan, "Folder Environment");
assert_eq!(item.action, ImportPlanAction::Update);
assert_eq!(item.changed_fields, vec!["variables"]);
query_manager
.with_tx(|db| {
let mut local = db.get_environment(&environment.id)?;
local.variables[0].value = "local".to_string();
db.upsert_environment(&local, &UpdateSource::Background)
})
.expect("edit folder variables locally");
let plan = replan(&query_manager, workspace_id, resources);
assert_eq!(
item_by_name(&plan, "Folder Environment").action,
ImportPlanAction::KeepLocal
);
let plan = replan(&query_manager, workspace_id, changed);
assert_eq!(
item_by_name(&plan, "Folder Environment").action,
ImportPlanAction::Conflict
);
}
}
#[test]
fn base_and_named_environment_names_remain_import_content() {
for parent_model in ["workspace", "environment"] {
let original = Environment {
model: "environment".to_string(),
parent_model: parent_model.to_string(),
name: "Original".to_string(),
..Default::default()
};
let renamed = Environment { name: "Renamed".to_string(), ..original.clone() };
let original = comparable(serde_json::to_value(original).unwrap());
let renamed = comparable(serde_json::to_value(renamed).unwrap());
assert_eq!(changed_fields(&renamed, &original), vec!["name"]);
assert_ne!(hash_comparable(&original), hash_comparable(&renamed));
assert!(hash_comparable(&original).starts_with("v1:"));
}
}
#[test]
fn filling_in_editor_row_ids_is_not_an_edit() {
let (query_manager, _blob_manager, _rx) =
-2
View File
@@ -1,6 +1,4 @@
pub mod error;
pub mod example;
pub mod export;
pub mod import;
pub mod plugin_events;
pub mod response_body;
+199 -98
View File
@@ -33,6 +33,7 @@
"plugins/auth-oauth1",
"plugins/filter-jsonpath",
"plugins/filter-xpath",
"plugins/importer-bruno",
"plugins/importer-curl",
"plugins/importer-insomnia",
"plugins/importer-openapi",
@@ -152,9 +153,9 @@
"jotai-family": "^1.0.1",
"js-md5": "^0.9.2",
"jsonc-parser": "^3.3.1",
"lucide-react": "^0.525.0",
"lucide-react": "^1.45.0",
"mime": "^4.0.4",
"motion": "^12.4.7",
"motion": "^13.2.0",
"nanoid": "^6.0.1",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
@@ -162,7 +163,7 @@
"react-colorful": "^5.6.1",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-pdf": "^10.0.1",
"react-pdf": "^11.0.0",
"react-syntax-highlighter": "^16.1.0",
"react-use": "^17.6.1",
"rehype-stringify": "^10.0.1",
@@ -243,7 +244,7 @@
"classnames": "^2.5.1",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"motion": "^12.4.7",
"motion": "^13.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@@ -1769,9 +1770,9 @@
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz",
"integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==",
"license": "MIT",
"optional": true,
"workspaces": [
@@ -1785,23 +1786,23 @@
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
"@napi-rs/canvas-android-arm64": "1.0.9",
"@napi-rs/canvas-darwin-arm64": "1.0.9",
"@napi-rs/canvas-darwin-x64": "1.0.9",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.9",
"@napi-rs/canvas-linux-arm64-musl": "1.0.9",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.9",
"@napi-rs/canvas-linux-x64-gnu": "1.0.9",
"@napi-rs/canvas-linux-x64-musl": "1.0.9",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.9",
"@napi-rs/canvas-win32-x64-msvc": "1.0.9"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz",
"integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==",
"cpu": [
"arm64"
],
@@ -1819,9 +1820,9 @@
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz",
"integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==",
"cpu": [
"arm64"
],
@@ -1839,9 +1840,9 @@
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz",
"integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==",
"cpu": [
"x64"
],
@@ -1859,9 +1860,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz",
"integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==",
"cpu": [
"arm"
],
@@ -1879,9 +1880,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz",
"integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==",
"cpu": [
"arm64"
],
@@ -1902,9 +1903,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz",
"integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==",
"cpu": [
"arm64"
],
@@ -1925,9 +1926,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz",
"integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==",
"cpu": [
"riscv64"
],
@@ -1948,9 +1949,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz",
"integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==",
"cpu": [
"x64"
],
@@ -1971,9 +1972,9 @@
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz",
"integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==",
"cpu": [
"x64"
],
@@ -1994,9 +1995,9 @@
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz",
"integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==",
"cpu": [
"arm64"
],
@@ -2014,9 +2015,9 @@
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz",
"integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==",
"cpu": [
"x64"
],
@@ -4574,11 +4575,19 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/nanoid": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/nanoid/-/nanoid-2.1.0.tgz",
"integrity": "sha512-xdkn/oRTA0GSNPLIKZgHWqDTWZsVrieKomxJBOQUK9YDD+zfSgmwD5t4WJYra5S7XyhTw7tfvwznW+pFexaepQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/node": {
"version": "25.9.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.6.tgz",
"integrity": "sha512-JR6Q/PV5DKFvjrGFVqQJdeG0qvsqQQLDa3TzFrqVwhqRXqwNaxPo2KYCtCQXpOdIulCouKwT7a6in9nFthBAzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
@@ -4667,12 +4676,28 @@
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz",
"integrity": "sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@ungap/structured-clone": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
"license": "ISC"
},
"node_modules/@usebruno/common": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/@usebruno/common/-/common-0.25.0.tgz",
"integrity": "sha512-aTQtMuWTU3dL6brl5zwnmaoPeKStl6EK/frzwtVP4gmbqo+8Kjf7xafgYumrgOnL/lO1q34HqE00XCTEwXSmtw==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.1.tgz",
@@ -5084,6 +5109,10 @@
"resolved": "plugins-external/httpsnippet",
"link": true
},
"node_modules/@yaak/importer-bruno": {
"resolved": "plugins/importer-bruno",
"link": true
},
"node_modules/@yaak/importer-curl": {
"resolved": "plugins/importer-curl",
"link": true
@@ -5904,6 +5933,12 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/arcsecond": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/arcsecond/-/arcsecond-5.0.0.tgz",
"integrity": "sha512-J/fHdyadnsIencRsM6oUSsraCKG+Ni9Udcgr/eusxjTzX3SEQtCUQSpP0YtImFPfIK6DdT1nqwN0ng4FqNmwgA==",
"license": "MIT"
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
@@ -7385,6 +7420,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.52.0.tgz",
"integrity": "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks",
"tests/types",
"tests/browser-compat"
]
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -7829,24 +7876,20 @@
}
},
"node_modules/framer-motion": {
"version": "12.43.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.43.0.tgz",
"integrity": "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g==",
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-13.2.0.tgz",
"integrity": "sha512-9E33ebgMaO33w1nN/jEdW8z3/GO483fMi4rqbMG9rt83XgW9QLKRe4NcmJ8s+fQ3O34++UHrIQwlIWGIWTITjA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.43.0",
"motion-utils": "^12.39.0",
"motion-dom": "^13.2.0",
"motion-utils": "^13.0.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
@@ -9694,6 +9737,12 @@
"node": ">=4"
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.includes": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -9790,9 +9839,9 @@
"license": "ISC"
},
"node_modules/lucide-react": {
"version": "0.525.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz",
"integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==",
"version": "1.45.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.45.0.tgz",
"integrity": "sha512-yH1ubCAduho9UR7oJhRXIQXogksRILBiTuZC4/bQIGeB9JOkxMlSuEHyyZpo1Z3S0yWJO2KTSUZbjiNvVxeOUw==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -10963,23 +11012,19 @@
"license": "BSD-3-Clause"
},
"node_modules/motion": {
"version": "12.43.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.43.0.tgz",
"integrity": "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ==",
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/motion/-/motion-13.2.0.tgz",
"integrity": "sha512-4Hrb5vD6HhjFstLUiCmWvtpsw+WTpP4R+QXfSDYZBz7+uxE/LrRg3aV0ReJxHrTRffHhbIE6svEqnotngXvesQ==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.43.0",
"framer-motion": "^13.2.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
@@ -10989,18 +11034,18 @@
}
},
"node_modules/motion-dom": {
"version": "12.43.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.43.0.tgz",
"integrity": "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag==",
"version": "13.2.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-13.2.0.tgz",
"integrity": "sha512-N6gdSoWRDk0Rh/fVtlqUtLs+fEN3ELFZI3cn3IQE9Mnf3E+Mh8wjO6MstzCOPFh4Yf0L1as5m2eUyYWj8ylVSQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.39.0"
"motion-utils": "^13.0.0"
}
},
"node_modules/motion-utils": {
"version": "12.39.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.39.0.tgz",
"integrity": "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==",
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-13.0.0.tgz",
"integrity": "sha512-7DnN7TmbLcYXcG4RVadXIihWlyuM9afoUww8Y5Agg431kGKiuL2/OMyP4mJ5wLz+pvN3t5ySClLOaVXJ+wekRQ==",
"license": "MIT"
},
"node_modules/mrmime": {
@@ -11363,6 +11408,15 @@
"node": ">=12.20.0"
}
},
"node_modules/ohm-js": {
"version": "16.6.0",
"resolved": "https://registry.npmjs.org/ohm-js/-/ohm-js-16.6.0.tgz",
"integrity": "sha512-X9P4koSGa7swgVQ0gt71UCYtkAQGOjciJPJAz74kDxWt8nXbH5HrDOQG6qBDH7SR40ktNv4x61BwpTDE9q4lRA==",
"license": "MIT",
"engines": {
"node": ">=0.12.1"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -11700,22 +11754,21 @@
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "5.4.296",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz",
"integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==",
"version": "6.3.289",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz",
"integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.80"
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true,
"license": "MIT"
},
"node_modules/picocolors": {
@@ -12044,17 +12097,18 @@
}
},
"node_modules/react-pdf": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.5.0.tgz",
"integrity": "sha512-zGKb6Ph5AYn2OfO4TS8Gg0Go3eW5K/TKy0+0FTm8EwFNbpgbuaWBZoJdYkJt+cRBCkizE9dYvqtMtHRj522Q4w==",
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-11.0.0.tgz",
"integrity": "sha512-x4Ierg3Sl+gOoqd0pVOQndiuFKeGcXQmUNtaSH0D/cs2nUW1HBDlFBT2Z8jDsDJv0Kh//jlOJT21ERyQtlRe7A==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"dequal": "^2.0.3",
"es-toolkit": "^1.52.0",
"make-cancellable-promise": "^2.0.0",
"make-event-props": "^2.0.0",
"merge-refs": "^2.0.0",
"pdfjs-dist": "5.4.296",
"pdfjs-dist": "6.3.289",
"tiny-invariant": "^1.0.0",
"warning": "^4.0.0"
},
@@ -12062,9 +12116,9 @@
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
"@types/react": "^19.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -13655,7 +13709,6 @@
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
},
"node_modules/unicorn-magic": {
@@ -14764,9 +14817,9 @@
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz",
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
@@ -14782,7 +14835,6 @@
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
"integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"pend": "~1.2.0"
@@ -14908,11 +14960,13 @@
"packages/plugin-runtime": {
"name": "@yaakapp-internal/plugin-runtime",
"dependencies": {
"ws": "^8.21.0"
"ws": "^8.21.0",
"yauzl": "^3.4.0"
},
"devDependencies": {
"@types/node": "^24.0.13",
"@types/ws": "^8.5.13",
"@types/yauzl": "^3.4.0",
"esbuild": "^0.28.0"
}
},
@@ -15096,6 +15150,7 @@
"name": "@yaak/filter-jsonpath",
"version": "0.1.0",
"dependencies": {
"@yaakapp-internal/lib": "^1.0.0",
"jsonpath-plus": "^10.3.0"
},
"devDependencies": {
@@ -15110,6 +15165,52 @@
"xpath": "^0.0.34"
}
},
"plugins/importer-bruno": {
"name": "@yaak/importer-bruno",
"version": "0.1.0",
"dependencies": {
"@usebruno/filestore": "^0.12.0",
"yaml": "^2.8.3"
}
},
"plugins/importer-bruno/node_modules/@usebruno/filestore": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@usebruno/filestore/-/filestore-0.12.0.tgz",
"integrity": "sha512-MgVuM6H+qXxipPJ+T3SaIdWoTn5OTq5QaMQHVQZ3GUByj78GPgWiflqfQXGJTLF8AAQFNNgMkCOU/36hv1Lw6g==",
"license": "MIT",
"dependencies": {
"@types/nanoid": "^2.1.0",
"@usebruno/common": "0.25.0",
"@usebruno/lang": "0.39.0",
"ajv": "^8.17.1",
"lodash": "4.18.1",
"yaml": "^2.3.4"
}
},
"plugins/importer-bruno/node_modules/@usebruno/lang": {
"version": "0.39.0",
"resolved": "https://registry.npmjs.org/@usebruno/lang/-/lang-0.39.0.tgz",
"integrity": "sha512-D/TWoJYhXvC4CjI7OsZCm8zVRel7so9BiktMXmdpKbvF2b1bin33aO71hn9K38pypsBicl5Y8CK8RJ0QXo1qBg==",
"license": "MIT",
"dependencies": {
"arcsecond": "^5.0.0",
"dotenv": "^16.3.1",
"lodash": "4.18.1",
"ohm-js": "^16.6.0"
}
},
"plugins/importer-bruno/node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"plugins/importer-curl": {
"name": "@yaak/importer-curl",
"version": "0.1.0",
+1
View File
@@ -32,6 +32,7 @@
"plugins/auth-oauth1",
"plugins/filter-jsonpath",
"plugins/filter-xpath",
"plugins/importer-bruno",
"plugins/importer-curl",
"plugins/importer-insomnia",
"plugins/importer-openapi",
+39
View File
@@ -0,0 +1,39 @@
/** Literal steps into a JSON response, distinct from JSONPath operators. */
export type JsonPathSegment =
| { readonly kind: "key"; readonly key: string }
| { readonly kind: "index"; readonly index: number };
const PATH_SEGMENT = /^(?:\.([A-Za-z_$][A-Za-z0-9_$]*)|\[(0|[1-9]\d*)\]|\[("(?:[^"\\]|\\.)*")\])/;
/**
* Parse a single location with JSON-quoted keys (`$.a[0]["b.c"]`). Other
* JSONPath expressions belong to the filter engine, not to the breadcrumb trail.
*/
export function jsonPathToSegments(path: string): JsonPathSegment[] | null {
let rest = path.trim();
if (!rest.startsWith("$")) return null;
rest = rest.slice(1);
const segments: JsonPathSegment[] = [];
while (rest.length > 0) {
const match = PATH_SEGMENT.exec(rest);
if (match == null) return null;
const [whole, bareKey, index, quotedKey] = match;
if (bareKey != null) {
if (bareKey === "$") return null; // The library's root operator, not a literal key.
segments.push({ kind: "key", key: bareKey });
} else if (index != null) {
const value = Number(index);
if (!Number.isSafeInteger(value)) return null;
segments.push({ kind: "index", index: value });
} else if (quotedKey != null) {
try {
segments.push({ kind: "key", key: JSON.parse(quotedKey) as string });
} catch {
return null;
}
}
rest = rest.slice(whole.length);
}
return segments;
}
+4 -2
View File
@@ -231,6 +231,9 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
// which is the one thing each host answers differently.
cmd_export_data: (payload, db) => db.rpc("cmd_export_data", payload),
// The same example the desktop creates, built by the model layer in the worker.
cmd_create_example_workspace: (payload, db) => db.rpc("cmd_create_example_workspace", payload),
async cmd_get_sse_events() {
return [];
},
@@ -285,11 +288,10 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
"Importing from a file needs a filesystem, which a browser tab has no",
"localFiles",
],
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
cmd_import_sources_for_origin: ["Importing isn't available in the browser yet", null],
cmd_create_example_workspace: ["The example workspace isn't available in the browser yet", null],
cmd_detect_import_source: ["Importing isn't available in the browser yet", null],
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
cmd_format_graphql: ["Formatting GraphQL needs a plugin, which this host doesn't run", null],
@@ -0,0 +1,40 @@
# Importers
Implement **`onImportSource(ctx, { source })`**. The source is one of two things:
- `{ type: "text", name, text }`: a single UTF-8 document, or pasted text (named `input`).
- `{ type: "directory", files }`: a directory or a ZIP archive, read as one file tree.
Return `null` or `undefined` when the source isn't your format. Only one importer's result is used per source.
```ts
import type { PluginDefinition } from "@yaakapp/api";
export const plugin: PluginDefinition = {
importer: {
name: "Example",
async onImportSource(ctx, { source }) {
if (source.type === "text") return convertDocument(source.text);
for (const entry of await source.files.readDir()) {
if (entry.name === "example.json") {
return convertDocument(await source.files.readTextFile(entry.path));
}
}
return null;
},
},
};
```
The older `onImport(ctx, { text })` hook still works but is deprecated. It only receives single text documents and is ignored when `onImportSource` is defined.
## The file tree
- `files.kind` is `"directory"` or `"zip"`; `files.name` is the selected basename.
- `readDir(path = "")` lists immediate children. `readFile(path)` returns bytes. `readTextFile(path)` decodes UTF-8 strictly.
- Paths use `/` and are relative to the root. Absolute paths, `..`, and backslashes are rejected. Symbolic links are skipped in listings and rejected on read. Nothing is extracted to disk.
- ZIPs are detected by signature, not extension. A ZIP made by compressing a folder is rooted at that folder, so plugins see the same tree as for the directory. Directory reads are not a snapshot: a symlink swapped in under a validated path can redirect a read outside the root, so treat directory imports as trusted input.
- Limits: 64 MiB per source file or download, 32 MiB per file read, 10,000 entries, 256 MiB declared ZIP expansion. Encrypted ZIPs are unsupported.
- The tree is read-only and only valid until the hook settles.
Plugins run with the normal Node.js permissions of the plugin runtime; the tree scopes convenience, not security.
+10 -1
View File
@@ -470,7 +470,16 @@ export type HttpRequestAction = { label: string, icon?: Icon, };
export type Icon = "alert_triangle" | "check" | "check_circle" | "chevron_down" | "copy" | "info" | "pin" | "search" | "trash" | "_unknown";
export type ImportRequest = { content: string, };
/**
* Transport descriptor; plugins see a scoped ImportFiles API, not this representation.
*/
export type ImportFileSource = { "type": "file", name: string, base64: string, } | { "type": "directory", path: string, };
export type ImportRequest = {
/**
* Kept for text-only importers and older runtimes.
*/
content: string, source?: ImportFileSource, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
+7 -1
View File
@@ -6,4 +6,10 @@ export * from "./bindings/gen_events";
// Some extras for utility
export type { PartialImportResources } from "./plugins/ImporterPlugin";
export type {
PartialImportResources,
ImportFiles,
ImportFileEntry,
ImportSource,
ImportSourceHandler,
} from "./plugins/ImporterPlugin";
@@ -21,11 +21,45 @@ export type ImportPluginResponse =
resources: PartialImportResources;
});
/** A read-only view of a selected directory or ZIP archive. Paths are relative and use
* forward slashes. Directory path checks are best-effort under concurrent filesystem
* changes, not a security boundary. Valid only while onImportSource runs. */
export interface ImportFiles {
readonly name: string;
readonly kind: "zip" | "directory";
/** List immediate children. The root directory is "" (the default). */
readDir(path?: string): Promise<ImportFileEntry[]>;
readFile(path: string): Promise<Uint8Array>;
/** Decode UTF-8 strictly; binary or invalid UTF-8 content throws. */
readTextFile(path: string): Promise<string>;
}
export interface ImportFileEntry {
name: string;
path: string;
type: "file" | "directory";
}
/** What the user chose to import: one text document, or a directory or ZIP read as a tree. */
export type ImportSource =
| { type: "text"; name: string; text: string }
| { type: "directory"; files: ImportFiles };
type ImportTextHandler = (
ctx: Context,
args: { text: string },
) => MaybePromise<ImportPluginResponse | undefined>;
export type ImportSourceHandler = (
ctx: Context,
args: { source: ImportSource },
) => MaybePromise<ImportPluginResponse | undefined>;
export type ImporterPlugin = {
name: string;
description?: string;
onImport(
ctx: Context,
args: { text: string },
): MaybePromise<ImportPluginResponse | null | undefined>;
/** Return null when the source isn't this importer's format. Takes precedence over onImport. */
onImportSource?: ImportSourceHandler;
/** @deprecated Use onImportSource. Only receives single text documents. */
onImport?: ImportTextHandler;
};
+3 -1
View File
@@ -6,11 +6,13 @@
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
},
"dependencies": {
"ws": "^8.21.0"
"ws": "^8.21.0",
"yauzl": "^3.4.0"
},
"devDependencies": {
"@types/node": "^24.0.13",
"@types/ws": "^8.5.13",
"@types/yauzl": "^3.4.0",
"esbuild": "^0.28.0"
}
}
+14 -13
View File
@@ -51,6 +51,7 @@ import type {
} from "@yaakapp-internal/plugins";
import { applyDynamicFormInput } from "./common";
import { EventChannel } from "./EventChannel";
import { runImporter } from "./importFiles";
import { migrateTemplateFunctionSelectOptions } from "./migrations";
import { createResponseBody, decodeBase64Chunk } from "./responseBody";
@@ -157,19 +158,14 @@ export class PluginInstance {
return;
}
if (
payload.type === "import_request" &&
typeof this.#mod?.importer?.onImport === "function"
) {
const reply = await this.#mod.importer.onImport(ctx, {
text: payload.content,
});
if (payload.type === "import_request" && this.#mod?.importer != null) {
const reply = await runImporter(this.#mod.importer, ctx, payload);
if (reply != null) {
const replyPayload: InternalEventPayload = {
type: "import_response",
importer: this.#mod.importer.name,
resources: reply.resources as ImportResources,
sourceKeys: reply.sourceKeys ?? null,
sourceKeys: reply.sourceKeys,
};
this.#sendPayload(context, replyPayload, replyId);
return;
@@ -649,10 +645,12 @@ export class PluginInstance {
complete: info.complete,
},
async (offset, length) => {
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(
context,
{ type: "read_http_response_body_chunk_request", responseId, offset, length },
);
const chunk = await this.#sendForReply<ReadHttpResponseBodyChunkResponse>(context, {
type: "read_http_response_body_chunk_request",
responseId,
offset,
length,
});
return decodeBase64Chunk(chunk.data);
},
{ refresh: bodyInfo },
@@ -853,7 +851,10 @@ export class PluginInstance {
// carries the only copy of its body. A saved one is read back from
// the host like any other. Callers get the same thing either way.
if (body == null) {
return { httpResponse: forPlugin(httpResponse), body: await storedBody(httpResponse.id) };
return {
httpResponse: forPlugin(httpResponse),
body: await storedBody(httpResponse.id),
};
}
const bytes = decodeBase64Chunk(body);
Binary file not shown.
@@ -0,0 +1,224 @@
import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Context, ImportRequest, ImportSource } from "@yaakapp/api";
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
import { createImportFiles, runImporter } from "./importFiles";
const dirs: string[] = [];
beforeEach(() => {
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(async () => {
vi.restoreAllMocks();
await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
const input = (name: string, data: Uint8Array): ImportRequest => ({
content: "",
source: { type: "file", name, base64: Buffer.from(data).toString("base64") },
});
async function zip(name = "import-files.zip") {
return input(name, await readFile(new URL(`./fixtures/${name}`, import.meta.url)));
}
const ctx = {} as Context;
describe("import file tree", () => {
test("exposes legacy text and single binary files through the same read API", async () => {
const session = await createImportFiles({ content: "café" });
expect(session.text).toBe("café");
expect(await session.files.readDir()).toEqual([{ name: "input", path: "input", type: "file" }]);
expect(await session.files.readTextFile("input")).toBe("café");
session.close();
await expect(session.files.readFile("input")).rejects.toThrow("closed");
const binary = await createImportFiles(input("body.bin", Uint8Array.from([255, 0, 128])));
expect(binary.isText).toBe(false);
expect([...(await binary.files.readFile("body.bin"))]).toEqual([255, 0, 128]);
await expect(binary.files.readTextFile("body.bin")).rejects.toThrow();
binary.close();
});
test("lists immediate ZIP children, including implicit/empty directories, and reads binary/UTF-8", async () => {
const session = await createImportFiles(await zip());
expect(session.files.kind).toBe("zip");
expect(session.text).toBe("");
expect(await session.files.readDir()).toEqual([
{ name: "binary.bin", path: "binary.bin", type: "file" },
{ name: "collection", path: "collection", type: "directory" },
]);
expect((await session.files.readDir("collection")).map((e) => e.name)).toEqual([
"café.yml",
"empty",
"nested",
]);
expect(await session.files.readDir("collection/empty")).toEqual([]);
expect(await session.files.readTextFile("collection/café.yml")).toBe("name: café\n");
expect([...(await session.files.readFile("binary.bin"))]).toEqual([0, 255, 128]);
await expect(session.files.readFile("collection")).rejects.toThrow("not found");
await expect(session.files.readDir("missing")).rejects.toThrow("not found");
session.close();
});
test("a misleading ZIP suffix still dispatches as text", async () => {
const content = '{"openapi":"3.0.0","info":{"title":"Example","version":"1"},"paths":{}}';
const source = input("api.zip", Buffer.from(content));
const onImport = vi.fn().mockResolvedValue(null);
await runImporter({ name: "Text", onImport }, ctx, source);
expect(onImport).toHaveBeenCalledExactlyOnceWith(ctx, { text: content });
const onImportSource = vi.fn().mockResolvedValue(null);
await runImporter({ name: "Source", onImportSource }, ctx, source);
expect(onImportSource).toHaveBeenCalledExactlyOnceWith(ctx, {
source: { type: "text", name: "api.zip", text: content },
});
});
test("ZIP signatures take precedence over names, including empty archives", async () => {
const bytes = await readFile(new URL("./fixtures/import-files.zip", import.meta.url));
const empty = Buffer.alloc(22);
empty.writeUInt32LE(0x06054b50);
for (const data of [bytes, empty]) {
const source = input("collection.yaml", data);
const session = await createImportFiles(source);
try {
expect(session.files.kind).toBe("zip");
if (data === empty) expect(await session.files.readDir()).toEqual([]);
else expect(await session.files.readTextFile("collection/café.yml")).toBe("name: café\n");
} finally {
session.close();
}
const onImport = vi.fn().mockResolvedValue(null);
expect(await runImporter({ name: "Text", onImport }, ctx, source)).toBeNull();
expect(onImport).not.toHaveBeenCalled();
}
});
test("a ZIP made by compressing a folder is rooted at that folder", async () => {
const session = await createImportFiles(await zip("wrapped.zip"));
try {
expect((await session.files.readDir()).map((e) => e.name)).toEqual(["nested", "root.txt"]);
expect(await session.files.readTextFile("nested/file.txt")).toBe("inner\n");
await expect(session.files.readDir("Demo")).rejects.toThrow("not found");
} finally {
session.close();
}
});
test("malformed input with a ZIP signature still fails archive validation", async () => {
const source = input("broken.zip", Buffer.from([0x50, 0x4b, 0x03, 0x04]));
await expect(createImportFiles(source)).rejects.toThrow();
});
test("directory view reads lazily and blocks links and escaping paths", async () => {
const root = await mkdtemp(path.join(tmpdir(), "yaak-import-"));
dirs.push(root);
await mkdir(path.join(root, "nested"));
await writeFile(path.join(root, "nested", "file"), "first");
await symlink(tmpdir(), path.join(root, "escape"));
const session = await createImportFiles({
content: "",
source: { type: "directory", path: root },
});
expect(await session.files.readDir()).toEqual([
{ name: "nested", path: "nested", type: "directory" },
]);
await writeFile(path.join(root, "nested", "file"), "updated");
expect(await session.files.readTextFile("nested/file")).toBe("updated");
await expect(session.files.readDir("escape")).rejects.toThrow("symbolic");
for (const p of [
"../outside",
"/absolute",
"C:/outside",
"nested/../../outside",
"nested\\file",
]) {
await expect(session.files.readFile(p)).rejects.toThrow();
}
session.close();
});
test.each(["import-traversal.zip", "import-duplicate.zip", "import-symlink.zip"])(
"rejects unsafe ZIP %s",
async (name) => {
await expect(createImportFiles(await zip(name))).rejects.toThrow();
},
);
test("ZIP entries are decompressed only on read, with a read-size limit", async () => {
const session = await createImportFiles(await zip("import-large.zip"));
expect((await session.files.readDir())[0]?.name).toBe("large.txt");
await expect(session.files.readFile("large.txt")).rejects.toThrow("32 MiB");
session.close();
});
test("the deprecated text hook preserves its arguments and skips other input", async () => {
const onImport = vi.fn().mockResolvedValue(null);
const importer = { name: "Legacy", onImport };
await runImporter(importer, ctx, { content: "original" });
expect(onImport).toHaveBeenCalledExactlyOnceWith(ctx, { text: "original" });
onImport.mockClear();
await runImporter(importer, ctx, input("empty.txt", new Uint8Array()));
expect(onImport).toHaveBeenCalledExactlyOnceWith(ctx, { text: "" });
onImport.mockClear();
for (const source of [
await zip(),
input("binary", Uint8Array.from([255])),
{
content: "",
source: { type: "directory", path: "/does-not-exist" },
} satisfies ImportRequest,
]) {
expect(await runImporter(importer, ctx, source)).toBeNull();
}
expect(onImport).not.toHaveBeenCalled();
});
test("the source hook gets text for documents and a tree for directories and ZIPs", async () => {
const onImport = vi.fn().mockResolvedValue(null);
const onImportSource = vi.fn().mockResolvedValue(null);
const importer = { name: "Both", onImport, onImportSource };
const root = await mkdtemp(path.join(tmpdir(), "yaak-import-hooks-"));
dirs.push(root);
const cases: [ImportRequest, ImportSource["type"] | null, string?][] = [
[{ content: "pasted" }, "text", "pasted"],
[input("text.txt", Buffer.from("text")), "text", "text"],
[input("empty.txt", new Uint8Array()), "text", ""],
[input("binary", Uint8Array.from([255])), null],
[await zip(), "directory"],
[{ content: "", source: { type: "directory", path: root } }, "directory"],
];
for (const [request, type, text] of cases) {
onImportSource.mockClear();
expect(await runImporter(importer, ctx, request)).toBeNull();
if (type == null) {
expect(onImportSource).not.toHaveBeenCalled();
continue;
}
expect(onImportSource).toHaveBeenCalledOnce();
const { source } = onImportSource.mock.calls[0]![1] as { source: ImportSource };
expect(source.type).toBe(type);
if (source.type === "text") expect(source.text).toBe(text);
else await expect(source.files.readDir()).rejects.toThrow("closed");
}
onImportSource.mockResolvedValue(undefined);
await runImporter(importer, ctx, { content: "pasted" });
expect(onImport).not.toHaveBeenCalled();
expect(console.warn).not.toHaveBeenCalled();
});
test("closes file access even if the importer throws", async () => {
let saved: import("@yaakapp/api").ImportFiles | undefined;
await expect(
runImporter(
{
name: "Fails",
onImportSource(_ctx, { source }) {
if (source.type === "directory") saved = source.files;
throw new Error("failure");
},
},
ctx,
await zip(),
),
).rejects.toThrow("failure");
await expect(saved!.readDir()).rejects.toThrow("closed");
});
});
+326
View File
@@ -0,0 +1,326 @@
import { constants } from "node:fs";
import { lstat, open, opendir, realpath } from "node:fs/promises";
import path from "node:path";
import type { Context, ImportFileEntry, ImportFiles, ImportRequest } from "@yaakapp/api";
import type { ImporterPlugin, ImportSource } from "@yaakapp/api/lib/plugins/ImporterPlugin";
import { type Entry, fromBuffer, type ZipFile } from "yauzl";
const MAX_FILE_BYTES = 32 * 1024 * 1024;
const MAX_SOURCE_BYTES = 64 * 1024 * 1024;
const MAX_ENTRIES = 10_000;
const MAX_EXPANDED_BYTES = 256 * 1024 * 1024;
function relativePath(value: string): string {
if (
value.includes("\\") ||
value.includes("\0") ||
value.startsWith("/") ||
/^[a-z]:/i.test(value)
) {
throw new Error(`Invalid import path: ${value}`);
}
const parts = value.split("/");
if (parts.includes("..")) throw new Error("Import paths cannot leave the selected source");
return parts.filter((p) => p !== "" && p !== ".").join("/");
}
function isZip(bytes: Buffer): boolean {
// Names are hints only: a text specification can legitimately be named *.zip.
return (
bytes[0] === 0x50 &&
bytes[1] === 0x4b &&
((bytes[2] === 3 && bytes[3] === 4) ||
(bytes[2] === 5 && bytes[3] === 6) ||
(bytes[2] === 7 && bytes[3] === 8))
);
}
export interface ImportFileSession {
files: ImportFiles;
kind: "file" | ImportFiles["kind"];
name: string;
text: string;
/** Whether text-only plugins can handle this input, including an empty text file. */
isText: boolean;
close(): void;
}
/** Construct one scoped view for one invocation. No ZIP entries are extracted to disk. */
export async function createImportFiles(input: ImportRequest): Promise<ImportFileSession> {
let closed = false;
let zip: ZipFile | undefined;
let zipError: Error | undefined;
const assertOpen = () => {
if (closed) throw new Error("Import source is closed");
if (zipError) throw zipError;
};
let kind: ImportFileSession["kind"] = "file";
let name = "input";
let text = input.content;
let isText = true;
let readDir: ImportFiles["readDir"];
let readFile: ImportFiles["readFile"];
if (input.source?.type === "directory") {
kind = "directory";
isText = false;
text = "";
const root = await realpath(input.source.path);
name = path.basename(root);
const resolve = async (value: string) => {
assertOpen();
const rel = relativePath(value);
let candidate = root;
// Reject links visible during validation, including intermediate components.
// These path checks are not atomic with open/opendir: concurrent filesystem
// changes can redirect a later operation. This is not a confinement boundary.
for (const component of rel.split("/").filter(Boolean)) {
candidate = path.join(candidate, component);
if ((await lstat(candidate)).isSymbolicLink())
throw new Error("Import sources do not follow symbolic links");
}
const canonical = await realpath(candidate);
const within = path.relative(root, canonical);
if (within === ".." || within.startsWith(`..${path.sep}`) || path.isAbsolute(within))
throw new Error("Import path is outside the selected source");
return canonical;
};
readDir = async (value = "") => {
const rel = relativePath(value);
const entries: ImportFileEntry[] = [];
for await (const entry of await opendir(await resolve(rel))) {
if (!entry.isFile() && !entry.isDirectory()) continue;
entries.push({
name: entry.name,
path: rel ? `${rel}/${entry.name}` : entry.name,
type: entry.isDirectory() ? "directory" : "file",
});
if (entries.length > MAX_ENTRIES)
throw new Error("Import directory contains too many entries");
}
assertOpen();
return entries.sort((a, b) => a.path.localeCompare(b.path));
};
readFile = async (value) => {
const handle = await open(
await resolve(value),
constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0),
);
try {
const stat = await handle.stat();
if (!stat.isFile()) throw new Error("Import path is not a regular file");
if (stat.size > MAX_FILE_BYTES)
throw new Error("Import file exceeds the 32 MiB read limit");
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of handle.createReadStream({ autoClose: false })) {
const bytes = Buffer.from(chunk as Uint8Array);
total += bytes.length;
if (total > MAX_FILE_BYTES) throw new Error("Import file exceeds the 32 MiB read limit");
chunks.push(bytes);
}
assertOpen();
return Buffer.concat(chunks);
} finally {
await handle.close();
}
};
} else {
const source = input.source;
if (source && source.base64.length > Math.ceil(MAX_SOURCE_BYTES / 3) * 4)
throw new Error("Import source exceeds the 64 MiB limit");
const bytes = source ? Buffer.from(source.base64, "base64") : Buffer.from(input.content);
name = source?.name || "input";
if (bytes.length > MAX_SOURCE_BYTES) throw new Error("Import source exceeds the 64 MiB limit");
const entries = new Map<string, { type: "file" | "directory"; entry?: Entry; data?: Buffer }>([
["", { type: "directory" }],
]);
if (source && isZip(bytes)) {
kind = "zip";
text = "";
isText = false;
zip = await new Promise<ZipFile>((resolve, reject) => {
fromBuffer(
bytes,
{ lazyEntries: true, autoClose: false, strictFileNames: false, validateEntrySizes: true },
(err, result) => (err ? reject(err) : resolve(result)),
);
});
zip.on("error", (err: Error) => {
zipError = err;
});
const archive = zip;
try {
await new Promise<void>((resolve, reject) => {
let count = 0;
let expandedBytes = 0;
archive.once("error", reject);
archive.once("end", () => {
archive.removeListener("error", reject);
resolve();
});
archive.on("entry", (entry: Entry) => {
try {
if (++count > MAX_ENTRIES) throw new Error("ZIP contains too many entries");
expandedBytes += entry.uncompressedSize;
if (expandedBytes > MAX_EXPANDED_BYTES)
throw new Error("ZIP expanded size exceeds the 256 MiB limit");
if (entry.isEncrypted()) throw new Error("Encrypted ZIP entries are not supported");
const mode = (entry.externalFileAttributes >>> 16) & 0xf000;
if (mode === 0xa000) throw new Error("ZIP symbolic links are not supported");
const entryPath = relativePath(entry.fileName);
if (!entryPath) throw new Error("ZIP contains an empty entry path");
const type = entry.fileName.endsWith("/") ? "directory" : "file";
const previous = entries.get(entryPath);
if (previous && (type !== "directory" || previous.type !== "directory"))
throw new Error(`Duplicate ZIP path: ${entryPath}`);
entries.set(entryPath, { type, entry });
let parent = path.posix.dirname(entryPath);
while (parent !== ".") {
if (entries.get(parent)?.type === "file")
throw new Error(`ZIP file/directory conflict: ${parent}`);
if (!entries.has(parent)) entries.set(parent, { type: "directory" });
parent = path.posix.dirname(parent);
}
if (entries.size > MAX_ENTRIES) throw new Error("ZIP contains too many paths");
archive.readEntry();
} catch (err) {
reject(err);
}
});
archive.readEntry();
});
} catch (err) {
archive.close();
throw err;
}
unwrapSingleRoot(entries);
} else {
const entryPath = relativePath(name);
if (!entryPath || entryPath.includes("/"))
throw new Error("Single import files must have a basename");
entries.set(entryPath, { type: "file", data: bytes });
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
text = "";
isText = false;
}
}
readDir = async (value = "") => {
assertOpen();
const rel = relativePath(value);
if (entries.get(rel)?.type !== "directory")
throw new Error(`Import directory not found: ${rel}`);
return [...entries]
.filter(
([p]) => p !== "" && (path.posix.dirname(p) === "." ? "" : path.posix.dirname(p)) === rel,
)
.map(([p, entry]) => ({ name: path.posix.basename(p), path: p, type: entry.type }))
.sort((a, b) => a.path.localeCompare(b.path));
};
readFile = async (value) => {
assertOpen();
const found = entries.get(relativePath(value));
if (!found || found.type !== "file") throw new Error(`Import file not found: ${value}`);
if (found.data) {
if (found.data.length > MAX_FILE_BYTES)
throw new Error("Import file exceeds the 32 MiB read limit");
return Buffer.from(found.data);
}
const entry = found.entry!;
if (entry.uncompressedSize > MAX_FILE_BYTES)
throw new Error("Import file exceeds the 32 MiB read limit");
const stream = await new Promise<import("node:stream").Readable>((resolve, reject) => {
zip!.openReadStream(entry, (err, result) => (err ? reject(err) : resolve(result)));
});
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of stream) {
const bytes = Buffer.from(chunk as Uint8Array);
total += bytes.length;
if (total > MAX_FILE_BYTES) {
stream.destroy();
throw new Error("Import file exceeds the 32 MiB read limit");
}
chunks.push(bytes);
}
assertOpen();
return Buffer.concat(chunks);
};
}
return {
text,
isText,
kind,
name,
files: {
// A lone file is only ever handed to plugins as text, never as a tree
kind: kind === "file" ? "directory" : kind,
name,
readDir,
readFile,
async readTextFile(value) {
return new TextDecoder("utf-8", { fatal: true }).decode(await readFile(value));
},
},
close() {
closed = true;
zip?.close();
},
};
}
/**
* Compressing a folder puts everything under that folder inside the archive. Present the
* folder's contents as the root so plugins see the same tree they would for the directory.
*/
function unwrapSingleRoot<T extends { type: "file" | "directory" }>(entries: Map<string, T>) {
const tops = new Set<string>();
for (const entryPath of entries.keys()) {
if (entryPath === "") continue;
const top = entryPath.split("/")[0]!;
if (top === "__MACOSX" || top.startsWith(".")) continue;
tops.add(top);
}
if (tops.size !== 1) return;
const [root] = tops;
if (entries.get(root!)?.type !== "directory") return;
const prefix = `${root}/`;
const snapshot = Array.from(entries);
for (const [entryPath, value] of snapshot) {
if (entryPath === "") continue;
entries.delete(entryPath);
if (entryPath === root) continue;
if (entryPath.startsWith(prefix)) entries.set(entryPath.slice(prefix.length), value);
}
}
/** Keep legacy text plugins out of directories, archives, and arbitrary binary inputs. */
export async function runImporter(importer: ImporterPlugin, ctx: Context, input: ImportRequest) {
if (!importer.onImportSource && input.source?.type === "directory") return null;
// Avoid opening archives once for every legacy importer.
if (!importer.onImportSource && input.source?.type === "file") {
const bytes = Buffer.from(input.source.base64, "base64");
if (isZip(bytes)) return null;
}
const session = await createImportFiles(input);
try {
if (importer.onImportSource) {
const source: ImportSource | null =
session.kind === "file"
? session.isText
? { type: "text", name: session.name, text: session.text }
: null
: { type: "directory", files: session.files };
if (source == null) return null;
return await importer.onImportSource(ctx, { source });
}
if (session.isText && importer.onImport) {
return await importer.onImport(ctx, { text: session.text });
}
return null;
} finally {
session.close();
}
}
+2
View File
@@ -17,6 +17,8 @@ export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onC
size?: ButtonSize;
justify?: "start" | "center";
type?: "button" | "submit";
/** Submit a form the button is not inside, by that form's id */
form?: string;
forDropdown?: boolean;
disabled?: boolean;
title?: string;
+3 -1
View File
@@ -11,9 +11,11 @@
},
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev"
"dev": "yaakcli dev",
"test": "vp test --run tests"
},
"dependencies": {
"@yaakapp-internal/lib": "^1.0.0",
"jsonpath-plus": "^10.3.0"
},
"devDependencies": {
+19 -1
View File
@@ -1,4 +1,5 @@
import type { PluginDefinition } from "@yaakapp/api";
import { jsonPathToSegments } from "@yaakapp-internal/lib/jsonPath";
import { JSONPath } from "jsonpath-plus";
export const plugin: PluginDefinition = {
@@ -8,7 +9,24 @@ export const plugin: PluginDefinition = {
onFilter(_ctx, args) {
const parsed = JSON.parse(args.payload);
try {
const filtered = JSONPath({ path: args.filter, json: parsed });
// jsonpath-plus doesn't decode JSON-quoted keys reliably and can treat
// their contents as operators. Only complete literal paths take this
// route; all other expressions retain the library's existing behavior.
const segments = args.filter.includes('["') ? jsonPathToSegments(args.filter) : null;
let filtered: unknown;
if (segments != null) {
let value: unknown = parsed;
for (const segment of segments) {
const key = segment.kind === "key" ? segment.key : segment.index;
if (value == null || !Object.prototype.hasOwnProperty.call(value, key)) {
return { content: "[]" };
}
value = (value as Record<string, unknown>)[key];
}
filtered = [value];
} else {
filtered = JSONPath({ path: args.filter, json: parsed });
}
return { content: JSON.stringify(filtered, null, 2) };
} catch (err) {
return {
@@ -0,0 +1,130 @@
import { forceParsing } from "@codemirror/language";
import { EditorState } from "@codemirror/state";
import { jsonc } from "@shopify/lang-jsonc";
import { JSONPath } from "jsonpath-plus";
import { describe, expect, test } from "vite-plus/test";
import {
jsonPathSegmentsAt,
jsonPathToSegments,
segmentsToJsonPath,
} from "../../../apps/yaak-client/components/core/Editor/json/jsonPath";
import { plugin } from "../src";
async function filter(json: unknown, path: string) {
const result = await plugin.filter!.onFilter({} as never, {
payload: JSON.stringify(json),
filter: path,
mimeType: "application/json",
});
expect(result.error).toBeUndefined();
return JSON.parse(result.content);
}
/** Exercise the caret path and expression the breadcrumb button actually sends. */
function breadcrumb(json: unknown) {
const doc = JSON.stringify(json, null, 2);
const state = EditorState.create({ doc, extensions: [jsonc()] });
forceParsing({ state } as never, doc.length, 5000);
const segments = jsonPathSegmentsAt(state, doc.indexOf('"needle-value"') + 4);
expect(segments).not.toBeNull();
const path = segmentsToJsonPath(segments ?? []);
expect(jsonPathToSegments(path)).toEqual(segments);
return path;
}
describe("breadcrumb paths through the real filter plugin", () => {
test.each([
"identifier",
"a.b",
"space key",
"",
'quote"key',
"single'key",
"back\\slash",
"bracket]key",
"combined\"\\]'key",
"*",
"..",
"$",
"^",
"~",
"a,b",
"0:2",
"?(true)",
"(@.length-1)",
"@number()",
"`key",
"x)]y",
"x)']y",
"semi;colon",
"percent%@%key",
"line\nbreak\t\u0000",
"emoji 🤔",
"lone surrogate \ud800",
"__proto__",
"constructor",
])("selects the literal key %j, not an operator or expression", async (key) => {
const json = Object.fromEntries([
[key, "needle-value"],
["decoy", "wrong-value"],
]);
expect(await filter(json, breadcrumb(json))).toEqual(["needle-value"]);
});
test("nested unusual keys and arrays round-trip and filter at every crumb", async () => {
const inner = Object.fromEntries([["inner'\\]", { "": "needle-value" }]]);
const json = { outer: Object.fromEntries([['quote"key', ["decoy", inner]]]) };
const path = breadcrumb(json);
const segments = jsonPathToSegments(path)!;
expect(await filter(json, path)).toEqual(["needle-value"]);
expect(await filter(json, segmentsToJsonPath(segments, 4))).toEqual([{ "": "needle-value" }]);
expect(await filter(json, segmentsToJsonPath(segments, 3))).toEqual([inner]);
expect(await filter(json, segmentsToJsonPath(segments, 2))).toEqual([["decoy", inner]]);
expect(await filter(json, segmentsToJsonPath(segments, 1))).toEqual([json.outer]);
expect(await filter(json, segmentsToJsonPath(segments, 0))).toEqual([json]);
});
test("root arrays and quoted numeric object keys remain distinct", async () => {
const json = ["decoy", { "01": { "1": "needle-value" } }];
expect(await filter(json, breadcrumb(json))).toEqual(["needle-value"]);
expect(await filter(json, '$[1]["01"]["missing"]')).toEqual([]);
});
test.each([null, false, 0, "", [1, 2], { value: 3 }])(
"retains the matched value %j",
async (value) => {
expect(await filter({ 'quote"key': value }, '$["quote\\\"key"]')).toEqual([value]);
},
);
test("only follows own properties, while allowing actual prototype-named JSON keys", async () => {
const json = JSON.parse('{"a.b":{"__proto__":{"constructor":"own-value"}}}');
expect(await filter(json, '$["a.b"].__proto__.constructor')).toEqual(["own-value"]);
expect(await filter(json, '$["a.b"].constructor')).toEqual([]);
expect(await filter(json, '$["a.b"].__proto__.toString')).toEqual([]);
expect(await filter(json, '$["a.b"].__proto__.constructor.__proto__')).toEqual([]);
});
});
describe("existing JSONPath expressions", () => {
const json = { items: [{ id: 1 }, { id: 2 }, { id: 3 }], "space key": 4 };
test.each([
["$", [json]],
["$.items[1].id", [2]],
['$["space key"]', [4]],
["$['space key']", [4]],
["$.items[*].id", [1, 2, 3]],
["$..id", [1, 2, 3]],
["$.items[0:2].id", [1, 2]],
["$.items[0,2].id", [1, 3]],
["$.items[?(@.id > 1)].id", [2, 3]],
['$.items[?(@["id"] > 1)].id', [2, 3]],
["$.items[(@.length-1)].id", [3]],
['$["items"][*].id', [1, 2, 3]],
['$["items"].$', [json.items]],
["$.missing", []],
])("preserves %s", async (path, expected) => {
expect(await filter(json, path as string)).toEqual(expected);
expect(await filter(json, path as string)).toEqual(JSONPath({ path: path as string, json }));
});
});
+27
View File
@@ -0,0 +1,27 @@
# Bruno importer
Import Bruno's **Single File (YAML)** or **Bruno Collection (ZIP)** exports, or select a collection directory. Both OpenCollection `1.0.0` YAML collections and legacy `.bru` collections are supported. Individual `.bru` request files also work. A directory/ZIP must contain one collection root (`opencollection.yml`, `opencollection.yaml`, or `bruno.json`); a wrapper directory in the ZIP is allowed. Use **Choose folder** for an on-disk collection, rather than selecting its configuration file alone.
The importer consumes the shared [file-tree API](../../packages/plugin-runtime-types/IMPORTERS.md). It skips `.git`, `node_modules`, `__MACOSX`, and hidden entries. It does not install dependencies, load dotenv files, or run collection scripts.
Supported:
- Nested folders, ordering, descriptions, collection/folder headers, and variables.
- Environments, disabled variables, typed values, and secret placeholders. Environment inheritance is flattened into each selectable environment because Yaak does not have nested selectable environments.
- HTTP URLs, query/path parameters, headers, raw bodies, URL-encoded forms, multipart files, and selected binary files.
- GraphQL query and variables in Yaak's sendable body shape.
- gRPC URL, service/method, metadata, and selected message; WebSocket URL, headers, and selected message.
- Explicit auth inheritance, no auth, basic, bearer, digest, NTLM, API key, AWS credentials, common OAuth 1.0 header signing, and OAuth 2.0 flows with header tokens.
- Plain `{{variable}}` references converted to Yaak templates. Expressions, dynamic variables, and `process.env` references stay unchanged.
Scripts, assertions, actions, request-scoped variables, examples, advanced settings, and unsupported authentication configurations are retained in resource descriptions for manual migration, not executed. Alternative bodies/messages use the selected variant (or the first when unspecified); retained source variants remain in descriptions. File paths are preserved without reading files; relative paths and gRPC proto files may need to be configured after import. External secret providers, certificates, proxies, OAuth token caches, and WebSocket binary messages are not activated by the importer.
OpenCollection does not export persistent resource IDs, so Yaak derives the import source keys using its existing fallback matching.
Implementation references:
- [OpenCollection types and schema](https://github.com/opencollection-dev/opencollection)
- [Bruno's bundled export converter](https://github.com/usebruno/bruno/blob/main/packages/bruno-converters/src/opencollection/bruno-to-opencollection.ts)
- [Bruno's Share exporter](https://github.com/usebruno/bruno/blob/main/packages/bruno-app/src/utils/exporters/opencollection.js)
Legacy `.bru` parsing uses Bruno's official `@usebruno/filestore` (backed by `@usebruno/lang`). Its YAML serializers normalize parsed collections to OpenCollection before they enter the same Yaak conversion path. YAML collections are assembled directly to preserve their fields.
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@yaak/importer-bruno",
"displayName": "Bruno Importer",
"version": "0.1.0",
"private": true,
"description": "Import Bruno collections",
"scripts": {
"build": "yaakcli build",
"dev": "yaakcli dev",
"test": "vp test --run tests"
},
"dependencies": {
"@usebruno/filestore": "^0.12.0",
"yaml": "^2.8.3"
}
}
+118
View File
@@ -0,0 +1,118 @@
import type { HttpRequest } from "@yaakapp/api";
import { object, template, text, type Obj } from "./common";
type Authentication = Pick<HttpRequest, "authentication" | "authenticationType">;
export function auth(value: unknown, inheritWhenAbsent = false): Authentication {
// Bruno omits auth for 'none'. A folder without request defaults inherits from its parent.
if (value === "inherit" || (inheritWhenAbsent && value == null))
return { authenticationType: null, authentication: {} };
const a = object(value);
const type = text(a.type);
const fields = (names: string[]) =>
Object.fromEntries(names.map((name) => [name, template(a[name])]));
switch (type) {
case "basic":
case "digest":
return { authenticationType: type, authentication: fields(["username", "password"]) };
case "bearer":
return { authenticationType: type, authentication: fields(["token"]) };
case "ntlm":
return {
authenticationType: "windows",
authentication: fields(["username", "password", "domain"]),
};
case "awsv4":
if (a.profileName) break;
return {
authenticationType: type,
authentication: fields([
"accessKeyId",
"secretAccessKey",
"sessionToken",
"region",
"service",
]),
};
case "apikey":
return {
authenticationType: type,
authentication: {
...fields(["key", "value"]),
location: a.placement === "query" ? "query" : "header",
},
};
case "oauth1": {
const privateKey = object(a.privateKey);
if (
(a.placement != null && a.placement !== "header") ||
a.includeBodyHash === true ||
privateKey.type === "file"
)
break;
return {
authenticationType: type,
authentication: {
...fields([
"consumerKey",
"consumerSecret",
"verifier",
"timestamp",
"nonce",
"version",
"realm",
]),
signatureMethod: text(a.signatureMethod) || "HMAC-SHA1",
tokenKey: template(a.accessToken),
tokenSecret: template(a.accessTokenSecret),
callback: template(a.callbackUrl),
privateKey: template(typeof a.privateKey === "string" ? a.privateKey : privateKey.value),
},
};
}
case "oauth2": {
const flow = text(a.flow);
const token = object(a.tokenConfig);
const placement = object(token.placement);
// Yaak currently places OAuth tokens in a header and uses the access token.
if (
placement.query != null ||
token.source === "id_token" ||
![
"authorization_code",
"client_credentials",
"resource_owner_password_credentials",
"implicit",
].includes(flow)
)
break;
const credentials = object(a.credentials);
const owner = object(a.resourceOwner);
const pkce = object(a.pkce);
return {
authenticationType: type,
authentication: {
...fields(["authorizationUrl", "accessTokenUrl", "scope", "state"]),
grantType: flow === "resource_owner_password_credentials" ? "password" : flow,
clientId: template(credentials.clientId),
clientSecret: template(credentials.clientSecret),
credentials: credentials.placement === "basic_auth_header" ? "basic" : "body",
redirectUri: template(a.callbackUrl),
username: template(owner.username),
password: template(owner.password),
usePkce: a.pkce != null && pkce.disabled !== true,
pkceChallengeMethod: pkce.method === "plain" ? "plain" : "S256",
headerPrefix: placement.header == null ? "Bearer" : template(placement.header),
},
};
}
}
return { authenticationType: "none", authentication: {} };
}
export function manualAuth(value: unknown): Obj | undefined {
const a = object(value);
if (!Object.keys(a).length) return;
if (auth(value).authenticationType === "none") return a;
// OAuth settings beyond the core flow are retained for manual migration.
if (a.type === "oauth2" && (a.additionalParameters || a.refreshTokenUrl || a.settings)) return a;
}
+109
View File
@@ -0,0 +1,109 @@
import type { HttpRequest, HttpRequestHeader } from "@yaakapp/api";
import { headers, object, rows, selected, template, text } from "./common";
export function body(
value: unknown,
graphql: boolean,
rawHeaders: unknown,
inheritedHeaders: HttpRequestHeader[] = [],
): Pick<HttpRequest, "body" | "bodyType" | "headers"> {
const b = object(selected(value, "body"));
const requestHeaders = headers(rawHeaders);
const result = (bodyType: string | null, data: Record<string, unknown>, contentType = "") => {
if (
contentType &&
![...inheritedHeaders, ...requestHeaders].some((h) => h.name.toLowerCase() === "content-type")
) {
requestHeaders.push({ name: "Content-Type", value: contentType, enabled: true });
}
return { bodyType, body: data, headers: requestHeaders };
};
if (graphql) {
return result(
"graphql",
{
query: template(b.query),
variables: template(
typeof b.variables === "object" && b.variables != null
? JSON.stringify(b.variables, null, 2)
: b.variables,
),
...(typeof b.operationName === "string"
? { operationName: template(b.operationName) }
: {}),
},
"application/json",
);
}
const types: Record<string, [string, string]> = {
json: ["application/json", "application/json"],
xml: ["application/xml", "application/xml"],
text: ["other", "text/plain"],
sparql: ["other", "application/sparql-query"],
};
const type = text(b.type);
const rawType = Object.hasOwn(types, type) ? types[type] : undefined;
if (rawType) return result(rawType[0], { text: template(b.data) }, rawType[1]);
if (type === "form-urlencoded")
return result(
"application/x-www-form-urlencoded",
{ form: headers(b.data) },
"application/x-www-form-urlencoded",
);
if (type === "multipart-form") {
const form = rows(b.data).flatMap<Record<string, unknown>>((v) => {
const base = {
name: template(v.name),
enabled: v.disabled !== true,
...(v.contentType ? { contentType: template(v.contentType) } : {}),
};
return v.type === "file"
? (Array.isArray(v.value) ? v.value : [v.value]).map((file: unknown) => ({
...base,
file: template(file),
}))
: [{ ...base, value: template(v.value) }];
});
return result("multipart/form-data", { form }, "multipart/form-data");
}
if (type === "file") {
const file = rows(b.data).find((v) => v.selected === true);
return file
? result("binary", { filePath: template(file.filePath) }, text(file.contentType))
: result(null, {});
}
return result(null, {});
}
export function urlAndParams(url: unknown, value: unknown) {
const params = rows(value);
const urlParameters = params.map((p) => ({
name: `${p.type === "path" ? ":" : ""}${template(p.name)}`,
value: template(p.value),
enabled: p.disabled !== true,
}));
// Bruno stores query parameters in both the URL and the editor rows. Yaak appends rows.
const names = new Set(params.filter((p) => p.type !== "path").map((p) => text(p.name)));
const raw = text(url);
const hash = raw.indexOf("#");
const fragment = hash < 0 ? "" : raw.slice(hash);
const base = hash < 0 ? raw : raw.slice(0, hash);
const q = base.indexOf("?");
if (q < 0 || names.size === 0) return { url: template(raw), urlParameters };
const query = base
.slice(q + 1)
.split("&")
.filter((pair) => {
const key = pair.split("=")[0] ?? "";
try {
return !names.has(decodeURIComponent(key.replaceAll("+", " ")));
} catch {
return !names.has(key);
}
})
.join("&");
return {
url: template(`${base.slice(0, q)}${query ? `?${query}` : ""}${fragment}`),
urlParameters,
};
}
+69
View File
@@ -0,0 +1,69 @@
import type { EnvironmentVariable, HttpRequestHeader } from "@yaakapp/api";
import YAML from "yaml";
export type Obj = Record<string, unknown>;
export function object(value: unknown): Obj {
return value != null && typeof value === "object" && !Array.isArray(value) ? (value as Obj) : {};
}
export function rows(value: unknown): Obj[] {
return Array.isArray(value) ? value.map(object) : [];
}
export function text(value: unknown): string {
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
return "";
}
export function template(value: unknown): string {
return text(value).replace(/{{\s*([^{}]+?)\s*}}/g, (full, name: string) => {
// Bruno's expressions, process.env and dynamic variables have different semantics.
if (
name.startsWith("process.env.") ||
["true", "false", "null"].includes(name) ||
!/^[\p{L}_][\p{L}\p{N}_.-]*$/u.test(name)
)
return full;
return `\${[ ${name} ]}`;
});
}
export function selected(value: unknown, key: string): unknown {
if (!Array.isArray(value)) return value;
const variants = rows(value);
return (variants.find((v) => v.selected === true) ?? variants[0])?.[key];
}
export function variables(value: unknown): EnvironmentVariable[] {
return rows(value).map((v) => {
const raw = selected(v.value, "value");
return {
name: text(v.name),
value: v.secret === true ? "" : template(typeof raw === "object" ? object(raw).data : raw),
enabled: v.disabled !== true,
};
});
}
export function headers(value: unknown): HttpRequestHeader[] {
return rows(value).map((h) => ({
name: template(h.name),
value: template(h.value),
enabled: h.disabled !== true,
}));
}
export function description(item: Obj, manual: Obj = {}): string {
const info = object(item.info);
const content = (v: unknown) => (typeof v === "string" ? v : text(object(v).content));
const parts = [content(info.description), content(item.docs)].filter(Boolean);
const retained = Object.fromEntries(Object.entries(manual).filter(([, v]) => v != null));
if (Object.keys(retained).length) {
// Keep unsupported executable features as inert text for manual migration.
const yaml = YAML.stringify(retained).trimEnd();
const fence = "`".repeat(Math.max(3, ...[...yaml.matchAll(/`+/g)].map((m) => m[0].length + 1)));
parts.push(
`Bruno source fields for manual review (scripts are not executed):\n\n${fence}yaml\n${yaml}\n${fence}`,
);
}
return parts.join("\n\n");
}
@@ -0,0 +1,39 @@
import type { PartialImportResources } from "@yaakapp/api";
import { rows, text, variables, type Obj } from "./common";
export function environments(
value: unknown,
workspaceId: string,
id: () => string,
): PartialImportResources["environments"] {
const source = rows(value);
const byName = new Map(source.map((e) => [text(e.name), e]));
const resolve = (env: Obj, seen = new Set<Obj>()): ReturnType<typeof variables> => {
if (seen.has(env)) throw new Error("Bruno environment inheritance contains a cycle");
if (seen.size > 100) throw new Error("Bruno environment inheritance is too deeply nested");
seen.add(env);
const own = variables(env.variables);
const parentName = text(env.extends);
if (!parentName) return own;
const parent = byName.get(parentName);
if (!parent)
throw new Error(
`Bruno environment "${text(env.name)}" extends missing environment "${parentName}"`,
);
const overrides = new Set(own.filter((v) => v.enabled).map((v) => v.name));
return [...resolve(parent, seen).filter((v) => v.enabled && !overrides.has(v.name)), ...own];
};
// Yaak has one level of selectable environments. Flatten Bruno's extends chain so
// inheritance survives the host clearing environment parent IDs during import.
return source.map((e, sortPriority) => ({
model: "environment",
id: id(),
workspaceId,
name: text(e.name) || "Environment",
parentModel: "environment",
parentId: null,
variables: resolve(e),
color: text(e.color) || null,
sortPriority,
}));
}
+187
View File
@@ -0,0 +1,187 @@
import path from "node:path";
import {
parseCollection,
parseEnvironment,
parseFolder,
parseRequest,
stringifyCollection,
stringifyEnvironment,
stringifyFolder,
stringifyRequest,
} from "@usebruno/filestore";
import type { ImportFileEntry, ImportFiles } from "@yaakapp/api";
import YAML from "yaml";
import { object, rows, text, type Obj } from "./common";
const ignored = new Set([".git", "node_modules", "__MACOSX"]);
const yaml = (value: string) => object(YAML.parse(value, { maxAliasCount: 0 }));
/** Assemble a collection from the virtual file tree. No direct filesystem access in the plugin. */
export function isBruRequestFile(fileName: string): boolean {
return /\.bru$/i.test(fileName) && !["collection.bru", "folder.bru"].includes(fileName);
}
/** A lone .bru request, wrapped as a one-request bundled collection */
export function singleBruRequest(contents: string, fileName: string): Obj {
return {
opencollection: "1.0.0",
bundled: true,
info: { name: fileName.replace(/\.bru$/i, "") },
items: [yaml(stringifyRequest(parseRequest(contents, { format: "bru" }), { format: "yml" }))],
};
}
const markerNames = ["bruno.json", "opencollection.yml", "opencollection.yaml"];
export async function readBrunoCollection(files: ImportFiles): Promise<Obj | null> {
const listings = new Map<string, ImportFileEntry[]>();
const list = async (dir: string) => {
let entries = listings.get(dir);
if (entries == null) {
entries = (await files.readDir(dir)).filter(
(e) => !ignored.has(e.name) && !e.name.startsWith("."),
);
listings.set(dir, entries);
}
return entries;
};
const has = async (dir: string, name: string) =>
(await list(dir)).some((e) => e.type === "file" && e.name === name);
const hasMarker = async (dir: string) => {
for (const name of markerNames) if (await has(dir, name)) return true;
return false;
};
// The selected directory must be the collection itself. Subfolders are only checked to give a
// better error than "not supported" when the user picked the folder above it.
const rootPath = "";
if (!(await hasMarker(rootPath))) {
const top = await list(rootPath);
const [only] = top;
if (top.length === 1 && only?.type === "file" && isBruRequestFile(only.name)) {
return singleBruRequest(await files.readTextFile(only.path), only.name);
}
for (const entry of top) {
if (entry.type === "directory" && (await hasMarker(entry.path))) {
throw new Error(
"This folder contains Bruno collections. Select the collection directory itself.",
);
}
}
return null;
}
const at = (name: string) => name;
const yamlMarker = (await has(rootPath, "opencollection.yml"))
? at("opencollection.yml")
: (await has(rootPath, "opencollection.yaml"))
? at("opencollection.yaml")
: undefined;
const isYaml = yamlMarker != null;
const root = isYaml
? yaml(await files.readTextFile(yamlMarker))
: object(JSON.parse(await files.readTextFile(at("bruno.json"))));
if (isYaml && root.opencollection !== "1.0.0")
throw new Error(`Unsupported Bruno collection format version: ${text(root.opencollection)}`);
const load = async (p: string, parse: (s: string) => Obj) => {
try {
return parse(await files.readTextFile(p));
} catch (error) {
throw new Error(
`Unable to import ${p}: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
const assemble = async (dir: string): Promise<Obj[]> => {
const items: Obj[] = [];
for (const entry of await list(dir)) {
if (dir === rootPath && entry.name === "environments") continue;
if (entry.type === "directory") {
let configPath: string | undefined;
for (const name of isYaml ? ["folder.yml", "folder.yaml"] : ["folder.bru"]) {
if (await has(entry.path, name)) {
configPath = `${entry.path}/${name}`;
break;
}
}
const folder = configPath
? await load(configPath, (s) =>
isYaml
? yaml(s)
: yaml(stringifyFolder(parseFolder(s, { format: "bru" }), { format: "yml" })),
)
: {};
const children = await assemble(entry.path);
items.push({
...folder,
info: { name: entry.name, ...object(folder.info), type: "folder" },
items: children,
});
} else if (
isYaml &&
/\.ya?ml$/i.test(entry.name) &&
!["opencollection.yml", "opencollection.yaml", "folder.yml", "folder.yaml"].includes(
entry.name,
)
) {
const item = await load(entry.path, yaml);
if (object(item.info).type || item.http || item.graphql || item.grpc || item.websocket)
items.push(item);
} else if (
!isYaml &&
/\.bru$/i.test(entry.name) &&
!["collection.bru", "folder.bru"].includes(entry.name)
) {
items.push(
await load(entry.path, (s) =>
yaml(stringifyRequest(parseRequest(s, { format: "bru" }), { format: "yml" })),
),
);
}
}
return items;
};
const environments: Obj[] = [];
const hasEnvDir = (await list(rootPath)).some(
(e) => e.type === "directory" && e.name === "environments",
);
const envFiles = hasEnvDir
? (await list(at("environments"))).filter(
(e) => e.type === "file" && (isYaml ? /\.ya?ml$/i.test(e.name) : /\.bru$/i.test(e.name)),
)
: [];
for (const p of envFiles.map((e) => e.path)) {
const env = await load(p, (s) =>
isYaml
? yaml(s)
: yaml(stringifyEnvironment(parseEnvironment(s, { format: "bru" }), { format: "yml" })),
);
environments.push({ ...env, name: path.posix.basename(p).replace(/\.(?:bru|ya?ml)$/i, "") });
}
const items = await assemble(rootPath);
if (isYaml)
return {
...root,
bundled: true,
items: [...rows(root.items), ...items],
config: {
...object(root.config),
environments: [...rows(object(root.config).environments), ...environments],
},
};
const collectionRoot = (await has(rootPath, "collection.bru"))
? parseCollection(await files.readTextFile(at("collection.bru")), { format: "bru" })
: undefined;
const normalized = yaml(
stringifyCollection(
collectionRoot ?? {},
{ ...root, name: text(root.name) || files.name },
{ format: "yml" },
),
);
return {
...normalized,
bundled: true,
items,
config: { ...object(normalized.config), environments },
};
}
+227
View File
@@ -0,0 +1,227 @@
import type { HttpRequestHeader, PartialImportResources, PluginDefinition } from "@yaakapp/api";
import type { ImportPluginResponse } from "@yaakapp/api/lib/plugins/ImporterPlugin";
import YAML from "yaml";
import { auth, manualAuth } from "./auth";
import { body, urlAndParams } from "./body";
import { environments } from "./environments";
import { isBruRequestFile, readBrunoCollection, singleBruRequest } from "./files";
import {
description,
headers,
object,
rows,
selected,
template,
text,
variables,
type Obj,
} from "./common";
export const plugin: PluginDefinition = {
importer: {
name: "Bruno",
description: "Import Bruno collections",
async onImportSource(_ctx, { source }) {
if (source.type === "text") {
return isBruRequestFile(source.name)
? convertBruno(YAML.stringify(singleBruRequest(source.text, source.name)))
: convertBruno(source.text);
}
const root = await readBrunoCollection(source.files);
return root ? convertBruno(YAML.stringify(root)) : null;
},
},
};
export function convertBruno(contents: string): ImportPluginResponse {
let root: Obj;
try {
// Bundled exports don't need aliases. Disallow cycles before walking the item tree.
root = object(YAML.parse(contents, { maxAliasCount: 0 }));
} catch {
return null;
}
if (typeof root.opencollection !== "string") return null;
if (root.opencollection !== "1.0.0")
throw new Error(`Unsupported Bruno collection format version: ${root.opencollection}`);
if (root.bundled === false || (!Array.isArray(root.items) && root.bundled !== true)) {
throw new Error(
"Select the collection using Choose folder, or export a single YAML file from Bruno's Share menu. A collection configuration file alone does not contain the requests.",
);
}
const resources: PartialImportResources = {
workspaces: [],
environments: [],
folders: [],
httpRequests: [],
grpcRequests: [],
websocketRequests: [],
};
let nextId = 0;
const id = () => `GENERATE_ID::${++nextId}`;
const workspaceId = id();
const defaults = object(root.request);
const config = object(root.config);
const environmentSettings = rows(config.environments)
.map((e) => ({
name: e.name,
externalSecrets: e.externalSecrets,
dotEnvFilePath: e.dotEnvFilePath,
clientCertificates: e.clientCertificates,
}))
.filter((e) => e.externalSecrets || e.dotEnvFilePath || e.clientCertificates);
resources.workspaces.push({
model: "workspace",
id: workspaceId,
name: text(object(root.info).name) || "Bruno Import",
headers: headers(defaults.headers),
...auth(defaults.auth),
description: description(root, {
scripts: defaults.scripts,
actions: defaults.actions,
settings: defaults.settings,
auth: manualAuth(defaults.auth),
...(environmentSettings.length ? { environmentSettings } : {}),
...Object.fromEntries(Object.entries(config).filter(([key]) => key !== "environments")),
}),
});
resources.environments.push({
model: "environment",
id: id(),
workspaceId,
name: "Base Environment",
parentModel: "workspace",
parentId: null,
variables: variables(defaults.variables),
});
resources.environments.push(...environments(config.environments, workspaceId, id));
const visit = (
items: unknown,
folderId: string | null,
depth = 0,
inheritedHeaders: HttpRequestHeader[] = headers(defaults.headers),
) => {
if (depth > 100) throw new Error("Bruno folders are nested too deeply");
for (const [index, item] of rows(items).entries()) {
const info = object(item.info);
const type = text(info.type);
const common = {
id: id(),
workspaceId,
folderId,
name: text(info.name) || "Untitled",
sortPriority: typeof info.seq === "number" && Number.isFinite(info.seq) ? info.seq : index,
};
if (type === "folder" || (type === "" && Array.isArray(item.items))) {
const request = object(item.request);
resources.folders.push({
...common,
model: "folder",
headers: headers(request.headers),
...auth(request.auth, item.request == null),
description: description(item, {
scripts: request.scripts,
actions: request.actions,
settings: request.settings,
auth: manualAuth(request.auth),
}),
});
if (Array.isArray(request.variables) && request.variables.length) {
resources.environments.push({
model: "environment",
id: id(),
workspaceId,
name: "Folder Environment",
parentModel: "folder",
parentId: common.id,
variables: variables(request.variables),
});
}
visit(item.items, common.id, depth + 1, [...inheritedHeaders, ...headers(request.headers)]);
continue;
}
const r = object(item[type]);
const settings = object(item.settings);
const desc = description(item, {
runtime: item.runtime,
examples: item.examples,
settings: item.settings,
auth: manualAuth(r.auth),
...(Array.isArray(r.body) || object(r.body).type === "file"
? { bodyVariants: r.body }
: type === "http" &&
r.body != null &&
!["json", "xml", "text", "sparql", "form-urlencoded", "multipart-form"].includes(
text(object(r.body).type),
)
? { unsupportedBody: r.body }
: {}),
});
if (type === "http" || type === "graphql") {
resources.httpRequests.push({
...common,
model: "http_request",
description: desc,
...urlAndParams(r.url, r.params),
method: text(r.method) || (type === "graphql" ? "POST" : "GET"),
...auth(r.auth),
...body(r.body, type === "graphql", r.headers, inheritedHeaders),
...(typeof settings.followRedirects === "boolean"
? { settingFollowRedirects: { enabled: true, value: settings.followRedirects } }
: {}),
...(typeof settings.timeout === "number" &&
Number.isInteger(settings.timeout) &&
settings.timeout >= 0 &&
settings.timeout <= 2147483647
? { settingRequestTimeout: { enabled: true, value: settings.timeout } }
: {}),
});
} else if (type === "grpc") {
const [service, method] = text(r.method).replace(/^\//, "").split("/");
resources.grpcRequests.push({
...common,
model: "grpc_request",
url: template(r.url),
service: service || null,
method: method || null,
message: template(selected(r.message, "message")),
metadata: headers(r.metadata),
...auth(r.auth),
description: description(item, {
runtime: item.runtime,
protoFilePath: r.protoFilePath,
messageVariants: Array.isArray(r.message) ? r.message : undefined,
auth: manualAuth(r.auth),
}),
});
} else if (type === "websocket") {
const message = object(selected(r.message, "message"));
resources.websocketRequests.push({
...common,
model: "websocket_request",
url: template(r.url),
headers: headers(r.headers),
...auth(r.auth),
message: message.type === "binary" ? "" : template(message.data),
description: description(item, {
runtime: item.runtime,
settings: item.settings,
message: r.message,
auth: manualAuth(r.auth),
}),
});
} else {
// Unknown items remain visible in the import instead of silently disappearing.
resources.folders.push({
...common,
model: "folder",
description: description(item, { unsupportedItem: item }),
});
}
}
};
visit(root.items, null);
// OpenCollection has no persistent resource IDs. Let the host derive source keys.
return { resources };
}
+118
View File
@@ -0,0 +1,118 @@
import { readFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { Context, ImportFiles } from "@yaakapp/api";
import { describe, expect, test } from "vite-plus/test";
import { createImportFiles, runImporter } from "../../../packages/plugin-runtime/src/importFiles";
import { plugin } from "../src";
import { readBrunoCollection } from "../src/files";
const ctx = {} as Context;
describe.each(["opencollection", "legacy"])("Bruno %s file tree", (name) => {
test("imports the same resources from a ZIP or directory, including nested folders and environments", async () => {
const directory = fileURLToPath(new URL(`./fixtures/${name}/Demo`, import.meta.url));
const bytes = await readFile(new URL(`./fixtures/${name}.zip`, import.meta.url));
const zipped = await runImporter(plugin.importer!, ctx, {
content: "",
source: { type: "file", name: `${name}.zip`, base64: bytes.toString("base64") },
});
const unzipped = await runImporter(plugin.importer!, ctx, {
content: "",
source: { type: "directory", path: directory },
});
expect(zipped).toEqual(unzipped);
expect(zipped?.resources.workspaces[0]?.name).toBe(
name === "legacy" ? "Legacy Bruno" : "Directory YAML",
);
expect(zipped?.resources.httpRequests).toHaveLength(2);
expect(zipped?.resources.httpRequests.find((r) => r.name === "Get Users")).toMatchObject({
url: "${[ host ]}/users",
authenticationType: null,
});
expect(zipped?.resources.httpRequests.find((r) => r.name === "Query")).toMatchObject({
bodyType: "graphql",
body: { query: "query { users { id } }" },
});
expect(zipped?.resources.environments[0]?.variables).toContainEqual({
name: "host",
value: "https://example.com",
enabled: true,
});
expect(zipped?.resources.environments[1]?.variables).toContainEqual({
name: "token",
value: "sample",
enabled: true,
});
expect(zipped?.resources.folders.find((f) => f.name === "Users")).toMatchObject({
sortPriority: 3,
});
});
});
test("still imports a bundled YAML file through the new runtime entry point", async () => {
const bytes = await readFile(new URL("./fixtures/collection.yaml", import.meta.url));
const result = await runImporter(plugin.importer!, ctx, {
content: bytes.toString(),
source: { type: "file", name: "collection.yaml", base64: bytes.toString("base64") },
});
expect(result?.resources.workspaces[0]?.name).toBe("Example API");
});
test("imports pasted YAML through the file hook's virtual input file", async () => {
const content = await readFile(new URL("./fixtures/collection.yaml", import.meta.url), "utf8");
const result = await runImporter(plugin.importer!, ctx, { content });
expect(result?.resources.workspaces[0]?.name).toBe("Example API");
});
test("supports an individual .bru request", async () => {
const bytes = await readFile(new URL("./fixtures/legacy/Demo/Users/get.bru", import.meta.url));
const result = await runImporter(plugin.importer!, ctx, {
content: bytes.toString(),
source: { type: "file", name: "get.bru", base64: bytes.toString("base64") },
});
expect(result?.resources.httpRequests[0]?.name).toBe("Get Users");
});
test("does not claim an unrelated binary file", async () => {
expect(
await runImporter(plugin.importer!, ctx, {
content: "",
source: { type: "file", name: "body.bin", base64: "/w==" },
}),
).toBeNull();
});
test("rejects a folder of collections before reading their contents", async () => {
const files: ImportFiles = {
name: "collections",
kind: "directory",
async readDir(dir = "") {
return dir === ""
? ["a", "b"].map((name) => ({ name, path: name, type: "directory" as const }))
: [{ name: "bruno.json", path: `${dir}/bruno.json`, type: "file" }];
},
async readFile() {
throw new Error("should not read");
},
async readTextFile() {
throw new Error("should not read");
},
};
await expect(readBrunoCollection(files)).rejects.toThrow("Select the collection directory");
});
test("reports parse errors with the relative file path", async () => {
const session = await createImportFiles({
content: "meta { invalid",
source: {
type: "file",
name: "broken.bru",
base64: Buffer.from("meta { invalid").toString("base64"),
},
});
try {
await expect(readBrunoCollection(session.files)).rejects.toThrow();
} finally {
session.close();
}
});
+136
View File
@@ -0,0 +1,136 @@
# Synthetic bundled export following Bruno's OpenCollection 1.0.0 exporter.
opencollection: 1.0.0
info:
name: Example API
request:
auth:
type: bearer
token: "{{token}}"
headers:
- name: X-Collection
value: "{{client}}"
variables:
- name: host
value: https://example.com
- name: client
value: yaak
config:
environments:
- name: Development
extends: Shared
variables:
- name: host
value: https://dev.example.com
- name: token
secret: true
- name: disabled
value: ignored
disabled: true
- name: Shared
color: "#123456"
variables:
- name: count
value:
type: number
data: "42"
items:
- info:
name: Users
type: folder
seq: 2
request:
auth: inherit
variables:
- name: userId
value: "123"
items:
- info:
name: Get User
type: http
seq: 3
description: Fetch one user
http:
method: GET
url: "{{host}}/users/:id?expand=profile&keep=1#details"
auth: inherit
params:
- name: id
value: "{{userId}}"
type: path
- name: expand
value: profile
type: query
- name: inactive
value: "true"
type: query
disabled: true
headers:
- name: X-Disabled
value: ignored
disabled: true
settings:
timeout: 2000
followRedirects: false
- info:
name: GraphQL
type: graphql
seq: 1
graphql:
method: POST
url: "{{host}}/graphql"
auth:
type: basic
username: "{{client}}"
password: example
body:
query: "query Get($id: ID!) { node(id: $id) { id } }"
variables: '{"id":"{{userId}}"}'
- info:
name: Public
type: http
seq: 1
http:
method: GET
url: "{{host}}/status"
- info:
name: Upload
type: http
http:
method: POST
url: "{{host}}/upload"
body:
type: multipart-form
data:
- name: label
type: text
value: "{{client}}"
- name: files
type: file
value: [./first.txt, ./second.txt]
contentType: text/plain
- info:
name: Hello RPC
type: grpc
grpc:
url: localhost:50051
method: /example.Greeter/SayHello
message: '{"name":"{{client}}"}'
metadata:
- name: authorization
value: "Bearer {{token}}"
- info:
name: Events
type: websocket
websocket:
url: wss://example.com/events
auth: inherit
message:
type: json
data: '{"subscribe":"{{client}}"}'
docs:
type: text/markdown
content: Synthetic migration fixture.
bundled: true
extensions:
bruno:
exportedUsing: Bruno/3.0.0
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more