Compare commits

...
5 Commits
Author SHA1 Message Date
Gregory Schier 222ea9cb83 fix(editor): opt editors out of macOS Writing Tools suggestions
A "Write with Siri" pill appears over empty editors on the macOS 27 beta,
because Codemirror's contentDOM is a contenteditable like any other. The
`writingsuggestions` attribute opts an editable element out, and setting
it here covers every editor at once.

If the pill turns out to be separate from writing suggestions, the
remaining lever is WKWebView's writingToolsBehavior, which would disable
Writing Tools for the whole window.
2026-08-31 10:55:05 -07:00
Gregory Schier 360c098b09 fix(grpc): remove the dead Refresh item from the method picker
It has never had an onSelect. Introduced inert in #193 and carried
through the codebase split, so clicking it only ever closed the dropdown.
Reload Schema in the message editor's schema dropdown does the job it
looked like it was for.
2026-08-31 10:54:40 -07:00
Gregory Schier fce1039bac feat(grpc): generate an example message from the method schema (#613) 2026-08-31 10:44:31 -07:00
Gregory Schier f18f93d613 Stage imports before committing (#571) 2026-08-31 10:20:44 -07:00
Gregory SchierandClaude Opus 5 661a384bed Faster codemirror search match counting (#612)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 10:09:50 -07:00
36 changed files with 2162 additions and 285 deletions
Generated
+1
View File
@@ -11225,6 +11225,7 @@ dependencies = [
"base64 0.22.1",
"log 0.4.29",
"md5 0.8.0",
"rusqlite",
"serde_json",
"tempfile",
"thiserror 2.0.17",
+180 -63
View File
@@ -1,9 +1,8 @@
import { linter } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { jsoncLanguage } from "@shopify/lang-jsonc";
import type { GrpcRequest } from "@yaakapp-internal/models";
import { FormattedError, InlineCode, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type GrpcRequest, patchModel } from "@yaakapp-internal/models";
import { Banner, FormattedError, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import {
handleRefresh,
jsonCompletion,
@@ -11,12 +10,20 @@ import {
stateExtensions,
updateSchema,
} from "codemirror-json-schema";
import type { JSONSchema7 } from "json-schema";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ReflectResponseService } from "../hooks/useGrpc";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { showAlert } from "../lib/alert";
import { showConfirm } from "../lib/confirm";
import { showDialog } from "../lib/dialog";
import type { JsonSchema } from "../lib/jsonSchemaExample";
import { buildExampleFromSchema } from "../lib/jsonSchemaExample";
import { pluralizeCount } from "../lib/pluralize";
import { queryClient } from "../lib/queryClient";
import { Button } from "./core/Button";
import { Dropdown } from "./core/Dropdown";
import type { EditorProps } from "./core/Editor/Editor";
import { Editor } from "./core/Editor/LazyEditor";
import { GrpcProtoSelectionDialog } from "./GrpcProtoSelectionDialog";
@@ -29,6 +36,11 @@ type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceU
protoFiles: string[];
};
type MethodSchema =
| { type: "none" }
| { type: "schema"; schema: JsonSchema }
| { type: "error"; id: string; title: string; body: ReactNode; log: unknown[] };
export function GrpcEditor({
services,
reflectionError,
@@ -42,21 +54,16 @@ export function GrpcEditor({
setEditorView(h);
}, []);
// Find the schema for the selected service and method and update the editor
useEffect(() => {
if (
editorView == null ||
services === null ||
request.service === null ||
request.method === null
) {
return;
// Find the schema for the selected service and method
const methodSchema = useMemo<MethodSchema>(() => {
if (services === null || request.service === null || request.method === null) {
return { type: "none" };
}
const s = services.find((s) => s.name === request.service);
if (s == null) {
console.log("Failed to find service", { service: request.service, services });
showAlert({
return {
type: "error",
id: "grpc-find-service-error",
title: "Couldn't Find Service",
body: (
@@ -64,14 +71,14 @@ export function GrpcEditor({
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
</>
),
});
return;
log: ["Failed to find service", { service: request.service, services }],
};
}
const schema = s.methods.find((m) => m.name === request.method)?.schema;
if (request.method != null && schema == null) {
console.log("Failed to find method", { method: request.method, methods: s?.methods });
showAlert({
if (schema == null) {
return {
type: "error",
id: "grpc-find-schema-error",
title: "Couldn't Find Method",
body: (
@@ -80,18 +87,15 @@ export function GrpcEditor({
<InlineCode>{request.service}</InlineCode> in schema
</>
),
});
return;
}
if (schema == null) {
return;
log: ["Failed to find method", { method: request.method, methods: s.methods }],
};
}
try {
updateSchema(editorView, JSON.parse(schema));
return { type: "schema", schema: JSON.parse(schema) as JsonSchema };
} catch (err) {
showAlert({
return {
type: "error",
id: "grpc-parse-schema-error",
title: "Failed to Parse Schema",
body: (
@@ -103,9 +107,22 @@ export function GrpcEditor({
<FormattedError>{String(err)}</FormattedError>
</VStack>
),
});
log: ["Failed to parse schema", err],
};
}
}, [editorView, services, request.method, request.service]);
}, [services, request.method, request.service]);
useEffect(() => {
if (methodSchema.type !== "error") return;
console.log(...methodSchema.log);
showAlert({ id: methodSchema.id, title: methodSchema.title, body: methodSchema.body });
}, [methodSchema]);
// Update the editor whenever the schema changes
useEffect(() => {
if (editorView == null || methodSchema.type !== "schema") return;
updateSchema(editorView, methodSchema.schema as JSONSchema7);
}, [editorView, methodSchema]);
const extraExtensions = useMemo(
() => [
@@ -124,45 +141,145 @@ export function GrpcEditor({
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
reflectionError = reflectionUnavailable ? undefined : reflectionError;
const handleGenerateExample = useCallback(async () => {
if (methodSchema.type !== "schema") return;
if (request.message.trim() !== "") {
const confirmed = await showConfirm({
id: "grpc-generate-example",
title: "Generate Example",
description: "The current message will be replaced with an example.",
confirmText: "Generate",
});
if (!confirmed) return;
}
const message = JSON.stringify(buildExampleFromSchema(methodSchema.schema), null, 2);
await patchModel(request, { message });
// Force the editor to pick up the new message
wasUpdatedExternally(request.id);
}, [methodSchema, request]);
// The reflect query is keyed by request, url and proto files, so a prefix invalidate
// reaches it without threading a refetch down from the connection layout.
const handleReloadSchema = useCallback(
() => queryClient.invalidateQueries({ queryKey: ["grpc_reflect", request.id] }),
[request.id],
);
const handleShowReflectionError = useCallback(() => {
showDialog({
id: "grpc-reflection-error",
title: "Reflection Failed",
size: "sm",
render: ({ hide }) => (
<>
<FormattedError>{reflectionError ?? "unknown"}</FormattedError>
<div className="w-full my-4">
<Button
className="ml-auto"
color="primary"
size="sm"
onClick={async () => {
hide();
await handleReloadSchema();
}}
>
Retry
</Button>
</div>
</>
),
});
}, [handleReloadSchema, reflectionError]);
const actions = useMemo(
() => [
<div key="reflection" className={classNames(services == null && "opacity-100!")}>
<Button
size="xs"
color={
reflectionLoading
? "secondary"
: reflectionUnavailable
? "info"
: reflectionError
? "danger"
: "secondary"
}
isLoading={reflectionLoading}
onClick={() => {
showDialog({
title: "Configure Schema",
size: "md",
id: "reflection-failed",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
}}
// Matches the GraphQL editor: one always-visible control labelled by schema state,
// with everything schema-related behind it.
<div key="schema" className="opacity-100!">
<Dropdown
items={[
{
// Hidden for servers without reflection, which isn't an error
hidden: !reflectionError,
type: "content",
label: (
<Banner color="danger">
<p className="mb-1">Reflection failed</p>
<Button
size="xs"
color="danger"
variant="border"
onClick={handleShowReflectionError}
>
View Error
</Button>
</Banner>
),
},
{
label: "Generate Example Message",
leftSlot: <Icon icon="magic_wand" />,
disabled: methodSchema.type !== "schema",
onSelect: handleGenerateExample,
},
{ type: "separator" },
{
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={reflectionLoading} />,
keepOpenOnSelect: true,
onSelect: handleReloadSchema,
},
{
label: protoFiles.length > 0 ? "Select Proto Files\u2026" : "Configure Schema\u2026",
leftSlot: <Icon icon="settings" />,
onSelect: () => {
showDialog({
title: "Configure Schema",
size: "md",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
},
},
]}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null && protoFiles.length === 0
? "Schema Detected"
: "Select Schema"}
</Button>
<Button
size="sm"
variant="border"
title="Schema"
forDropdown
isLoading={reflectionLoading}
color={reflectionUnavailable ? "info" : reflectionError ? "danger" : "default"}
>
{reflectionLoading
? "Inspecting Schema"
: reflectionUnavailable
? "Select Proto Files"
: reflectionError
? "Server Error"
: protoFiles.length > 0
? pluralizeCount("File", protoFiles.length)
: services != null
? "Schema Detected"
: "Select Schema"}
</Button>
</Dropdown>
</div>,
],
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
[
handleGenerateExample,
handleReloadSchema,
handleShowReflectionError,
methodSchema.type,
protoFiles.length,
reflectionError,
reflectionLoading,
reflectionUnavailable,
services,
],
);
return (
@@ -194,13 +194,6 @@ export function GrpcRequestPane({
type: "default",
shortLabel: o.label,
}))}
itemsAfter={[
{
label: "Refresh",
type: "default",
leftSlot: <Icon size="sm" icon="refresh" />,
},
]}
>
<Button
size="sm"
+191 -20
View File
@@ -1,17 +1,37 @@
import {
type Folder,
type ImportDestination,
type ImportPlan,
modelTypeLabel,
type Workspace,
} from "@yaakapp-internal/models";
import { HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import { Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useRef, useState } from "react";
import { useLocalStorage } from "react-use";
import { pluralizeCount } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
interface Props {
importFile: (filePath: string) => Promise<void>;
importUrl: (url: string) => Promise<void>;
currentWorkspace: Workspace | null;
workspaces: Workspace[];
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
commit: (plan: ImportPlan) => Promise<void>;
cancel: () => void;
onError: (err: unknown) => void;
}
/** Sentinel for the "create a new workspace" option. Workspace IDs are prefixed `wk_`, so this
* can never collide with a real one. */
const NEW_WORKSPACE = "new_workspace";
/**
* 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).
@@ -31,8 +51,20 @@ function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
export function ImportDataDialog({ importFile, importUrl }: Props) {
export function ImportDataDialog({
currentWorkspace,
workspaces,
selectedFolder,
planFile,
planUrl,
commit,
cancel,
onError,
}: Props) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [destinationId, setDestinationId] = useState<string>(NEW_WORKSPACE);
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useLocalStorage<string | null>("importPathOrUrl", null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
@@ -71,19 +103,117 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
selectSource(selected);
};
const handleImport = async () => {
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder =
selectedFolder != null && destinationId === currentWorkspace?.id;
const destination = (): ImportDestination => {
if (destinationId === NEW_WORKSPACE) {
return { type: "new_workspace" };
}
return {
type: "existing_workspace",
workspaceId: destinationId,
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
};
};
const handlePreview = async () => {
setIsLoading(true);
try {
if (filePath != null) {
await importFile(filePath);
} else {
await importUrl(trimmedSource);
}
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
setPlan(nextPlan);
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
const handleCommit = async () => {
if (plan == null) return;
setIsLoading(true);
try {
await commit(plan);
} catch (err) {
onError(err);
} finally {
setIsLoading(false);
}
};
if (plan != null) {
const counts = [
[plan.resources.workspaces[0], plan.resources.workspaces.length],
[plan.resources.environments[0], plan.resources.environments.length],
[plan.resources.folders[0], plan.resources.folders.length],
[plan.resources.httpRequests[0], plan.resources.httpRequests.length],
[plan.resources.grpcRequests[0], plan.resources.grpcRequests.length],
[plan.resources.websocketRequests[0], plan.resources.websocketRequests.length],
] as const;
const destinationLabel = (() => {
if (plan.destination.type === "new_workspace") return "New workspace";
const { workspaceId, folderId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name;
})();
return (
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label="Destination" value={destinationLabel} />
</div>
<div>
<div className="text-sm font-semibold mb-1">Resources</div>
<ul className="list-disc pl-6 text-sm text-text-subtle">
{counts.map(([model, count]) =>
model == null ? null : (
<li key={model.model}>{pluralizeCount(modelTypeLabel(model), count)}</li>
),
)}
</ul>
</div>
{plan.warnings.length > 0 && (
<div>
<div className="text-sm font-semibold mb-1">Import details</div>
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
{plan.warnings.map((warning) => (
<div
key={`${warning.title}:${warning.detail}`}
className="flex items-start gap-2.5 px-3 py-2.5"
>
<Icon icon="info" color="info" size="sm" className="mt-0.5" />
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</div>
))}
</div>
</div>
)}
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading ? "Importing" : "Confirm Import"}
</Button>
</HStack>
</VStack>
);
}
return (
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
@@ -115,25 +245,66 @@ export function ImportDataDialog({ importFile, importUrl }: Props) {
</div>
</button>
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<VStack space={2}>
<PlainInput
label="Or enter a file path or URL"
<Select
name="import-destination"
label="Import location"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
value={destinationId}
onChange={setDestinationId}
// The native macOS select drops separators
filterable
options={[
{ value: NEW_WORKSPACE, label: "New Workspace" },
...(workspaces.length > 0
? [{ type: "separator" as const, label: "Existing Workspaces" }]
: []),
...workspaces.map((w) => ({
value: w.id,
label: w.id === currentWorkspace?.id ? `${w.name} (current workspace)` : w.name,
})),
]}
/>
{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}
isLoading={isLoading}
size="sm"
onClick={handleImport}
onClick={handlePreview}
>
{isLoading ? "Importing" : "Import"}
{isLoading ? "Analyzing" : "Preview Import"}
</Button>
</VStack>
</HStack>
</VStack>
);
}
function PreviewRow({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-4 px-3 py-2 text-sm">
<span className="text-text-subtle">{label}</span>
<span className="text-right font-medium">{value}</span>
</div>
);
}
+7 -5
View File
@@ -10,11 +10,13 @@ export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
disableBackdropClose?: boolean;
/** Block dismissal from the backdrop, Escape key, and built-in close button. */
disableClose?: boolean;
title?: ReactNode;
description?: ReactNode;
className?: string;
size?: DialogSize;
/** Hide the built-in close button without changing backdrop or Escape behavior. */
hideX?: boolean;
noPadding?: boolean;
noScroll?: boolean;
@@ -27,7 +29,7 @@ export function Dialog({
size = "full",
open,
onClose,
disableBackdropClose,
disableClose,
title,
description,
hideX,
@@ -42,7 +44,7 @@ export function Dialog({
);
return (
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
<Overlay open={open} onClose={disableClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(
@@ -58,7 +60,7 @@ export function Dialog({
// NOTE: We handle Escape on the element itself so that it doesn't close multiple
// dialogs and can be intercepted by children if needed.
if (e.key === "Escape") {
onClose?.();
if (!disableClose) onClose?.();
e.stopPropagation();
e.preventDefault();
}
@@ -110,7 +112,7 @@ export function Dialog({
</div>
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!hideX && (
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
<IconButton
className="opacity-70 hover:opacity-100"
@@ -601,6 +601,8 @@ function getExtensions({
EditorView.contentAttributes.of({
autocapitalize: "off",
autocorrect: "off",
// Keeps macOS Writing Tools from offering to write code for us
writingsuggestions: "false",
}),
EditorView.domEventHandlers({
focus: () => {
@@ -0,0 +1,202 @@
import { SearchQuery } from "@codemirror/search";
import { EditorState } from "@codemirror/state";
import { describe, expect, test } from "vite-plus/test";
import {
currentMatch,
literalSearch,
MAX_COUNT,
MatchCounter,
normalizeDoc,
normalizeSearch,
scanNormalized,
scanQuery,
} from "./searchMatchCount";
type QueryConfig = ConstructorParameters<typeof SearchQuery>[0];
const stateOf = (doc: string) => EditorState.create({ doc });
/**
* The matches the counter finds, having checked them against the search panel's own cursor.
*
* The cursor decides which ranges the editor highlights and which one `find next` lands on, so
* a count that doesn't agree with it is a wrong count, however fast it was to produce.
*/
function matchesOf(doc: string, config: QueryConfig) {
const state = stateOf(doc);
const query = new SearchQuery(config);
const matches = new MatchCounter().matches(state, query);
expect(matches).toEqual(scanQuery(state, query));
return matches;
}
const countOf = (doc: string, config: QueryConfig) => matchesOf(doc, config).length;
describe("counting", () => {
test("counts every match, whatever the case", () => {
expect(countOf("one Two three two", { search: "two" })).toBe(2);
expect(countOf("one Two three two", { search: "two", caseSensitive: true })).toBe(1);
});
test("skips matches overlapping an earlier one", () => {
expect(countOf("aaaaa", { search: "aa" })).toBe(2);
expect(countOf("ababa", { search: "aba" })).toBe(1);
});
test("treats a query as text, not as a pattern", () => {
expect(countOf("a.b axb", { search: "a.b" })).toBe(1);
});
test("unquotes escapes unless the query is literal", () => {
expect(countOf("one\ntwo\nthree", { search: "\\n" })).toBe(2);
expect(countOf("one\\ntwo", { search: "\\n", literal: true })).toBe(1);
});
test("counts regexp and whole word queries through the cursor", () => {
expect(literalSearch(new SearchQuery({ search: "a", regexp: true }))).toBe(null);
expect(literalSearch(new SearchQuery({ search: "a", wholeWord: true }))).toBe(null);
expect(countOf("a1 b2 c3", { search: "[a-z]\\d", regexp: true })).toBe(3);
expect(countOf("cat cats cat", { search: "cat", wholeWord: true })).toBe(2);
});
test("stops counting at the cap", () => {
expect(countOf("x".repeat(MAX_COUNT + 100), { search: "x" })).toBe(MAX_COUNT + 1);
});
test("reports where the matches are", () => {
expect(matchesOf("ab..ab", { search: "ab" })).toEqual([
{ from: 0, to: 2 },
{ from: 4, to: 6 },
]);
});
test("finds nothing to match with an empty needle", () => {
expect(scanNormalized(normalizeDoc("abc", false), "")).toEqual([]);
});
});
describe("normalization", () => {
test("finds what a character decomposes into", () => {
// The é is one character holding an `e`, and the match covers the whole of it
expect(matchesOf("café", { search: "e" })).toEqual([{ from: 3, to: 4 }]);
expect(matchesOf("file", { search: "fi" })).toEqual([{ from: 0, to: 1 }]);
expect(matchesOf("a…b", { search: "..." })).toEqual([{ from: 1, to: 2 }]);
expect(countOf("one two", { search: "one two" })).toBe(1);
expect(countOf("full width", { search: "full" })).toBe(1);
});
test("matches a decomposed query against composed text, and the reverse", () => {
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
expect(countOf("café", { search: "café" })).toBe(1);
});
test("keeps offsets straight after an expansion", () => {
expect(matchesOf("é.é.end", { search: "end" })).toEqual([{ from: 4, to: 7 }]);
expect(matchesOf("fififi stop", { search: "stop" })).toEqual([{ from: 4, to: 8 }]);
});
test("normalizes the query whole, the document by character", () => {
expect(normalizeSearch("CAFÉ", false)).toBe("café");
expect(normalizeSearch("CAFÉ", true)).toBe("CAFÉ");
// Whole-string NFKD would fold this to a final sigma, which the cursor never does
expect(normalizeDoc("ΟΔΟΣ", false).text).toBe("οδοσ");
});
test("leaves a document that normalizes to itself untouched", () => {
const { text, expansions } = normalizeDoc("plain 日本 🎉 text", false);
expect(text).toBe("plain 日本 🎉 text");
expect(expansions).toEqual([]);
});
});
describe("current match", () => {
const matches = [
{ from: 0, to: 2 },
{ from: 4, to: 6 },
{ from: 8, to: 10 },
];
test("counts from one, and reports 0 off a match", () => {
expect(currentMatch(matches, { from: 4, to: 6 })).toBe(2);
expect(currentMatch(matches, { from: 8, to: 10 })).toBe(3);
expect(currentMatch(matches, { from: 5, to: 5 })).toBe(2);
expect(currentMatch(matches, { from: 2, to: 3 })).toBe(0);
expect(currentMatch(matches, { from: 4, to: 7 })).toBe(0);
expect(currentMatch([], { from: 0, to: 0 })).toBe(0);
});
test("moving the selection doesn't scan again", () => {
const state = stateOf("a1 b2 c3");
const query = new SearchQuery({ search: "\\d", regexp: true });
const counter = new MatchCounter();
const found = counter.matches(state, query);
// The document a selection-only transaction leaves behind is the one already scanned
const moved = state.update({ selection: { anchor: 4, head: 5 } }).state;
expect(counter.matches(moved, query)).toBe(found);
expect(currentMatch(found, moved.selection.main)).toBe(2);
});
});
/**
* The mapping from normalized offsets back to document offsets is the part of this that can go
* quietly wrong, and only on input nobody thinks to write a case for. So generate the input.
*/
describe("against the cursor, on awkward text", () => {
const ALPHABET = [
..."abcABC .\\\n".split(""),
"é",
"é",
"fi",
"…",
" ",
"İ",
"Σ",
"ς",
"日",
"🎉",
"Ⅻ",
"",
"①",
"́",
];
/** Seeded, so a failure is the same failure next run */
function random(seed: number) {
let state = seed;
return () => {
state = (state * 1664525 + 1013904223) >>> 0;
return state / 2 ** 32;
};
}
for (const caseSensitive of [false, true]) {
test(`agrees on every generated document (caseSensitive: ${caseSensitive})`, () => {
const next = random(caseSensitive ? 20260831 : 7);
for (let round = 0; round < 400; round++) {
const doc = Array.from(
{ length: 2 + Math.floor(next() * 60) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
// Half the queries are lifted out of the document, so matches are actually found
const start = Math.floor(next() * doc.length);
const search =
next() < 0.5
? doc.slice(start, start + 1 + Math.floor(next() * 3))
: Array.from(
{ length: 1 + Math.floor(next() * 2) },
() => ALPHABET[Math.floor(next() * ALPHABET.length)]!,
).join("");
if (search === "") continue;
const state = stateOf(doc);
const query = new SearchQuery({ search, caseSensitive });
const where = `doc=${JSON.stringify(doc)} search=${JSON.stringify(search)}`;
expect(new MatchCounter().matches(state, query), where).toEqual(scanQuery(state, query));
}
});
}
});
@@ -1,7 +1,232 @@
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
import type { Extension } from "@codemirror/state";
import { getSearchQuery, type SearchQuery, searchPanelOpen } from "@codemirror/search";
import type { EditorState, Extension, Text } from "@codemirror/state";
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
/** Matches are counted no further than this, since an exact total stops being useful long before */
export const MAX_COUNT = 9999;
/** What normalizing rewrites: anything outside ASCII, plus the case it folds */
const REWRITTEN = /\P{ASCII}|[A-Z]+/gu;
const REWRITTEN_CASE_SENSITIVE = /\P{ASCII}/gu;
export interface Match {
from: number;
to: number;
}
/** A character whose normalized form is a different length, shifting every offset past it */
interface Expansion {
normFrom: number;
normTo: number;
docFrom: number;
docTo: number;
}
/** A document as SearchCursor compares it, with what's needed to get back to real offsets */
export interface NormalizedDoc {
text: string;
expansions: Expansion[];
}
/**
* Rewrites a document the way SearchCursor does — NFKD, then a case fold unless the search is
* case-sensitive — in a single pass rather than one call per code point.
*
* The cursor spends 90% of its time asking ICU about one character at a time, which is what
* makes counting matches in a large response slow. Doing it a character at a time still matters
* for the result, since it keeps NFKD from reordering marks across characters, so the
* granularity stays and only the repeated work goes: each distinct character is normalized once
* and the answer reused, and ASCII runs never reach ICU at all.
*/
export function normalizeDoc(text: string, caseSensitive: boolean): NormalizedDoc {
const rewritten = new Map<string, string>();
const expansions: Expansion[] = [];
let shift = 0;
const normalized = text.replace(
caseSensitive ? REWRITTEN_CASE_SENSITIVE : REWRITTEN,
(chunk: string, at: number) => {
// An ASCII run only ever folds case, which can't change its length
if (chunk.charCodeAt(0) < 0x80) return chunk.toLowerCase();
let out = rewritten.get(chunk);
if (out === undefined) {
out = chunk.normalize("NFKD");
if (!caseSensitive) out = out.toLowerCase();
rewritten.set(chunk, out);
}
if (out.length !== chunk.length) {
expansions.push({
normFrom: at + shift,
normTo: at + shift + out.length,
docFrom: at,
docTo: at + chunk.length,
});
shift += out.length - chunk.length;
}
return out;
},
);
return { text: normalized, expansions };
}
/** The query as SearchCursor compares it, which it normalizes whole rather than by character */
export function normalizeSearch(search: string, caseSensitive: boolean): string {
const normalized = search.normalize("NFKD");
return caseSensitive ? normalized : normalized.toLowerCase();
}
/** Every occurrence of `needle`, skipping matches that overlap an earlier one */
export function scanNormalized(doc: NormalizedDoc, needle: string): Match[] {
const matches: Match[] = [];
if (needle === "") return matches;
const { text } = doc;
let pos = text.indexOf(needle);
while (pos >= 0) {
let end = pos + needle.length;
// However the query was cut, a match ends on a whole code point, as the cursor's do
if (isLowSurrogate(text.charCodeAt(end))) end++;
matches.push({ from: docStart(doc, pos), to: docEnd(doc, end) });
if (matches.length > MAX_COUNT) break;
pos = text.indexOf(needle, resumeAfter(doc, end));
}
return matches;
}
/** The same through the query's own cursor, which handles regexps and whole words */
export function scanQuery(state: EditorState, query: SearchQuery): Match[] {
const matches: Match[] = [];
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
matches.push({ from: result.value.from, to: result.value.to });
if (matches.length > MAX_COUNT) break;
}
return matches;
}
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff;
/** The last character expansion beginning at or before `offset`, if there is one */
function expansionAt({ expansions }: NormalizedDoc, offset: number): Expansion | null {
let low = 0;
let high = expansions.length - 1;
let found: Expansion | null = null;
while (low <= high) {
const mid = (low + high) >> 1;
if (expansions[mid]!.normFrom <= offset) {
found = expansions[mid]!;
low = mid + 1;
} else {
high = mid - 1;
}
}
return found;
}
function docStart(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
// A match starting inside a character's expansion starts at the character
return offset < expansion.normTo
? expansion.docFrom
: offset - (expansion.normTo - expansion.docTo);
}
/**
* Where scanning picks up after a match ending at `offset`.
*
* The cursor moves through the document a character at a time, so once a match ends inside a
* character's expansion the rest of that expansion is behind it — "…" holds three dots but only
* ever counts as one match of ".".
*/
function resumeAfter(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
return expansion != null && offset > expansion.normFrom && offset < expansion.normTo
? expansion.normTo
: offset;
}
function docEnd(doc: NormalizedDoc, offset: number): number {
const expansion = expansionAt(doc, offset);
if (expansion == null) return offset;
if (offset <= expansion.normFrom) return expansion.docFrom;
// A match ending inside a character's expansion covers the whole character
return offset < expansion.normTo
? expansion.docTo
: offset - (expansion.normTo - expansion.docTo);
}
/** Position of the match holding the selection, counting from one, or 0 when it isn't on one */
export function currentMatch(matches: Match[], selection: { from: number; to: number }): number {
let index = 0;
for (const match of matches) {
index++;
if (match.from <= selection.from && match.to >= selection.to) return index;
}
return 0;
}
/** The text a plain query looks for, or null when only the cursor can answer it */
export function literalSearch(query: SearchQuery): string | null {
if (query.regexp || query.wholeWord || query.test != null) return null;
// Mirrors SearchQuery's own unquoting, which the published type doesn't expose
return query.literal
? query.search
: query.search.replace(/\\([nrt\\])/g, (_, ch) =>
ch === "n" ? "\n" : ch === "r" ? "\r" : ch === "t" ? "\t" : "\\",
);
}
/**
* Finds the matches for the search panel, keeping the normalized document and the matches it
* last found, so neither moving the selection nor typing another character starts over.
*/
export class MatchCounter {
private doc: { doc: Text; caseSensitive: boolean; normalized: NormalizedDoc } | null = null;
private last: { doc: Text; query: SearchQuery; matches: Match[] } | null = null;
matches(state: EditorState, query: SearchQuery): Match[] {
const last = this.last;
if (last != null && last.doc === state.doc && last.query.eq(query)) {
return last.matches;
}
const matches = this.scan(state, query);
this.last = { doc: state.doc, query, matches };
return matches;
}
private scan(state: EditorState, query: SearchQuery): Match[] {
const search = literalSearch(query);
if (search == null) return scanQuery(state, query);
const doc = this.normalizedDoc(state.doc, query.caseSensitive);
return scanNormalized(doc, normalizeSearch(search, query.caseSensitive));
}
private normalizedDoc(doc: Text, caseSensitive: boolean): NormalizedDoc {
const cached = this.doc;
if (cached != null && cached.doc === doc && cached.caseSensitive === caseSensitive) {
return cached.normalized;
}
const normalized = normalizeDoc(doc.toString(), caseSensitive);
this.doc = { doc, caseSensitive, normalized };
return normalized;
}
}
/**
* A CodeMirror extension that displays the total number of search matches
* inside the built-in search panel.
@@ -10,6 +235,7 @@ export function searchMatchCount(): Extension {
return ViewPlugin.fromClass(
class {
private countEl: HTMLElement | null = null;
private counter = new MatchCounter();
constructor(private view: EditorView) {
this.updateCount();
@@ -38,38 +264,21 @@ export function searchMatchCount(): Extension {
}
this.ensureCountEl();
if (this.countEl == null) return;
if (!query.search) {
if (this.countEl) {
this.countEl.textContent = "0/0";
}
this.countEl.textContent = "0/0";
return;
}
const selection = state.selection.main;
let count = 0;
let currentIndex = 0;
const MAX_COUNT = 9999;
const cursor = query.getCursor(state);
for (let result = cursor.next(); !result.done; result = cursor.next()) {
count++;
const match = result.value;
if (match.from <= selection.from && match.to >= selection.to) {
currentIndex = count;
}
if (count > MAX_COUNT) break;
}
if (this.countEl) {
if (count > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (count === 0) {
this.countEl.textContent = "0/0";
} else if (currentIndex > 0) {
this.countEl.textContent = `${currentIndex}/${count}`;
} else {
this.countEl.textContent = `0/${count}`;
}
const matches = this.counter.matches(state, query);
if (matches.length > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (matches.length === 0) {
this.countEl.textContent = "0/0";
} else {
const current = currentMatch(matches, state.selection.main);
this.countEl.textContent = `${current}/${matches.length}`;
}
}
+6 -2
View File
@@ -119,9 +119,13 @@ export function Select<T extends string>({
)}
>
<Button
className="w-full text-sm font-mono"
className={classNames(
"w-full text-sm font-mono",
disabled && "border-dotted",
isInvalidSelection && "border-danger",
)}
justify="start"
variant="border"
variant="input"
size={size}
leftSlot={leftSlot}
disabled={disabled}
+1 -3
View File
@@ -86,10 +86,8 @@ export async function promptDivergedStrategy({
showDialog({
id: "git-diverged",
title: "Branches Diverged",
hideX: true,
size: "sm",
disableBackdropClose: true,
onClose: () => resolve("cancel"),
disableClose: true,
render: ({ hide }) =>
DivergedDialog({
remote,
+1 -2
View File
@@ -14,9 +14,8 @@ export function showAlert({ id, title, body, size = "sm" }: AlertArgs) {
showDialog({
id,
title,
hideX: true,
size,
disableBackdropClose: true, // Prevent accidental dismisses
disableClose: true,
render: ({ hide }) => Alert({ onHide: hide, body }),
});
}
+1 -2
View File
@@ -18,9 +18,8 @@ export async function showConfirm({
return new Promise((onResult: ConfirmProps["onResult"]) => {
showDialog({
...extraProps,
hideX: true,
size,
disableBackdropClose: true, // Prevent accidental dismisses
disableClose: true,
render: ({ hide }) => Confirm({ onHide: hide, color, onResult, confirmText, requireTyping }),
});
});
+36 -14
View File
@@ -1,10 +1,18 @@
import type { BatchUpsertResult } from "@yaakapp-internal/models";
import {
type BatchUpsertResult,
type ImportDestination,
type ImportPlan,
workspacesAtom,
} from "@yaakapp-internal/models";
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert";
import { showDialog } from "./dialog";
import { jotaiStore } from "./jotai";
import { pluralizeCount } from "./pluralize";
import { router } from "./router";
import { rpc } from "./rpc";
@@ -21,29 +29,43 @@ export const importData = createFastMutation({
},
mutationFn: async () => {
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: "sm",
disableClose: true,
render: ({ hide }) => {
const importAndHide = async (runImport: () => Promise<BatchUpsertResult>) => {
try {
await finishImport(await runImport());
resolve();
} catch (err) {
reject(err);
} finally {
hide();
}
const cancel = () => {
hide();
resolve();
};
const fail = (err: unknown) => {
hide();
reject(err);
};
const commit = async (plan: ImportPlan) => {
const imported = await rpc<BatchUpsertResult>("cmd_commit_import", { plan });
hide();
await finishImport(imported);
resolve();
};
return (
<ImportDataDialog
importFile={(filePath) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_data", { filePath }))
currentWorkspace={currentWorkspace}
workspaces={workspaces}
selectedFolder={selectedFolder}
planFile={(filePath: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", { filePath, destination })
}
importUrl={(url) =>
importAndHide(() => rpc<BatchUpsertResult>("cmd_import_url", { url }))
planUrl={(url: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_url", { url, destination })
}
commit={commit}
cancel={cancel}
onError={fail}
/>
);
},
@@ -0,0 +1,251 @@
import { describe, expect, test } from "vite-plus/test";
import type { JsonSchema } from "./jsonSchemaExample";
import { buildExampleFromSchema } from "./jsonSchemaExample";
describe("buildExampleFromSchema", () => {
test("fills scalar fields with placeholders", () => {
const schema: JsonSchema = {
type: "object",
properties: {
name: { type: "string" },
age: { type: "number", format: "int32" },
active: { type: "boolean" },
data: { type: "string", format: "byte" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
name: "",
age: 0,
active: false,
data: "",
});
});
test("encodes 64-bit integers as strings", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string", format: "int64" },
count: { type: "string", format: "uint64" },
offset: { type: "string", format: "sfixed64" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ id: "0", count: "0", offset: "0" });
});
test("fills date-time with a parseable timestamp", () => {
const schema: JsonSchema = {
type: "object",
properties: { createdAt: { type: "string", format: "date-time" } },
};
const example = buildExampleFromSchema(schema) as { createdAt: string };
expect(Number.isNaN(Date.parse(example.createdAt))).toBe(false);
});
test("fills a duration with a value that parses", () => {
const schema: JsonSchema = {
type: "object",
properties: { timeout: { type: "string", format: "duration" } },
};
// An empty string fails protobuf's Duration parsing, so the message wouldn't send
expect(buildExampleFromSchema(schema)).toEqual({ timeout: "0s" });
});
test("expands nested messages through $defs", () => {
const schema: JsonSchema = {
type: "object",
properties: { user: { $ref: "#/$defs/example.User" } },
$defs: {
"example.User": {
type: "object",
properties: {
name: { type: "string" },
address: { $ref: "#/$defs/example.Address" },
},
},
"example.Address": {
type: "object",
properties: { city: { type: "string" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
user: { name: "", address: { city: "" } },
});
});
test("gives repeated fields a single placeholder item", () => {
const schema: JsonSchema = {
type: "object",
properties: {
tags: { type: "array", items: { type: "string" } },
users: { type: "array", items: { $ref: "#/$defs/example.User" } },
unknown: { type: "array" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
tags: [""],
users: [{ name: "" }],
unknown: [],
});
});
test("uses the first value of an enum", () => {
const schema: JsonSchema = {
type: "object",
properties: {
status: { type: "string", enum: ["STATUS_UNSPECIFIED", "STATUS_ACTIVE"] },
empty: { type: "string", enum: [] },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ status: "STATUS_UNSPECIFIED", empty: "" });
});
test("gives maps a single placeholder entry", () => {
const schema: JsonSchema = {
type: "object",
properties: {
labels: { type: "object", additionalProperties: { type: "string" } },
users: { type: "object", additionalProperties: { $ref: "#/$defs/example.User" } },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
labels: { key: "" },
users: { key: { name: "" } },
});
});
test("stops at the root self-reference", () => {
const schema: JsonSchema = {
type: "object",
properties: {
value: { type: "string" },
children: { type: "array", items: { $ref: "#" } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ value: "", children: [{}] });
});
test("stops at a cycle between messages", () => {
const schema: JsonSchema = {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
$defs: {
"example.Node": {
type: "object",
properties: {
name: { type: "string" },
parent: { $ref: "#/$defs/example.Node" },
leaf: { $ref: "#/$defs/example.Leaf" },
},
},
"example.Leaf": {
type: "object",
properties: { node: { $ref: "#/$defs/example.Node" } },
},
},
};
expect(buildExampleFromSchema(schema)).toEqual({
node: { name: "", parent: {}, leaf: { node: {} } },
});
});
test("expands the same message twice when it is not on the same path", () => {
const schema: JsonSchema = {
type: "object",
properties: {
from: { $ref: "#/$defs/example.User" },
to: { $ref: "#/$defs/example.User" },
},
$defs: {
"example.User": { type: "object", properties: { name: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ from: { name: "" }, to: { name: "" } });
});
test("fills every branch of a flattened oneof", () => {
const schema: JsonSchema = {
type: "object",
properties: {
id: { type: "string" },
text: { type: "string" },
image: { $ref: "#/$defs/example.Image" },
},
$defs: {
"example.Image": { type: "object", properties: { url: { type: "string" } } },
},
};
expect(buildExampleFromSchema(schema)).toEqual({
id: "",
text: "",
image: { url: "" },
});
});
test("stops expanding once the node budget runs out", () => {
// Every level references the next one twice, so an unbounded walk would build 2^depth
// nodes without ever repeating a ref on the same path.
const depth = 16;
const $defs: Record<string, JsonSchema> = { [`d${depth}`]: { type: "string" } };
for (let i = 0; i < depth; i++) {
$defs[`d${i}`] = {
type: "object",
properties: {
a: { $ref: `#/$defs/d${i + 1}` },
b: { $ref: `#/$defs/d${i + 1}` },
},
};
}
const example = buildExampleFromSchema({
type: "object",
properties: { root: { $ref: "#/$defs/d0" } },
$defs,
});
// 2 ** 16 nodes unbounded; the budget holds it to a couple of thousand
expect(countNodes(example)).toBeLessThan(10_000);
});
test("handles messages without a known type", () => {
const schema: JsonSchema = {
type: "object",
properties: {
empty: {},
struct: { type: "object" },
missing: { $ref: "#/$defs/example.Nope" },
},
};
expect(buildExampleFromSchema(schema)).toEqual({ empty: null, struct: {}, missing: {} });
});
});
function countNodes(value: unknown): number {
if (Array.isArray(value)) {
return 1 + value.reduce((total: number, v) => total + countNodes(v), 0);
}
if (value !== null && typeof value === "object") {
return 1 + Object.values(value).reduce((total: number, v) => total + countNodes(v), 0);
}
return 1;
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Subset of JSON Schema emitted by the gRPC reflection layer for a method's
* input message. See `message_to_json_schema` in the `yaak-grpc` crate.
*/
export type JsonSchema = {
type?: string;
format?: string;
properties?: Record<string, JsonSchema>;
items?: JsonSchema;
additionalProperties?: JsonSchema;
enum?: unknown[];
$defs?: Record<string, JsonSchema>;
$ref?: string;
};
const DEFS_PREFIX = "#/$defs/";
const ROOT_REF = "#";
// Protobuf 64-bit integers are encoded as strings in the JSON mapping
const STRING_NUMBER_FORMATS = ["int64", "uint64", "sint64", "fixed64", "sfixed64"];
// Refs on sibling branches each expand their own subtree, so a schema that references the
// same messages repeatedly can produce exponentially many nodes without ever cycling.
const MAX_NODES = 5000;
type Budget = { remaining: number };
/** Build a sample message with placeholder values for every field in the schema */
export function buildExampleFromSchema(schema: JsonSchema): unknown {
// The root is already being built, so a `#` ref anywhere below it is a cycle
return buildValue(schema, schema, new Set([ROOT_REF]), { remaining: MAX_NODES });
}
function buildValue(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema == null || typeof schema !== "object" || budget.remaining <= 0) {
return null;
}
budget.remaining -= 1;
if (typeof schema.$ref === "string") {
if (refPath.has(schema.$ref)) {
return {};
}
const resolved = resolveRef(schema.$ref, root);
if (resolved == null) {
return {};
}
return buildValue(resolved, root, new Set(refPath).add(schema.$ref), budget);
}
if (Array.isArray(schema.enum)) {
return schema.enum[0] ?? "";
}
switch (schema.type) {
case "object":
return buildObject(schema, root, refPath, budget);
case "array":
return schema.items == null ? [] : [buildValue(schema.items, root, refPath, budget)];
case "string":
return buildString(schema.format);
case "number":
return 0;
case "boolean":
return false;
default:
return null;
}
}
function buildObject(
schema: JsonSchema,
root: JsonSchema,
refPath: Set<string>,
budget: Budget,
): unknown {
if (schema.properties != null && typeof schema.properties === "object") {
const example: Record<string, unknown> = {};
for (const [name, propertySchema] of Object.entries(schema.properties)) {
example[name] = buildValue(propertySchema, root, refPath, budget);
}
return example;
}
// Maps have no properties, only a value schema
if (schema.additionalProperties != null) {
return { key: buildValue(schema.additionalProperties, root, refPath, budget) };
}
return {};
}
function buildString(format: string | undefined): string {
if (format === "date-time") {
return new Date().toISOString();
}
// Duration JSON is a decimal string with an `s` suffix, and an empty one fails to parse
if (format === "duration") {
return "0s";
}
if (format != null && STRING_NUMBER_FORMATS.includes(format)) {
return "0";
}
return "";
}
function resolveRef(ref: string, root: JsonSchema): JsonSchema | null {
if (ref === ROOT_REF) {
return root;
}
if (!ref.startsWith(DEFS_PREFIX)) {
return null;
}
return root.$defs?.[ref.slice(DEFS_PREFIX.length)] ?? null;
}
+1 -6
View File
@@ -25,13 +25,8 @@ export async function showPromptForm({
id,
title,
description,
hideX: true,
size: size ?? "sm",
disableBackdropClose: true, // Prevent accidental dismisses
onClose: () => {
// Click backdrop, close, or escape
resolve(null);
},
disableClose: true,
render: ({ hide }) =>
Prompt({
onCancel: () => {
@@ -5,8 +5,7 @@ use std::fs;
use std::io::ErrorKind;
use yaak::export::{self, ExportDataParams};
use yaak::import;
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination};
use yaak_plugins::events::{ImportResources, PluginContext};
type CommandResult<T = ()> = std::result::Result<T, String>;
@@ -51,6 +50,7 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.import_data(&plugin_context, &file_contents)
.await
.map_err(|e| format!("Failed to import data: {e}"))?;
let importer = import_result.importer;
let resources = import_result.resources;
let workspace_id = args.workspace_id;
if workspace_id.is_none() && resources_need_current_workspace(&resources) {
@@ -59,13 +59,13 @@ async fn import(ctx: &CliContext, args: ImportArgs) -> CommandResult<BatchUpsert
.to_string(),
);
}
let workspace_context = WorkspaceContext {
workspace_id,
environment_id: None,
cookie_jar_id: None,
request_id: None,
let destination = match workspace_id {
Some(workspace_id) => ImportDestination::ExistingWorkspace { workspace_id, folder_id: None },
None => ImportDestination::NewWorkspace,
};
let imported = import::import_resources(ctx.query_manager(), workspace_context, resources)
let plan = import::plan_import_resources(ctx.query_manager(), importer, destination, resources)
.map_err(|e| format!("Failed to plan import: {e}"))?;
let imported = import::commit_import_plan(ctx.query_manager(), plan)
.map_err(|e| format!("Failed to import data: {e}"))?;
Ok(imported)
}
@@ -81,14 +81,21 @@ fn import_reads_yaak_workspace_file() {
let query_manager = query_manager(data_dir);
let db = query_manager.connect();
assert_eq!(
db.get_workspace("wrk_import").expect("workspace imported").name,
"Imported Workspace"
);
assert_eq!(
db.get_http_request("req_import").expect("request imported").url,
"https://example.com"
);
let workspaces = db.list_workspaces().expect("list imported workspaces");
let workspace = workspaces
.iter()
.find(|workspace| workspace.name == "Imported Workspace")
.expect("workspace imported");
assert_ne!(workspace.id, "wrk_import");
let requests = db.list_http_requests(&workspace.id).expect("list imported requests");
let request = requests
.iter()
.find(|request| request.name == "Imported Request")
.expect("request imported");
assert_ne!(request.id, "req_import");
assert_eq!(request.workspace_id, workspace.id);
assert_eq!(request.url, "https://example.com");
}
fn write_postman_environment_fixture(path: &std::path::Path) {
+29 -19
View File
@@ -4,53 +4,63 @@ use crate::models_ext::QueryManagerExt;
use std::fs::read_to_string;
use std::io::ErrorKind;
use tauri::{Manager, Runtime, WebviewWindow};
use yaak::import::{self, ImportDataParams};
use yaak::import::{self, PlanImportDataParams};
use yaak_api::{ApiClientKind, yaak_api_client};
use yaak_core::WorkspaceContext;
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_plugins::manager::PluginManager;
use yaak_tauri_utils::window::WorkspaceWindowTrait;
pub(crate) async fn import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
) -> Result<BatchUpsertResult> {
let contents = read_import_file(file_path)?;
import_contents(window, &contents).await
let plan = plan_import_data(window, file_path, ImportDestination::NewWorkspace).await?;
commit_import(window, plan)
}
pub(crate) async fn import_url<R: Runtime>(
pub(crate) async fn plan_import_data<R: Runtime>(
window: &WebviewWindow<R>,
file_path: &str,
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = read_import_file(file_path)?;
plan_import_contents(window, &contents, destination).await
}
pub(crate) async fn plan_import_url<R: Runtime>(
window: &WebviewWindow<R>,
url: &str,
) -> Result<BatchUpsertResult> {
destination: ImportDestination,
) -> Result<ImportPlan> {
let contents = fetch_import_url(window, url).await?;
import_contents(window, &contents).await
plan_import_contents(window, &contents, destination).await
}
async fn import_contents<R: Runtime>(
async fn plan_import_contents<R: Runtime>(
window: &WebviewWindow<R>,
contents: &str,
) -> Result<BatchUpsertResult> {
destination: ImportDestination,
) -> Result<ImportPlan> {
let plugin_manager = window.state::<PluginManager>();
let query_manager = window.db_manager();
let plugin_context = window.plugin_context();
let workspace_context = WorkspaceContext {
workspace_id: window.workspace_id(),
environment_id: window.environment_id(),
cookie_jar_id: window.cookie_jar_id(),
request_id: None,
};
Ok(import::import_data(ImportDataParams {
Ok(import::plan_import_data(PlanImportDataParams {
query_manager: &query_manager,
plugin_manager: &plugin_manager,
plugin_context: &plugin_context,
workspace_context,
destination,
contents,
})
.await?)
}
pub(crate) fn commit_import<R: Runtime>(
window: &WebviewWindow<R>,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
Ok(import::commit_import_plan(&window.db_manager(), plan)?)
}
/// Download an importable document (OpenAPI, Postman, Insomnia, …) so it can be fed to the same
/// pipeline as a file on disk.
///
+14 -5
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::{import_data, import_url};
use crate::import::{commit_import, plan_import_data, plan_import_url};
use crate::models_ext::{BlobManagerExt, QueryManagerExt};
use crate::notifications::YaakNotifier;
use crate::render::{render_grpc_request, render_template};
@@ -40,7 +40,7 @@ use yaak_models::models::{
CookieJar, Environment, GrpcConnection, GrpcConnectionState, GrpcEvent,
GrpcEventType, HttpRequest, HttpResponse, HttpResponseState, Workspace,
};
use yaak_models::util::{BatchUpsertResult, UpdateSource};
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan, UpdateSource};
use yaak_plugins::events::{
Color, ErrorResponse, FilterResponse, InternalEvent, InternalEventPayload, PluginContext,
RenderPurpose, ShowToastRequest,
@@ -1014,15 +1014,24 @@ async fn cmd_get_sse_events<R: Runtime>(
async fn cmd_import_data<R: Runtime>(
window: WebviewWindow<R>,
file_path: &str,
) -> YaakResult<BatchUpsertResult> {
import_data(&window, file_path).await
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
}
async fn cmd_commit_import<R: Runtime>(
window: WebviewWindow<R>,
plan: ImportPlan,
) -> YaakResult<BatchUpsertResult> {
import_url(&window, url).await
commit_import(&window, plan)
}
+9 -6
View File
@@ -40,7 +40,7 @@ use yaak_models::models::{
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportPlan};
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
CallWebsocketRequestActionRequest, CallWorkspaceActionRequest, FilterResponse, ImportResponse,
@@ -441,12 +441,16 @@ async fn cmd_get_http_response_events<R: Runtime>(ctx: ClientCtx<R>, req: CmdGet
Ok(yaak_commands::responses::cmd_get_http_response_events(ctx, req).await?)
}
async fn cmd_import_data<R: Runtime>(ctx: ClientCtx<R>, req: CmdImportDataReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_import_data(ctx.window.clone(), &req.file_path).await?)
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<BatchUpsertResult> {
Ok(crate::cmd_import_url(ctx.window.clone(), &req.url).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?)
}
async fn cmd_commit_import<R: Runtime>(ctx: ClientCtx<R>, req: CmdCommitImportReq) -> Result<BatchUpsertResult> {
Ok(crate::cmd_commit_import(ctx.window.clone(), req.plan).await?)
}
async fn cmd_http_request_actions<R: Runtime>(ctx: ClientCtx<R>, req: CmdHttpRequestActionsReq) -> Result<Vec<GetHttpRequestActionsResponse>> {
@@ -843,4 +847,3 @@ async fn cmd_plugins_updates<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpda
async fn cmd_plugins_update_all<R: Runtime>(ctx: ClientCtx<R>, _req: CmdPluginsUpdateAllReq) -> Result<Vec<PluginNameVersion>> {
Ok(crate::plugins_ext::cmd_plugins_update_all(ctx.window.clone()).await?)
}
File diff suppressed because one or more lines are too long
+12
View File
@@ -2,3 +2,15 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
/**
* Where a staged import will be committed.
*
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use.
*/
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>, };
export type ImportPlanWarning = { title: string, detail: string, };
+13 -3
View File
@@ -23,7 +23,7 @@ use yaak_models::models::{
AnyModel, GraphQlIntrospection, GrpcEvent, HttpRequest, HttpRequestHeader, HttpResponse,
HttpResponseEvent, Plugin, Settings, WebsocketConnection, WebsocketEvent, WorkspaceMeta,
};
use yaak_models::util::BatchUpsertResult;
use yaak_models::util::{BatchUpsertResult, ImportDestination, ImportPlan};
use yaak_plugins::api::{PluginNameVersion, PluginSearchResponse, PluginUpdatesResponse};
use yaak_plugins::events::{
CallFolderActionRequest, CallGrpcRequestActionRequest, CallHttpRequestActionRequest,
@@ -229,6 +229,7 @@ pub struct CmdGetHttpResponseEventsReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportDataReq {
pub file_path: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
@@ -236,6 +237,14 @@ pub struct CmdImportDataReq {
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdImportUrlReq {
pub url: String,
pub destination: ImportDestination,
}
#[derive(Debug, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_rpc.ts")]
pub struct CmdCommitImportReq {
pub plan: ImportPlan,
}
#[derive(Debug, Deserialize, TS)]
@@ -909,8 +918,9 @@ macro_rules! with_commands {
cmd_http_request_body(CmdHttpRequestBodyReq) -> Option<Vec<u8>>,
cmd_get_sse_events(CmdGetSseEventsReq) -> Vec<ServerSentEvent>,
cmd_get_http_response_events(CmdGetHttpResponseEventsReq) -> Vec<HttpResponseEvent>,
cmd_import_data(CmdImportDataReq) -> BatchUpsertResult,
cmd_import_url(CmdImportUrlReq) -> BatchUpsertResult,
cmd_import_data(CmdImportDataReq) -> ImportPlan,
cmd_import_url(CmdImportUrlReq) -> ImportPlan,
cmd_commit_import(CmdCommitImportReq) -> BatchUpsertResult,
cmd_http_request_actions(CmdHttpRequestActionsReq) -> Vec<GetHttpRequestActionsResponse>,
cmd_websocket_request_actions(CmdWebsocketRequestActionsReq) -> Vec<GetWebsocketRequestActionsResponse>,
cmd_call_websocket_request_action(CmdCallWebsocketRequestActionReq) -> (),
+1 -1
View File
@@ -210,7 +210,7 @@ fn field_to_type_or_ref(root_name: &str, field: FieldDescriptor) -> JsonSchemaEn
// [Protocol Buffers Well-Known Types]: https://protobuf.dev/reference/protobuf/google.protobuf/
"google.protobuf.FieldMask" => JsonSchemaEntry::string(),
"google.protobuf.Timestamp" => JsonSchemaEntry::string_with_format("date-time"),
"google.protobuf.Duration" => JsonSchemaEntry::string(),
"google.protobuf.Duration" => JsonSchemaEntry::string_with_format("duration"),
"google.protobuf.StringValue" => JsonSchemaEntry::string(),
"google.protobuf.BytesValue" => JsonSchemaEntry::string_with_format("byte"),
"google.protobuf.Int32Value" => JsonSchemaEntry::number("int32"),
+12
View File
@@ -2,3 +2,15 @@
import type { Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace } from "./gen_models";
export type BatchUpsertResult = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
/**
* Where a staged import will be committed.
*
* The destination workspace and optional folder IDs are captured in the plan so the preview describes
* the exact destination that confirmation will use.
*/
export type ImportDestination = { "type": "new_workspace" } | { "type": "existing_workspace", workspaceId: string, folderId?: string, };
export type ImportPlan = { importer: string, destination: ImportDestination, resources: BatchUpsertResult, warnings: Array<ImportPlanWarning>, };
export type ImportPlanWarning = { title: string, detail: string, };
+36
View File
@@ -85,6 +85,42 @@ pub struct BatchUpsertResult {
pub websocket_requests: Vec<WebsocketRequest>,
}
/// Where a staged import will be committed.
///
/// The destination workspace and optional folder IDs are captured in the plan so the preview describes
/// the exact destination that confirmation will use.
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "snake_case", tag = "type")]
#[ts(export, export_to = "gen_util.ts")]
pub enum ImportDestination {
NewWorkspace,
ExistingWorkspace {
#[serde(rename = "workspaceId")]
workspace_id: String,
#[serde(rename = "folderId")]
#[ts(optional)]
folder_id: Option<String>,
},
}
#[derive(Debug, Clone, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlanWarning {
pub title: String,
pub detail: String,
}
#[derive(Debug, Deserialize, Serialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "gen_util.ts")]
pub struct ImportPlan {
pub importer: String,
pub destination: ImportDestination,
pub resources: BatchUpsertResult,
pub warnings: Vec<ImportPlanWarning>,
}
pub fn get_workspace_export_resources(
db: &ClientDb,
yaak_version: &str,
+1 -1
View File
@@ -474,7 +474,7 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportResponse = { resources: ImportResources, };
export type ImportResponse = { importer: string, resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
+2
View File
@@ -247,6 +247,8 @@ pub struct ImportRequest {
#[serde(default, rename_all = "camelCase")]
#[ts(export, export_to = "gen_events.ts")]
pub struct ImportResponse {
/// Display name of the importer that recognized the input.
pub importer: String,
pub resources: ImportResources,
}
+13 -2
View File
@@ -1104,8 +1104,19 @@ impl PluginManager {
.await?;
// TODO: Don't just return the first valid response
let result = reply_events.into_iter().find_map(|e| match e.payload {
InternalEventPayload::ImportResponse(resp) => Some(resp),
let result = reply_events.into_iter().find_map(|e| match e {
InternalEvent {
plugin_name,
payload: InternalEventPayload::ImportResponse(mut resp),
..
} => {
// Older plugin runtimes do not include the importer's display name. The plugin
// package name is still enough to identify the detected format in that case.
if resp.importer.is_empty() {
resp.importer = plugin_name;
}
Some(resp)
}
_ => None,
});
+1
View File
@@ -21,5 +21,6 @@ yaak-templates = { workspace = true }
yaak-tls = { workspace = true }
[dev-dependencies]
rusqlite = { version = "0.38", features = ["bundled"] }
tempfile = "3"
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+744 -73
View File
@@ -1,129 +1,800 @@
use crate::Result;
use log::info;
use std::collections::BTreeMap;
use yaak_core::WorkspaceContext;
use std::collections::{BTreeMap, BTreeSet};
use yaak_models::client_db::ClientDb;
use yaak_models::models::{
Environment, Folder, GrpcRequest, HttpRequest, WebsocketRequest, Workspace,
DEFAULT_REQUEST_MESSAGE_SIZE, Environment, Folder, GrpcRequest, HttpRequest, UpsertModelInfo,
WebsocketRequest, Workspace,
};
use yaak_models::query_manager::QueryManager;
use yaak_models::util::{BatchUpsertResult, UpdateSource, maybe_gen_id, maybe_gen_id_opt};
use yaak_models::util::{
BatchUpsertResult, ImportDestination, ImportPlan, ImportPlanWarning, UpdateSource,
};
use yaak_plugins::events::{ImportResources, PluginContext};
use yaak_plugins::manager::PluginManager;
pub struct ImportDataParams<'a> {
pub struct PlanImportDataParams<'a> {
pub query_manager: &'a QueryManager,
pub plugin_manager: &'a PluginManager,
pub plugin_context: &'a PluginContext,
pub workspace_context: WorkspaceContext,
pub destination: ImportDestination,
pub contents: &'a str,
}
pub async fn import_data(params: ImportDataParams<'_>) -> Result<BatchUpsertResult> {
/// 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?;
import_resources(params.query_manager, params.workspace_context, import_result.resources)
plan_import_resources(
params.query_manager,
import_result.importer,
params.destination,
import_result.resources,
)
}
pub fn import_resources(
/// Remap parsed importer resources into their selected destination.
///
/// Every imported model gets a fresh ID. This prevents an import from accidentally updating an
/// existing model and also makes the plan safe to inspect before it is committed.
pub fn plan_import_resources(
query_manager: &QueryManager,
workspace_context: WorkspaceContext,
importer: String,
destination: ImportDestination,
resources: ImportResources,
) -> Result<BatchUpsertResult> {
let mut id_map: BTreeMap<String, String> = BTreeMap::new();
) -> Result<ImportPlan> {
let mut warnings = Vec::new();
validate_destination(query_manager, &destination)?;
let workspaces: Vec<Workspace> = resources
.workspaces
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Workspace>(&workspace_context, v.id.as_str(), &mut id_map);
v
})
.collect();
let source_folder_ids = resources.folders.iter().map(|v| v.id.clone()).collect::<BTreeSet<_>>();
let mut folder_ids = BTreeMap::new();
for folder in &resources.folders {
folder_ids.insert(folder.id.clone(), Folder::generate_id());
}
let environments: Vec<Environment> = resources
.environments
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Environment>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
match (v.parent_model.as_str(), v.parent_id.clone().as_deref()) {
("folder", Some(parent_id)) => {
v.parent_id =
Some(maybe_gen_id::<Folder>(&workspace_context, parent_id, &mut id_map));
}
("", _) => {
v.parent_model = "workspace".to_string();
}
_ => {
v.parent_id = None;
}
};
v
})
.collect();
let mut workspace_ids = BTreeMap::new();
let mut workspaces = Vec::new();
let (default_workspace_id, target_folder_id) = match &destination {
ImportDestination::NewWorkspace => {
for source in &resources.workspaces {
let mut workspace = source.clone();
workspace.id = Workspace::generate_id();
workspace_ids.insert(source.id.clone(), workspace.id.clone());
workspaces.push(workspace);
}
let folders: Vec<Folder> = resources
if workspaces.is_empty() {
let workspace = Workspace {
id: Workspace::generate_id(),
model: "workspace".to_string(),
name: format!("{} Import", display_importer_name(&importer)),
setting_follow_redirects: true,
setting_request_message_size: DEFAULT_REQUEST_MESSAGE_SIZE,
setting_validate_certificates: true,
setting_send_cookies: true,
setting_store_cookies: true,
..Default::default()
};
workspaces.push(workspace);
}
(workspaces[0].id.clone(), None)
}
ImportDestination::ExistingWorkspace { workspace_id, folder_id } => {
for source in &resources.workspaces {
workspace_ids.insert(source.id.clone(), workspace_id.clone());
}
if !resources.workspaces.is_empty() {
let destination_workspace = query_manager.connect().get_workspace(workspace_id)?;
let skipped_fields = resources
.workspaces
.iter()
.flat_map(|source| {
workspace_fields_not_imported(source, &destination_workspace)
})
.collect::<BTreeSet<_>>();
if !skipped_fields.is_empty() {
let source = if resources.workspaces.len() == 1 {
resources.workspaces[0].name.clone()
} else {
format!("{} imported workspaces", resources.workspaces.len())
};
warnings.push(ImportPlanWarning {
title: "Workspace settings skipped".to_string(),
detail: format!("{source} · {}", display_list(&skipped_fields)),
});
}
}
(workspace_id.clone(), folder_id.clone())
}
};
let resolve_workspace_id = |source_id: &str| {
workspace_ids.get(source_id).cloned().unwrap_or_else(|| default_workspace_id.clone())
};
let resolve_folder_id = |source_id: Option<String>| match source_id {
Some(source_id) if source_folder_ids.contains(&source_id) => {
folder_ids.get(&source_id).cloned()
}
_ => target_folder_id.clone(),
};
let folders = resources
.folders
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<Folder>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
.map(|mut folder| {
folder.id = folder_ids.get(&folder.id).cloned().unwrap_or_else(Folder::generate_id);
folder.workspace_id = resolve_workspace_id(&folder.workspace_id);
folder.folder_id = resolve_folder_id(folder.folder_id);
folder
})
.collect();
let http_requests: Vec<HttpRequest> = resources
let http_requests = resources
.http_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<HttpRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
.map(|mut request| {
request.id = HttpRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
request
})
.collect();
let grpc_requests: Vec<GrpcRequest> = resources
let grpc_requests = resources
.grpc_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<GrpcRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
.map(|mut request| {
request.id = GrpcRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
request
})
.collect();
let websocket_requests: Vec<WebsocketRequest> = resources
let websocket_requests = resources
.websocket_requests
.into_iter()
.map(|mut v| {
v.id = maybe_gen_id::<WebsocketRequest>(&workspace_context, v.id.as_str(), &mut id_map);
v.workspace_id =
maybe_gen_id::<Workspace>(&workspace_context, v.workspace_id.as_str(), &mut id_map);
v.folder_id = maybe_gen_id_opt::<Folder>(&workspace_context, v.folder_id, &mut id_map);
v
.map(|mut request| {
request.id = WebsocketRequest::generate_id();
request.workspace_id = resolve_workspace_id(&request.workspace_id);
request.folder_id = resolve_folder_id(request.folder_id);
request
})
.collect();
info!("Importing data");
let importing_into_existing =
matches!(destination, ImportDestination::ExistingWorkspace { .. });
let mut separated_base_environments = Vec::new();
let mut converted_duplicate_base_environment = false;
let mut converted_duplicate_folder_environment = false;
let mut base_environment_workspaces = BTreeSet::new();
let mut folder_environment_ids = BTreeSet::new();
let environments = resources
.environments
.into_iter()
.map(|mut environment| {
environment.id = Environment::generate_id();
environment.workspace_id = resolve_workspace_id(&environment.workspace_id);
query_manager.with_tx(|tx| {
tx.batch_upsert(
match (environment.parent_model.as_str(), environment.parent_id.clone()) {
("workspace", _) if importing_into_existing => {
environment.parent_model = "environment".to_string();
environment.parent_id = None;
let source_name = environment.name.clone();
environment.name = format!("{} (Imported)", environment.name);
separated_base_environments.push((
source_name,
environment.name.clone(),
environment.variables.len(),
));
}
("workspace", _) => {
environment.parent_id = None;
if !base_environment_workspaces.insert(environment.workspace_id.clone()) {
environment.parent_model = "environment".to_string();
environment.name = format!("{} (Imported)", environment.name);
converted_duplicate_base_environment = true;
}
}
("folder", Some(parent_id)) if source_folder_ids.contains(&parent_id) => {
environment.parent_id = folder_ids.get(&parent_id).cloned();
if let Some(parent_id) = &environment.parent_id
&& !folder_environment_ids.insert(parent_id.clone())
{
environment.parent_model = "environment".to_string();
environment.parent_id = None;
converted_duplicate_folder_environment = true;
}
}
("folder", _) => {
// Never attach an imported folder environment to an existing folder: the model
// layer permits only one and would otherwise delete the destination's value.
environment.parent_model = "environment".to_string();
environment.parent_id = None;
}
("environment", _) => {
environment.parent_id = None;
}
_ => {
environment.parent_model = "environment".to_string();
environment.parent_id = None;
}
}
environment
})
.collect();
for (source_name, imported_name, variable_count) in separated_base_environments {
let variables = if variable_count == 1 { "variable" } else { "variables" };
warnings.push(ImportPlanWarning {
title: "Base environment kept separate".to_string(),
detail: format!("{source_name} → {imported_name} · {variable_count} {variables}"),
});
}
if converted_duplicate_base_environment {
warnings.push(ImportPlanWarning {
title: "Base environments separated".to_string(),
detail: "Only the first remains the base environment".to_string(),
});
}
if converted_duplicate_folder_environment {
warnings.push(ImportPlanWarning {
title: "Folder environments separated".to_string(),
detail: "Only the first remains attached to each folder".to_string(),
});
}
Ok(ImportPlan {
importer,
destination,
resources: BatchUpsertResult {
workspaces,
environments,
folders,
http_requests,
grpc_requests,
websocket_requests,
},
warnings,
})
}
/// Commit a previously prepared plan in one transaction.
pub fn commit_import_plan(
query_manager: &QueryManager,
plan: ImportPlan,
) -> Result<BatchUpsertResult> {
validate_plan(&plan)?;
let resources = plan.resources;
info!("Committing staged import from {}", plan.importer);
query_manager.with_tx(|tx| {
validate_destination_db(tx, &plan.destination)?;
tx.batch_upsert(
resources.workspaces,
resources.environments,
resources.folders,
resources.http_requests,
resources.grpc_requests,
resources.websocket_requests,
&UpdateSource::Import,
)
.map_err(crate::Error::from)
})
}
fn validate_destination(
query_manager: &QueryManager,
destination: &ImportDestination,
) -> Result<()> {
let db = query_manager.connect();
validate_destination_db(&db, destination)
}
fn validate_destination_db(db: &ClientDb<'_>, destination: &ImportDestination) -> Result<()> {
let ImportDestination::ExistingWorkspace { workspace_id, folder_id } = destination else {
return Ok(());
};
db.get_workspace(workspace_id)?;
if let Some(folder_id) = folder_id {
let folder = db.get_folder(folder_id)?;
if folder.workspace_id != *workspace_id {
return Err(yaak_models::error::Error::GenericError(format!(
"Folder {folder_id} does not belong to workspace {workspace_id}"
))
.into());
}
}
Ok(())
}
fn validate_plan(plan: &ImportPlan) -> Result<()> {
let invalid = |message: String| -> Result<()> {
Err(yaak_models::error::Error::GenericError(message).into())
};
match &plan.destination {
ImportDestination::ExistingWorkspace { workspace_id, .. } => {
if !plan.resources.workspaces.is_empty() {
return invalid(
"An existing-workspace import plan must not contain workspace updates"
.to_string(),
);
}
let all_workspace_ids = plan
.resources
.environments
.iter()
.map(|v| &v.workspace_id)
.chain(plan.resources.folders.iter().map(|v| &v.workspace_id))
.chain(plan.resources.http_requests.iter().map(|v| &v.workspace_id))
.chain(plan.resources.grpc_requests.iter().map(|v| &v.workspace_id))
.chain(plan.resources.websocket_requests.iter().map(|v| &v.workspace_id));
if all_workspace_ids.into_iter().any(|id| id != workspace_id) {
return invalid(
"An existing-workspace import plan contains resources for another workspace"
.to_string(),
);
}
if plan.resources.environments.iter().any(|v| v.parent_model == "workspace") {
return invalid(
"An existing-workspace import plan must not replace the base environment"
.to_string(),
);
}
}
ImportDestination::NewWorkspace => {
let workspace_ids =
plan.resources.workspaces.iter().map(|v| v.id.as_str()).collect::<BTreeSet<_>>();
if workspace_ids.is_empty() {
return invalid("A new-workspace import plan has no workspace".to_string());
}
let all_workspace_ids = plan
.resources
.environments
.iter()
.map(|v| v.workspace_id.as_str())
.chain(plan.resources.folders.iter().map(|v| v.workspace_id.as_str()))
.chain(plan.resources.http_requests.iter().map(|v| v.workspace_id.as_str()))
.chain(plan.resources.grpc_requests.iter().map(|v| v.workspace_id.as_str()))
.chain(plan.resources.websocket_requests.iter().map(|v| v.workspace_id.as_str()));
if all_workspace_ids.into_iter().any(|id| !workspace_ids.contains(id)) {
return invalid(
"A new-workspace import plan contains resources outside its workspaces"
.to_string(),
);
}
let mut base_environment_workspaces = BTreeSet::new();
if plan.resources.environments.iter().any(|v| {
v.parent_model == "workspace"
&& !base_environment_workspaces.insert(v.workspace_id.as_str())
}) {
return invalid(
"A new-workspace import plan contains multiple base environments for one workspace"
.to_string(),
);
}
}
}
let planned_folder_ids =
plan.resources.folders.iter().map(|v| v.id.as_str()).collect::<BTreeSet<_>>();
if plan.resources.environments.iter().any(|v| {
v.parent_model == "folder"
&& v.parent_id.as_deref().is_none_or(|id| !planned_folder_ids.contains(id))
}) {
return invalid(
"An import plan must not replace an existing folder environment".to_string(),
);
}
Ok(())
}
fn display_importer_name(importer: &str) -> &str {
importer.strip_prefix("@yaak/importer-").unwrap_or(importer)
}
fn workspace_fields_not_imported(source: &Workspace, destination: &Workspace) -> Vec<&'static str> {
let mut fields = Vec::new();
if source.name != destination.name {
fields.push("workspace name");
}
if source.description != destination.description {
fields.push("description");
}
if source.authentication != destination.authentication
|| source.authentication_type != destination.authentication_type
{
fields.push("authentication");
}
if source.headers != destination.headers {
fields.push("default headers");
}
if source.encryption_key_challenge != destination.encryption_key_challenge {
fields.push("encryption configuration");
}
if source.setting_validate_certificates != destination.setting_validate_certificates {
fields.push("certificate validation");
}
if source.setting_follow_redirects != destination.setting_follow_redirects {
fields.push("redirect behavior");
}
if source.setting_request_timeout != destination.setting_request_timeout {
fields.push("request timeout");
}
if source.setting_request_message_size != destination.setting_request_message_size {
fields.push("request message size");
}
if source.setting_dns_overrides != destination.setting_dns_overrides {
fields.push("DNS overrides");
}
if source.setting_send_cookies != destination.setting_send_cookies
|| source.setting_store_cookies != destination.setting_store_cookies
{
fields.push("cookie behavior");
}
fields
}
fn display_list(items: &BTreeSet<&str>) -> String {
let items = items.iter().copied().collect::<Vec<_>>();
match items.as_slice() {
[] => String::new(),
[item] => (*item).to_string(),
[first, second] => format!("{first} and {second}"),
_ => format!("{}, and {}", items[..items.len() - 1].join(", "), items[items.len() - 1]),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use yaak_models::models::{EnvironmentVariable, HttpRequestHeader};
fn destination_workspace() -> Workspace {
Workspace {
id: "wk_destination".to_string(),
model: "workspace".to_string(),
name: "Destination".to_string(),
authentication: BTreeMap::from([("token".to_string(), json!("keep-me"))]),
authentication_type: Some("bearer".to_string()),
headers: vec![HttpRequestHeader {
enabled: true,
name: "X-Destination".to_string(),
value: "preserved".to_string(),
id: None,
}],
setting_validate_certificates: false,
setting_follow_redirects: false,
setting_request_timeout: 1234,
..Default::default()
}
}
fn imported_resources() -> ImportResources {
ImportResources {
workspaces: vec![Workspace {
id: "wk_source".to_string(),
model: "workspace".to_string(),
name: "Imported".to_string(),
authentication_type: Some("basic".to_string()),
setting_validate_certificates: true,
..Default::default()
}],
environments: vec![Environment {
id: "ev_source_base".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Global Variables".to_string(),
parent_model: "workspace".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "imported".to_string(),
value: "yes".to_string(),
id: None,
}],
..Default::default()
}],
folders: vec![Folder {
id: "fl_source".to_string(),
model: "folder".to_string(),
workspace_id: "wk_source".to_string(),
name: "Imported Folder".to_string(),
..Default::default()
}],
http_requests: vec![
HttpRequest {
id: "rq_root".to_string(),
model: "http_request".to_string(),
workspace_id: "wk_source".to_string(),
name: "Root Request".to_string(),
method: "GET".to_string(),
url: "https://example.com/root".to_string(),
..Default::default()
},
HttpRequest {
id: "rq_nested".to_string(),
model: "http_request".to_string(),
workspace_id: "wk_source".to_string(),
folder_id: Some("fl_source".to_string()),
name: "Nested Request".to_string(),
method: "GET".to_string(),
url: "https://example.com/nested".to_string(),
..Default::default()
},
],
..Default::default()
}
}
#[test]
fn existing_workspace_plan_does_not_mutate_and_preserves_workspace_settings() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut destination = destination_workspace();
let selected_folder = Folder {
id: "fl_selected".to_string(),
model: "folder".to_string(),
workspace_id: destination.id.clone(),
name: "Selected Folder".to_string(),
..Default::default()
};
{
let db = query_manager.connect();
destination = db
.upsert_workspace(&destination, &UpdateSource::Import)
.expect("create destination");
db.upsert_folder(&selected_folder, &UpdateSource::Import)
.expect("create selected folder");
db.upsert_environment(
&Environment {
id: "ev_destination_base".to_string(),
model: "environment".to_string(),
workspace_id: destination.id.clone(),
name: "Destination Variables".to_string(),
parent_model: "workspace".to_string(),
variables: vec![EnvironmentVariable {
enabled: true,
name: "destination".to_string(),
value: "keep".to_string(),
id: None,
}],
..Default::default()
},
&UpdateSource::Import,
)
.expect("create base environment");
}
let plan = plan_import_resources(
&query_manager,
"OpenAPI".to_string(),
ImportDestination::ExistingWorkspace {
workspace_id: destination.id.clone(),
folder_id: Some(selected_folder.id.clone()),
},
imported_resources(),
)
.expect("plan import");
// Planning performed only reads.
{
let db = query_manager.connect();
assert_eq!(db.list_workspaces().expect("list workspaces").len(), 1);
assert_eq!(db.list_folders(&destination.id).expect("list folders").len(), 1);
assert!(db.list_http_requests(&destination.id).expect("list requests").is_empty());
assert_eq!(
db.list_environments_ensure_base(&destination.id).expect("list environments").len(),
1
);
assert_eq!(db.get_workspace(&destination.id).expect("get destination"), destination);
}
assert!(plan.resources.workspaces.is_empty());
assert_eq!(plan.resources.folders[0].workspace_id, destination.id);
assert_eq!(
plan.resources.folders[0].folder_id.as_deref(),
Some(selected_folder.id.as_str())
);
let root_request = plan
.resources
.http_requests
.iter()
.find(|v| v.name == "Root Request")
.expect("root request");
assert_eq!(root_request.folder_id.as_deref(), Some(selected_folder.id.as_str()));
let nested_request = plan
.resources
.http_requests
.iter()
.find(|v| v.name == "Nested Request")
.expect("nested request");
assert_eq!(nested_request.folder_id, Some(plan.resources.folders[0].id.clone()));
assert_eq!(plan.resources.environments[0].parent_model, "environment");
assert!(plan.resources.environments[0].name.ends_with("(Imported)"));
assert_eq!(plan.warnings.len(), 2);
assert!(plan.warnings.iter().any(|warning| {
warning.title == "Workspace settings skipped"
&& warning.detail.starts_with("Imported ·")
&& warning.detail.contains("authentication")
&& warning.detail.contains("default headers")
}));
assert!(plan.warnings.iter().any(|warning| {
warning.title == "Base environment kept separate"
&& warning.detail == "Global Variables → Global Variables (Imported) · 1 variable"
}));
let committed = commit_import_plan(&query_manager, plan).expect("commit import");
assert!(committed.workspaces.is_empty());
assert_eq!(committed.http_requests.len(), 2);
assert_eq!(
query_manager
.connect()
.get_workspace(&destination.id)
.expect("get destination after commit"),
destination
);
}
#[test]
fn environment_collisions_are_explicit_and_do_not_overwrite() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let mut resources = imported_resources();
resources.environments.extend([
Environment {
id: "ev_second_base".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Second Base".to_string(),
parent_model: "workspace".to_string(),
..Default::default()
},
Environment {
id: "ev_folder_one".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Folder One".to_string(),
parent_model: "folder".to_string(),
parent_id: Some("fl_source".to_string()),
..Default::default()
},
Environment {
id: "ev_folder_two".to_string(),
model: "environment".to_string(),
workspace_id: "wk_source".to_string(),
name: "Folder Two".to_string(),
parent_model: "folder".to_string(),
parent_id: Some("fl_source".to_string()),
..Default::default()
},
]);
let plan = plan_import_resources(
&query_manager,
"Yaak".to_string(),
ImportDestination::NewWorkspace,
resources,
)
.expect("plan import");
assert_eq!(
plan.resources.environments.iter().filter(|v| v.parent_model == "workspace").count(),
1
);
assert_eq!(
plan.resources.environments.iter().filter(|v| v.parent_model == "folder").count(),
1
);
assert_eq!(plan.warnings.len(), 2);
}
#[test]
fn importer_id_conventions_all_flow_through_the_same_planner() {
let (query_manager, _blob_manager, _rx) =
yaak_models::init_in_memory().expect("initialize database");
let destination = destination_workspace();
query_manager
.connect()
.upsert_workspace(&destination, &UpdateSource::Import)
.expect("create destination");
let resources = ImportResources {
workspaces: vec![
Workspace {
id: "GENERATE_ID::WORKSPACE_0".to_string(),
model: "workspace".to_string(),
name: "Generated ID Importer".to_string(),
..Default::default()
},
Workspace {
id: "wk_exported".to_string(),
model: "workspace".to_string(),
name: "Stable ID Importer".to_string(),
..Default::default()
},
],
http_requests: [
"GENERATE_ID::WORKSPACE_0",
"wk_exported",
"CURRENT_WORKSPACE",
]
.into_iter()
.enumerate()
.map(|(index, workspace_id)| HttpRequest {
id: format!("GENERATE_ID::HTTP_REQUEST_{index}"),
model: "http_request".to_string(),
workspace_id: workspace_id.to_string(),
name: format!("Request {index}"),
method: "GET".to_string(),
..Default::default()
})
.collect(),
..Default::default()
};
let plan = plan_import_resources(
&query_manager,
"Compatibility".to_string(),
ImportDestination::ExistingWorkspace {
workspace_id: destination.id.clone(),
folder_id: None,
},
resources,
)
.expect("plan import");
assert!(plan.resources.workspaces.is_empty());
assert!(plan.resources.http_requests.iter().all(|v| v.workspace_id == destination.id));
assert_eq!(
plan.resources
.http_requests
.iter()
.map(|v| v.id.as_str())
.collect::<BTreeSet<_>>()
.len(),
3
);
}
#[test]
fn commit_rolls_back_every_resource_when_a_late_write_fails() {
let dir = tempfile::tempdir().expect("create temp directory");
let db_path = dir.path().join("models.sqlite");
let blob_path = dir.path().join("blobs.sqlite");
let (query_manager, _blob_manager, _rx) =
yaak_models::init_standalone(&db_path, &blob_path).expect("initialize database");
let plan = plan_import_resources(
&query_manager,
"OpenAPI".to_string(),
ImportDestination::NewWorkspace,
imported_resources(),
)
.expect("plan import");
let workspace_id = plan.resources.workspaces[0].id.clone();
let environment_id = plan.resources.environments[0].id.clone();
let connection = rusqlite::Connection::open(&db_path).expect("open test database");
connection
.execute_batch(&format!(
"CREATE TRIGGER fail_import_environment BEFORE INSERT ON environments \
WHEN NEW.id = '{environment_id}' BEGIN SELECT RAISE(FAIL, 'forced failure'); END;"
))
.expect("install failure trigger");
drop(connection);
assert!(commit_import_plan(&query_manager, plan).is_err());
let db = query_manager.connect();
assert!(db.get_workspace(&workspace_id).is_err(), "workspace insert must roll back");
assert!(db.get_environment(&environment_id).is_err(), "environment must not exist");
}
}
+1
View File
@@ -267,6 +267,7 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
// Anything that needs files the page can't reach.
cmd_import_data: ["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_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
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"],
+1 -1
View File
@@ -474,7 +474,7 @@ export type ImportRequest = { content: string, };
export type ImportResources = { workspaces: Array<Workspace>, environments: Array<Environment>, folders: Array<Folder>, httpRequests: Array<HttpRequest>, grpcRequests: Array<GrpcRequest>, websocketRequests: Array<WebsocketRequest>, };
export type ImportResponse = { resources: ImportResources, };
export type ImportResponse = { importer: string, resources: ImportResources, };
export type InternalEvent = { id: string, pluginRefId: string, pluginName: string, replyId: string | null, context: PluginContext, payload: InternalEventPayload, };
@@ -167,6 +167,7 @@ export class PluginInstance {
if (reply != null) {
const replyPayload: InternalEventPayload = {
type: "import_response",
importer: this.#mod.importer.name,
resources: reply.resources as ImportResources,
};
this.#sendPayload(context, replyPayload, replyId);
+4 -1
View File
@@ -5,7 +5,7 @@ import { forwardRef } from "react";
import { Icon } from "./Icon";
import { LoadingIcon } from "./LoadingIcon";
type ButtonVariant = "border" | "solid";
type ButtonVariant = "border" | "solid" | "input";
type ButtonSize = "2xs" | "xs" | "sm" | "md" | "auto";
export type ButtonProps = Omit<HTMLAttributes<HTMLButtonElement>, "color" | "onChange"> & {
@@ -88,6 +88,9 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
resolvedColor !== "custom" &&
"border-border-subtle text-text-subtle enabled:hocus:border-border " +
"enabled:hocus:bg-surface-highlight enabled:hocus:text-text outline-border-subtler",
// Chrome of a form input rather than a button: no hover state, and colors resolve from the
// surrounding x-theme-input rather than a button theme.
variant === "input" && "border-border text-text",
)}
disabled={isDisabled}
onClick={onClick}