Compare commits

..
Author SHA1 Message Date
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
28 changed files with 1127 additions and 261 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}`);
+50 -4
View File
@@ -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
+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, 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 });
})(),
@@ -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,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) => {
+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"
},
@@ -3,7 +3,7 @@ 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_models::export::{self, ExportDataParams};
use yaak::import;
use yaak_models::util::{
BatchUpsertResult, ImportDestination, ImportOrigin, ImportPlanAction, ImportPlanItem,
@@ -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())
+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;
@@ -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;
+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 =
-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;
+96 -90
View File
@@ -152,9 +152,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 +162,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 +243,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 +1769,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 +1785,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 +1819,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 +1839,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 +1859,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 +1879,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 +1902,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 +1925,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 +1948,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 +1971,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 +1994,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 +2014,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"
],
@@ -7385,6 +7385,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 +7841,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
},
@@ -9790,9 +9798,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 +10971,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 +10993,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": {
@@ -11700,15 +11704,15 @@
"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": {
@@ -12044,17 +12048,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 +12067,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": {
@@ -15096,6 +15101,7 @@
"name": "@yaak/filter-jsonpath",
"version": "0.1.0",
"dependencies": {
"@yaakapp-internal/lib": "^1.0.0",
"jsonpath-plus": "^10.3.0"
},
"devDependencies": {
+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;
}
+3 -1
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 [];
},
@@ -289,7 +292,6 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
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_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],
+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 }));
});
});