Compare commits

..
383 changed files with 9441 additions and 31894 deletions

No files matched your search

-20
View File
@@ -1,20 +0,0 @@
{
"name": "yaak",
"interface": {
"displayName": "Yaak"
},
"plugins": [
{
"name": "yaak",
"source": {
"source": "local",
"path": "./crates-cli/yaak-cli"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Developer Tools"
}
]
}
-18
View File
@@ -1,18 +0,0 @@
{
"$schema": "https://json.schemastore.org/claude-code-marketplace.json",
"name": "yaak",
"description": "Plugins for Yaak, the desktop API client",
"owner": {
"name": "Yaak",
"url": "https://yaak.app"
},
"plugins": [
{
"name": "yaak",
"source": "./crates-cli/yaak-cli",
"description": "Build, save, and send API requests with the yaak CLI, with its MCP server for agents",
"category": "development",
"tags": ["api", "http", "rest", "graphql", "grpc", "openapi", "api-client"]
}
]
}
-26
View File
@@ -35,29 +35,3 @@ jobs:
run: |
cargo build -p yaak-cli
node plugins/importer-openapi/tests/roundtrip.mjs
test-windows:
name: Rust Tests (Windows)
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: voidzero-dev/setup-vp@v1
with:
node-version: "24"
cache: true
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
shared-key: ci
cache-on-failure: true
- name: Install Protoc for plugin-runtime
uses: arduino/setup-protoc@v3
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- run: vp install
- run: npm run bootstrap
env:
YAAK_TARGET_ARCH: x64
- name: Run Rust Tests
run: cargo test --all --exclude yaak-cli --features yaak-app-client/wry
Generated
+482 -572
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -2,6 +2,8 @@
resolver = "2"
members = [
"crates/yaak",
"crates/yaak-assertions",
"crates/yaak-jsonpath",
"crates/yaak-commands",
# Common/foundation crates
"crates/common/yaak-database",
@@ -11,12 +13,10 @@ members = [
"crates/yaak-core",
"crates/yaak-common",
"crates/yaak-crypto",
"crates/yaak-filter",
"crates/yaak-git",
"crates/yaak-grpc",
"crates/yaak-http",
"crates/yaak-lifecycle",
"crates/yaak-mcp",
"crates/yaak-models",
"crates/yaak-plugins",
"crates/yaak-sse",
@@ -59,7 +59,7 @@ reqwest = { version = "0.13.5", default-features = false, features = [
"native-tls",
] }
rustls = { version = "0.23.34", default-features = false }
rustls-platform-verifier = "0.7.1"
rustls-platform-verifier = "0.6.2"
schemars = { version = "0.8.22", features = ["chrono"] }
serde = "1.0.228"
serde_json = "1.0.145"
@@ -84,15 +84,15 @@ yaak-rpc-schema = { path = "crates/common/yaak-rpc-schema" }
# Internal crates - shared
yaak-core = { path = "crates/yaak-core" }
yaak = { path = "crates/yaak" }
yaak-assertions = { path = "crates/yaak-assertions" }
yaak-jsonpath = { path = "crates/yaak-jsonpath" }
yaak-commands = { path = "crates/yaak-commands" }
yaak-common = { path = "crates/yaak-common" }
yaak-crypto = { path = "crates/yaak-crypto" }
yaak-filter = { path = "crates/yaak-filter" }
yaak-git = { path = "crates/yaak-git" }
yaak-grpc = { path = "crates/yaak-grpc" }
yaak-http = { path = "crates/yaak-http" }
yaak-lifecycle = { path = "crates/yaak-lifecycle" }
yaak-mcp = { path = "crates/yaak-mcp" }
yaak-models = { path = "crates/yaak-models" }
yaak-plugins = { path = "crates/yaak-plugins" }
yaak-sse = { path = "crates/yaak-sse" }
-148
View File
@@ -1,148 +0,0 @@
import type { FunctionComponent, ReactElement } from "react";
import { beforeEach, expect, test, vi } from "vite-plus/test";
import type { SyncOp } from "@yaakapp-internal/sync/bindings/gen_sync";
import type { DialogInstance } from "../components/Dialogs";
import { syncWorkspace } from "./commands";
const mocks = vi.hoisted(() => ({
calculateSync: vi.fn(),
applySync: vi.fn(),
showDialog: vi.fn<(dialog: DialogInstance) => void>(),
hideDialog: vi.fn(),
showToast: vi.fn(),
}));
vi.mock("@yaakapp-internal/sync", () => mocks);
vi.mock("@yaakapp-internal/models", () => ({
createWorkspaceModel: vi.fn(),
modelTypeLabel: () => "Request",
}));
vi.mock("@yaakapp-internal/ui", () => ({
Banner: "div",
InlineCode: "code",
Table: "table",
TableBody: "tbody",
TableCell: "td",
TableHead: "thead",
TableHeaderCell: "th",
TableRow: "tr",
TruncatedWideTableCell: "td",
}));
vi.mock("../components/core/Button", () => ({ Button: "button" }));
vi.mock("../hooks/useActiveWorkspace", () => ({ activeWorkspaceIdAtom: null }));
vi.mock("../lib/dialog", () => mocks);
vi.mock("../lib/jotai", () => ({ jotaiStore: { get: vi.fn() } }));
vi.mock("../lib/prompt", () => ({ showPrompt: vi.fn() }));
vi.mock("../lib/resolvedModelName", () => ({ resolvedModelNameWithFolders: () => "Request" }));
vi.mock("../lib/toast", () => mocks);
const staleToast = expect.objectContaining({
message: "Files changed while syncing. Review the changes again.",
});
const params = { workspaceId: "wk_test", syncDir: "/sync" };
const deletion = {
type: "dbDelete",
model: { id: "rq_test", model: "http_request" },
state: {},
} as Extract<SyncOp, { type: "dbDelete" }>;
const update = {
type: "dbUpdate",
model: { id: "rq_test", model: "http_request" },
fs: { model: { id: "rq_test", model: "http_request", name: "Updated" } },
state: {},
} as Extract<SyncOp, { type: "dbUpdate" }>;
async function submit(dialog: DialogInstance) {
const render = dialog.render as FunctionComponent<{ hide: () => void }>;
const form = render({ hide: () => mocks.hideDialog(dialog.id) }) as ReactElement<{
onSubmit: (event: { preventDefault: () => void }) => Promise<void>;
}>;
await form.props.onSubmit({ preventDefault: vi.fn() });
}
function latestDialog() {
const dialog = mocks.showDialog.mock.calls.at(-1)?.[0];
if (dialog == null) throw new Error("Expected a sync dialog");
return dialog;
}
beforeEach(() => {
vi.resetAllMocks();
mocks.applySync.mockResolvedValue(true);
});
test("closes an obsolete dialog when the restored file leaves no changes", async () => {
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([]);
await syncWorkspace.mutateAsync(params);
mocks.applySync.mockResolvedValueOnce(false);
await submit(latestDialog());
expect(mocks.applySync).toHaveBeenCalledTimes(1);
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [deletion]);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
});
test("requires another confirmation for a changed plan", async () => {
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
await syncWorkspace.mutateAsync(params);
const original = latestDialog();
mocks.applySync.mockResolvedValueOnce(false);
await submit(original);
expect(mocks.applySync).toHaveBeenCalledTimes(1);
expect(mocks.showDialog).toHaveBeenCalledTimes(2);
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
expect(mocks.hideDialog).not.toHaveBeenCalled();
await submit(latestDialog());
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
});
test("applies an unchanged reviewed plan and closes the dialog", async () => {
mocks.calculateSync.mockResolvedValue([update]);
await syncWorkspace.mutateAsync(params);
await submit(latestDialog());
expect(mocks.calculateSync).toHaveBeenCalledTimes(1);
expect(mocks.applySync).toHaveBeenCalledTimes(1);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
});
test("forced sync retries the current plan without requesting another confirmation", async () => {
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([update]);
mocks.applySync.mockResolvedValueOnce(false);
await syncWorkspace.mutateAsync({ ...params, force: true });
expect(mocks.applySync).toHaveBeenCalledTimes(2);
expect(mocks.applySync).toHaveBeenLastCalledWith(params.workspaceId, params.syncDir, [update]);
expect(mocks.showDialog).not.toHaveBeenCalled();
});
test("outbound-only changes clear the old dialog and sync automatically", async () => {
const outbound = { type: "fsUpdate", model: update.model, state: {} } as SyncOp;
mocks.calculateSync.mockResolvedValueOnce([deletion]).mockResolvedValueOnce([outbound]);
await syncWorkspace.mutateAsync(params);
await syncWorkspace.mutateAsync(params);
expect(mocks.applySync).toHaveBeenCalledWith(params.workspaceId, params.syncDir, [outbound]);
expect(mocks.hideDialog).toHaveBeenCalledWith("commit-sync");
expect(mocks.showDialog).toHaveBeenCalledTimes(1);
});
test("stops retrying a plan that keeps going stale", async () => {
mocks.calculateSync.mockResolvedValue([update]);
mocks.applySync.mockResolvedValue(false);
await syncWorkspace.mutateAsync({ ...params, force: true });
expect(mocks.calculateSync).toHaveBeenCalledTimes(3);
expect(mocks.applySync).toHaveBeenCalledTimes(3);
expect(mocks.showToast).toHaveBeenCalledTimes(1);
expect(mocks.showToast).toHaveBeenCalledWith(staleToast);
expect(mocks.showDialog).not.toHaveBeenCalled();
});
+11 -34
View File
@@ -14,12 +14,11 @@ import {
} from "@yaakapp-internal/ui";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { hideDialog, showDialog } from "../lib/dialog";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from "../lib/pluralize";
import { showPrompt } from "../lib/prompt";
import { resolvedModelNameWithFolders } from "../lib/resolvedModelName";
import { showToast } from "../lib/toast";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export const createFolder = createFastMutation<
string | null,
@@ -53,48 +52,24 @@ export const createFolder = createFastMutation<
},
});
// Files can keep changing under a plan, so stop recalculating after a few stale applies.
const MAX_SYNC_ATTEMPTS = 3;
function showStaleSyncToast() {
showToast({
id: "sync-stale",
color: "warning",
message: "Files changed while syncing. Review the changes again.",
});
}
export const syncWorkspace = createFastMutation<
void,
void,
{ workspaceId: string; syncDir: string; force?: boolean; attempt?: number }
{ workspaceId: string; syncDir: string; force?: boolean }
>({
mutationKey: [],
mutationFn: async ({ workspaceId, syncDir, force, attempt = 1 }) => {
mutationFn: async ({ workspaceId, syncDir, force }) => {
const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
if (ops.length === 0) {
console.log("Nothing to sync", workspaceId, syncDir);
hideDialog("commit-sync");
return;
}
console.log("Syncing workspace", workspaceId, syncDir, ops);
const dbOps = ops.filter((o) => o.type.startsWith("db"));
const apply = async () => {
if (await applySync(workspaceId, syncDir, ops)) {
hideDialog("commit-sync");
} else if (attempt < MAX_SYNC_ATTEMPTS) {
await syncWorkspace.mutateAsync({ workspaceId, syncDir, force, attempt: attempt + 1 });
} else {
hideDialog("commit-sync");
showStaleSyncToast();
}
};
if (dbOps.length === 0 || force) {
hideDialog("commit-sync");
await apply();
if (dbOps.length === 0) {
await applySync(workspaceId, syncDir, ops);
return;
}
@@ -104,8 +79,9 @@ export const syncWorkspace = createFastMutation<
console.log("Directory changes detected", { dbOps, ops });
if (attempt > 1) {
showStaleSyncToast();
if (force) {
await applySync(workspaceId, syncDir, ops);
return;
}
showDialog({
@@ -117,7 +93,8 @@ export const syncWorkspace = createFastMutation<
className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3"
onSubmit={async (e) => {
e.preventDefault();
await apply();
await applySync(workspaceId, syncDir, ops);
hide();
}}
>
{isDeletingWorkspace ? (
@@ -6,12 +6,11 @@ import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
/**
* Prompt for and create a sub-environment of the given base environment, resolving with the new
* environment's ID (or null if the dialog was dismissed). This does not change the workspace's
* active environment.
*/
export const createSubEnvironment = createFastMutation<string | null, unknown, Environment | null>({
export const createSubEnvironmentAndActivate = createFastMutation<
string | null,
unknown,
Environment | null
>({
mutationKey: ["create_environment"],
mutationFn: async (baseEnvironment) => {
if (baseEnvironment == null) {
@@ -42,17 +41,6 @@ export const createSubEnvironment = createFastMutation<string | null, unknown, E
});
});
},
});
/** Same as {@link createSubEnvironment}, but also makes the new environment the active one. */
export const createSubEnvironmentAndActivate = createFastMutation<
string | null,
unknown,
Environment | null
>({
mutationKey: ["create_environment_and_activate"],
mutationFn: (baseEnvironment) => createSubEnvironment.mutateAsync(baseEnvironment),
disableToastError: true, // Already shown by createSubEnvironment
onSuccess: async (environmentId) => {
if (environmentId == null) {
return; // Was not created
@@ -3,9 +3,9 @@ import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-intern
import { MoveToWorkspaceDialog } from "../components/MoveToWorkspaceDialog";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { pluralizeCount } from "../lib/pluralize";
import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export const moveToWorkspace = createFastMutation({
mutationKey: ["move_workspace"],
@@ -17,7 +17,7 @@ export const openWorkspaceFromSyncDir = createFastMutation<void, void, string>({
return;
}
await applySync(workspace.id, dir, ops, { fsOnly: true });
await applySync(workspace.id, dir, ops);
await router.navigate({
to: "/workspaces/$workspaceId",
@@ -1,11 +1,12 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { useToggle } from "../hooks/useToggle";
import { showConfirm } from "../lib/confirm";
import { Button } from "./core/Button";
import { Link } from "./core/Link";
import { SizeTag } from "./core/SizeTag";
interface Props {
children: ReactNode;
@@ -1,5 +1,5 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { useSaveResponse } from "../hooks/useSaveResponse";
import { useToggle } from "../hooks/useToggle";
@@ -8,6 +8,7 @@ import { getContentTypeFromHeaders } from "../lib/model_util";
import { getResponseBodyText } from "../lib/responseBody";
import { CopyButton } from "./CopyButton";
import { Button } from "./core/Button";
import { SizeTag } from "./core/SizeTag";
interface Props {
children: ReactNode;
@@ -1,5 +1,5 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, SizeTag } from "@yaakapp-internal/ui";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { type ReactNode, useMemo } from "react";
import { getRequestBodyText as getHttpResponseRequestBodyText } from "../hooks/useHttpRequestBody";
import { useToggle } from "../hooks/useToggle";
@@ -7,6 +7,7 @@ import { isProbablyTextContentType } from "../lib/contentType";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { CopyButton } from "./CopyButton";
import { Button } from "./core/Button";
import { SizeTag } from "./core/SizeTag";
interface Props {
children: ReactNode;
+1 -2
View File
@@ -25,13 +25,12 @@ import {
TableHeaderCell,
TableRow,
TruncatedWideTableCell,
KeyValueRows,
} from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
import { Checkbox } from "./core/Checkbox";
import classNames from "classnames";
import { EventDetailHeader } from "./core/EventViewer";
import { KeyValueRow } from "./core/KeyValueRow";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
+25 -16
View File
@@ -1,22 +1,31 @@
import {
CopyIconButton as BaseCopyIconButton,
type CopyIconButtonProps,
} from "@yaakapp-internal/ui";
import { IconButton, type IconButtonProps, useTimedBoolean } from "@yaakapp-internal/ui";
import { copyToClipboard } from "../lib/copy";
import { showToast } from "../lib/toast";
export function CopyIconButton(props: Omit<CopyIconButtonProps, "copy" | "onCopyFailed">) {
return <BaseCopyIconButton {...props} copy={copy} onCopyFailed={showCopyFailed} />;
interface Props extends Omit<IconButtonProps, "onClick" | "icon"> {
text: string | (() => Promise<string | null>);
}
function copy(text: string) {
copyToClipboard(text, { disableToast: true });
}
function showCopyFailed() {
showToast({
id: "failed-to-copy",
color: "danger",
message: "Failed to copy",
});
export function CopyIconButton({ text, ...props }: Props) {
const [copied, setCopied] = useTimedBoolean();
return (
<IconButton
{...props}
icon={copied ? "check" : "copy"}
showConfirm
onClick={async () => {
const content = typeof text === "function" ? await text() : text;
if (content == null) {
showToast({
id: "failed-to-copy",
color: "danger",
message: "Failed to copy",
});
} else {
copyToClipboard(content, { disableToast: true });
setCopied();
}
}}
/>
);
}
@@ -5,8 +5,8 @@ import { ColorIndicator } from "./ColorIndicator";
import { Checkbox } from "./core/Checkbox";
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
import { DialogFooter } from "./core/Dialog";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { Label } from "@yaakapp-internal/ui";
interface Props {
onCreate: (id: string) => void;
@@ -1,7 +1,7 @@
import { gitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
import { VStack, Label } from "@yaakapp-internal/ui";
import { VStack } from "@yaakapp-internal/ui";
import { useId, useState } from "react";
import { router } from "../lib/router";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
@@ -9,6 +9,7 @@ import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { EncryptionHelp } from "./EncryptionHelp";
import { gitCallbacks } from "./git/callbacks";
+2 -1
View File
@@ -11,7 +11,7 @@ import type {
FormInputText,
JsonPrimitive,
} from "@yaakapp-internal/plugins";
import { Banner, VStack, Label } from "@yaakapp-internal/ui";
import { Banner, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
@@ -26,6 +26,7 @@ import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import type { InputProps } from "./core/Input";
import { Input } from "./core/Input";
import { Label } from "./core/Label";
import type { Pair } from "./core/PairEditor";
import { PairEditor } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
@@ -1,19 +1,11 @@
import type { Environment, Workspace } from "@yaakapp-internal/models";
import { duplicateModel, patchModel } from "@yaakapp-internal/models";
import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SplitLayout, Tree, IconTooltip } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import { atomFamily } from "jotai-family";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createSubEnvironment } from "../commands/createEnvironment";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import {
environmentsBreakdownAtom,
@@ -30,6 +22,7 @@ import { showColorPicker } from "../lib/showColorPicker";
import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
import { ContextMenu } from "./core/Dropdown";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import type { PairEditorHandle } from "./core/PairEditor";
import { EnvironmentColorIndicator } from "./EnvironmentColorIndicator";
import { EnvironmentEditor } from "./EnvironmentEditor";
@@ -47,9 +40,6 @@ interface Props {
type TreeModel = Environment | Workspace;
/** Lets tree row slots create a sub-environment and select it in the editor. */
const CreateSubEnvironmentContext = createContext<() => Promise<void>>(async () => {});
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
@@ -125,7 +115,7 @@ function EnvironmentEditDialogSidebar({
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
const treeId = `environment.${activeWorkspaceId}.sidebar`;
const treeRef = useRef<TreeHandle>(null);
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useLayoutEffect(() => {
@@ -134,31 +124,6 @@ function EnvironmentEditDialogSidebar({
treeRef.current?.focus();
}, []);
// A just-created environment to select once it reaches the model store. Selecting it before
// then would show a "failed to find" error in the editor, and the tree would drop the
// selection of an item it doesn't have yet.
const [pendingSelectionId, setPendingSelectionId] = useState<string | null>(null);
const tree = useAtomValue(treeAtom);
useEffect(() => {
if (pendingSelectionId == null) return;
if (!allEnvironments.some((e) => e.id === pendingSelectionId)) return;
setPendingSelectionId(null);
setSelectedEnvironmentId(pendingSelectionId);
// The tree leaves out sub-environments while there are multiple base environments
const node = tree == null ? null : findTreeNode(tree, pendingSelectionId);
if (node == null) return;
if (node.parent != null) {
// Expand the parent so the new item is visible to be selected
const parentId = node.parent.item.id;
jotaiStore.set(collapsedFamily(treeId), (prev) => ({ ...prev, [parentId]: false }));
}
// Wait a frame so the tree's own pending selection fixups, which may have been scheduled
// before the new item arrived, don't reset this selection
requestAnimationFrame(() => treeRef.current?.selectItem(pendingSelectionId));
}, [allEnvironments, pendingSelectionId, setSelectedEnvironmentId, tree, treeId]);
const handleDeleteEnvironment = useCallback(
async (environment: Environment) => {
await deleteModelWithConfirm(environment);
@@ -169,14 +134,6 @@ function EnvironmentEditDialogSidebar({
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
);
const handleCreateSubEnvironment = useCallback(async () => {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironment.mutateAsync(baseEnvironment);
// Show the new environment in the editor without changing the workspace's active environment
if (id != null) setPendingSelectionId(id);
}, []);
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const getSelectedTreeModels = useCallback(
@@ -238,14 +195,13 @@ function EnvironmentEditDialogSidebar({
const addEnvironmentItem: DropdownItem = {
label: "Create Sub Environment",
leftSlot: <Icon icon="plus" />,
onSelect: handleCreateSubEnvironment,
onSelect: async () => {
await createSubEnvironment();
},
};
// Sub-environments aren't shown while there are multiple base environments, same as the
// plus button on the base environment row
const canCreateSubEnvironment = baseEnvironments.length <= 1;
if (environment == null || environment.model !== "environment") {
return canCreateSubEnvironment ? [addEnvironmentItem] : [];
return [addEnvironmentItem];
}
const singleEnvironment = items.length === 1;
@@ -301,7 +257,7 @@ function EnvironmentEditDialogSidebar({
];
// Add sub environment to base environment
if (isBaseEnvironment(environment) && singleEnvironment && canCreateSubEnvironment) {
if (isBaseEnvironment(environment) && singleEnvironment) {
menuItems.push({ type: "separator" });
menuItems.push(addEnvironmentItem);
}
@@ -310,7 +266,6 @@ function EnvironmentEditDialogSidebar({
},
[
baseEnvironments.length,
handleCreateSubEnvironment,
handleDeleteEnvironment,
handleDuplicateSelected,
handleRenameSelected,
@@ -362,37 +317,34 @@ function EnvironmentEditDialogSidebar({
);
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>(
({ items, position, onClose }) =>
// Right-clicking empty space has nothing to offer while there are multiple base environments
items.length === 0 ? null : (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
({ items, position, onClose }) => (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
[],
);
const tree = useAtomValue(treeAtom);
return (
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
{tree != null && (
<CreateSubEnvironmentContext.Provider value={handleCreateSubEnvironment}>
<div className="pt-2">
<Tree
ref={treeRef}
treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
className="px-2 pb-10"
root={tree}
getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner}
ItemRightSlot={ItemRightSlot}
ItemInner={ItemInner}
onActivate={handleActivate}
getEditOptions={getEditOptions}
/>
</div>
</CreateSubEnvironmentContext.Provider>
<div className="pt-2">
<Tree
ref={treeRef}
treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
className="px-2 pb-10"
root={tree}
getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner}
ItemRightSlot={ItemRightSlot}
ItemInner={ItemInner}
onActivate={handleActivate}
getEditOptions={getEditOptions}
/>
</div>
)}
</aside>
);
@@ -432,15 +384,6 @@ const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
return root;
});
function findTreeNode(node: TreeNode<TreeModel>, id: string): TreeNode<TreeModel> | null {
if (node.item.id === id) return node;
for (const child of node.children ?? []) {
const found = findTreeNode(child, id);
if (found != null) return found;
}
return null;
}
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
return baseEnvironments.length > 1 ? (
@@ -452,7 +395,6 @@ function ItemLeftSlotInner({ item }: { item: TreeModel }) {
function ItemRightSlot({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
const handleCreateSubEnvironment = useContext(CreateSubEnvironmentContext);
return (
<>
{item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
@@ -463,7 +405,7 @@ function ItemRightSlot({ item }: { item: TreeModel }) {
icon="plus_circle"
className="opacity-50 hover:opacity-100"
title="Add Sub-Environment"
onClick={handleCreateSubEnvironment}
onClick={createSubEnvironment}
/>
)}
</>
@@ -483,6 +425,13 @@ function ItemInner({ item }: { item: TreeModel }) {
);
}
async function createSubEnvironment() {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
return id;
}
function getEditOptions(item: TreeModel) {
const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
defaultValue: item.name,
@@ -1,4 +1,4 @@
import { IconTooltip } from "@yaakapp-internal/ui";
import { IconTooltip } from "./core/IconTooltip";
export function EnvironmentSharableTooltip() {
return (
@@ -5,6 +5,7 @@ import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import slugify from "slugify";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize";
import { rpc } from "../lib/rpc";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Checkbox } from "./core/Checkbox";
@@ -12,7 +13,6 @@ import { DetailsBanner } from "./core/DetailsBanner";
import { DialogFooter } from "./core/Dialog";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
onHide: () => void;
+4 -9
View File
@@ -1,14 +1,6 @@
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { foldersAtom } from "@yaakapp-internal/models";
import {
Heading,
HStack,
Icon,
LoadingIcon,
HttpResponseDurationTag,
HttpStatusTag,
SizeTag,
} from "@yaakapp-internal/ui";
import { Heading, HStack, Icon, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { CSSProperties, ReactNode } from "react";
@@ -21,8 +13,11 @@ import { showDialog } from "../lib/dialog";
import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from "../lib/router";
import { Button } from "./core/Button";
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton";
import { Separator } from "./core/Separator";
import { SizeTag } from "./core/SizeTag";
import { HttpResponsePane } from "./HttpResponsePane";
interface Props {
@@ -1,5 +1,5 @@
import { createWorkspaceModel, foldersAtom, patchModel } from "@yaakapp-internal/models";
import { HStack, Icon, InlineCode, VStack, CountBadge } from "@yaakapp-internal/ui";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { Fragment, useMemo } from "react";
import { useAuthTab } from "../hooks/useAuthTab";
@@ -11,6 +11,7 @@ import { deleteModelWithConfirm } from "../lib/deleteModelWithConfirm";
import { hideDialog } from "../lib/dialog";
import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { Input } from "./core/Input";
import { Link } from "./core/Link";
import type { TabItem } from "./core/Tabs/Tabs";
+1 -1
View File
@@ -20,13 +20,13 @@ 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";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
type Props = Pick<EditorProps, "heightMode" | "onChange" | "className" | "forceUpdateKey"> & {
services: ReflectResponseService[] | null;
@@ -3,11 +3,11 @@ import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { useGrpc } from "../hooks/useGrpc";
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
import { pluralizeCount } from "../lib/pluralize";
import { DialogFooter } from "./core/Dialog";
import { PathList } from "./core/PathList";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
onDone: () => void;
@@ -4,7 +4,7 @@ import {
patchModel,
patchModelDebounced,
} from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack, CountBadge } from "@yaakapp-internal/ui";
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react";
@@ -15,6 +15,7 @@ import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { resolvedModelName } from "../lib/resolvedModelName";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { RadioDropdown } from "./core/RadioDropdown";
@@ -1,13 +1,5 @@
import type { GrpcEvent, GrpcRequest } from "@yaakapp-internal/models";
import {
HStack,
Icon,
type IconProps,
LoadingIcon,
VStack,
useStateWithDeps,
KeyValueRows,
} from "@yaakapp-internal/ui";
import { HStack, Icon, type IconProps, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import { useAtomValue, useSetAtom } from "jotai";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
@@ -17,12 +9,13 @@ import {
pinnedGrpcConnectionIdAtom,
useGrpcEvents,
} from "../hooks/usePinnedGrpcConnection";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor";
import { EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { HotkeyList } from "./core/HotkeyList";
import { KeyValueRow } from "./core/KeyValueRow";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { RecentGrpcConnectionsDropdown } from "./RecentGrpcConnectionsDropdown";
+12 -2
View File
@@ -1,5 +1,5 @@
import type { HttpRequestHeader } from "@yaakapp-internal/models";
import { HStack, CountBadge } from "@yaakapp-internal/ui";
import { HStack } from "@yaakapp-internal/ui";
import { acceptLanguages } from "../lib/data/acceptLanguages";
import { cacheControlDirectives } from "../lib/data/cacheControl";
import { charsets } from "../lib/data/charsets";
@@ -9,7 +9,7 @@ import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
import { headerNames } from "../lib/data/headerNames";
import { mimeTypes } from "../lib/data/mimetypes";
import { userAgents } from "../lib/data/userAgents";
import { validateHttpHeader } from "../lib/validateHttpHeader";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { InputProps } from "./core/Input";
@@ -178,3 +178,13 @@ const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
},
),
};
const validateHttpHeader = (v: string) => {
if (v === "") {
return true;
}
// Template strings are not allowed so we replace them with a valid example string
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
};
@@ -0,0 +1,79 @@
import deepEqual from "@gilbarbara/deep-equal";
import { useMutation } from "@tanstack/react-query";
import type { AssertionReport, HttpAssertions, HttpResponse } from "@yaakapp-internal/models";
import { format } from "date-fns";
import { rpc } from "../lib/rpc";
import { Button } from "./core/Button";
import { HttpAssertionReport } from "./HttpAssertionResults";
interface Props {
assertions: HttpAssertions;
response: HttpResponse | null;
}
export function HttpAssertionPreview(props: Props) {
// A response switch (including a new send) discards results and in-flight UI
// state. The old evaluation may finish, but it cannot label the new response.
return <ResponsePreview key={`${props.response?.id}:${props.response?.updatedAt}`} {...props} />;
}
function ResponsePreview({ assertions, response }: Props) {
const preview = useMutation({
mutationFn: (definition: HttpAssertions) =>
rpc<AssertionReport>("cmd_preview_http_assertions", {
responseId: response?.id,
assertions: definition,
}),
retry: false,
gcTime: 0,
});
const unavailable = !response
? "Send a request first to get a response to try."
: response.state !== "closed"
? "Wait for the response to finish."
: !assertions.checks.some((check) => check.enabled)
? "Enable an assertion to try it."
: null;
const changed = preview.data != null && !deepEqual(preview.data.definition, assertions);
return (
<div className="space-y-2">
<Button
size="sm"
variant="border"
disabled={unavailable != null || preview.isPending}
onClick={() => preview.mutate(assertions)}
>
{preview.isPending ? "Trying assertions…" : "Try assertions"}
</Button>
<p className="text-xs text-text-subtle">
{unavailable ?? "Preview against the selected response without sending again."}
</p>
{preview.isError && (
<p className="text-sm text-danger" role="alert">
Could not preview assertions:{" "}
{preview.error instanceof Error ? preview.error.message : String(preview.error)}
</p>
)}
{preview.data && response && (
<section
aria-label="Assertion preview"
aria-live="polite"
className="border border-border-subtle rounded-md p-3 space-y-2"
>
<p className="text-sm font-medium">Preview</p>
<p className="text-xs text-text-subtle">
Response from {format(`${response.createdAt}Z`, "MMM d, h:mm:ss a")}. Recorded results
are unchanged.
</p>
{changed && (
<p className="text-xs text-warning" role="status">
Assertions changed. Try again to update this preview.
</p>
)}
<HttpAssertionReport report={preview.data} />
</section>
)}
</div>
);
}
@@ -0,0 +1,81 @@
import type { AssertionReport, HttpResponse } from "@yaakapp-internal/models";
import classNames from "classnames";
import { EmptyStateText } from "./EmptyStateText";
export function HttpAssertionResults({ response }: { response: HttpResponse }) {
const report = response.assertionResults;
if (!report)
return <EmptyStateText>Assertions were not evaluated for this response.</EmptyStateText>;
if (!report.error && report.results.length === 0)
return <EmptyStateText>Waiting for the response to finish.</EmptyStateText>;
return (
<div className="h-full overflow-auto space-y-2 pb-3">
<p className="text-xs text-text-subtle">Checks saved with this response.</p>
<HttpAssertionReport report={report} />
</div>
);
}
export function HttpAssertionReport({ report }: { report: AssertionReport }) {
const passed = report.results.filter((r) => r.outcome === "passed").length;
const failed = report.results.filter((r) => r.outcome === "failed").length;
const errors = report.results.filter(
(r) => r.outcome === "error" || r.outcome === "invalid",
).length;
const skipped = report.results.filter((r) => r.outcome === "skipped").length;
return (
<div className="space-y-2">
<p className="text-sm">
{passed} passed · {failed} failed · {errors} errors · {skipped} skipped
</p>
{report.error && <p className="text-sm text-danger">{report.error}</p>}
{report.results.map((result, index) => {
const check = report.definition.checks[index];
return (
<div
key={`${result.assertionId}:${index}`}
className="border border-border-subtle rounded-md p-2 text-sm space-y-1"
>
<div className="flex items-start justify-between gap-2">
<span className="font-mono break-all">
{check?.target === "status" ? "Status" : check?.selector}
</span>
<span
className={classNames(
"capitalize shrink-0",
result.outcome === "passed"
? "text-success"
: result.outcome === "skipped"
? "text-text-subtle"
: "text-danger",
)}
>
{result.outcome}
</span>
</div>
{check && (
<p className="text-xs break-all">
{check.operator.replaceAll("_", " ")}
{check.operator !== "exists" && (
<>
{" "}
{check.expectedType === "null"
? "null"
: check.expectedType === "text"
? JSON.stringify(check.expected)
: check.expected}{" "}
({check.expectedType})
</>
)}
</p>
)}
<p className="text-xs text-text-subtle">{result.reason}</p>
{result.actual != null && (
<p className="font-mono text-xs break-all">Actual: {JSON.stringify(result.actual)}</p>
)}
</div>
);
})}
</div>
);
}
@@ -0,0 +1,216 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpAssertion, HttpAssertions, HttpRequest } from "@yaakapp-internal/models";
import { patchModelDebounced } from "@yaakapp-internal/models";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { JsonPathInput } from "./JsonPathInput";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { generateId } from "../lib/generateId";
import { rpc } from "../lib/rpc";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { HttpAssertionPreview } from "./HttpAssertionPreview";
const EMPTY: HttpAssertions = { version: 1, checks: [] };
const operators = [
{ value: "equals", label: "Equals" },
{ value: "not_equals", label: "Does not equal" },
{ value: "greater_than", label: "Greater than" },
{ value: "less_than", label: "Less than" },
{ value: "contains", label: "Contains" },
{ value: "exists", label: "Exists" },
];
export function HttpAssertionsEditor({ request }: { request: HttpRequest }) {
const updateKey = useRequestUpdateKey(request.id);
const { activeResponse } = usePinnedHttpResponse(request.id);
const [definition, setDefinition] = useStateWithDeps(request.assertions ?? EMPTY, [updateKey]);
const validation = useQuery({
queryKey: ["validate_assertions", definition],
queryFn: () =>
rpc<Record<string, string>>("cmd_validate_http_assertions", { assertions: definition }),
staleTime: Infinity,
gcTime: 0,
retry: false,
});
const save = (checks: HttpAssertion[]) => {
const next = { ...definition, checks };
setDefinition(next);
patchModelDebounced(request, { assertions: next });
};
const update = (id: string, patch: Partial<HttpAssertion>) =>
save(definition.checks.map((c) => (c.id === id ? { ...c, ...patch } : c)));
return (
<div className="@container h-full overflow-auto pb-3 space-y-3">
<p className="text-sm text-text-subtle">
Check the response each time you send. Results appear in the response’s Assertions tab.
</p>
{definition.checks.map((check, index) => (
<div key={check.id} className="border border-border-subtle rounded-md p-2 space-y-2">
<div className="flex items-center gap-2">
<Checkbox
title={`Assertion ${index + 1}`}
checked={check.enabled}
onChange={(enabled) => update(check.id, { enabled })}
/>
<IconButton
icon="trash"
size="xs"
title={`Delete assertion ${index + 1}`}
onClick={() => save(definition.checks.filter((c) => c.id !== check.id))}
/>
</div>
<div className="grid grid-cols-[minmax(0,1fr)] @[28rem]:grid-cols-[8rem_minmax(0,1fr)] gap-2">
<Select
name={`target-${check.id}`}
label="Response"
size="sm"
value={check.target}
options={[
{ value: "json", label: "JSON body" },
{ value: "status", label: "Status" },
{ value: "header", label: "Header" },
]}
onChange={(target) =>
update(check.id, {
target,
selector: "",
operator: "equals",
expected: target === "status" ? "200" : "",
expectedType: target === "status" ? "number" : "text",
})
}
/>
{check.target === "json" && (
<JsonPathInput
response={activeResponse}
stateKey={`assertion-path:${request.id}:${check.id}`}
defaultValue={check.selector}
forceUpdateKey={updateKey}
onChange={(selector) => update(check.id, { selector })}
/>
)}
{check.target === "header" && (
<PlainInput
label="Header name"
size="sm"
defaultValue={check.selector}
forceUpdateKey={`${updateKey}:${check.target}`}
placeholder="Content-Type"
onChange={(selector) => update(check.id, { selector })}
/>
)}
</div>
<div className="grid grid-cols-[minmax(0,1fr)] @[28rem]:grid-cols-[8rem_minmax(0,1fr)] gap-2">
<Select
name={`operator-${check.id}`}
label="Comparison"
size="sm"
value={check.operator}
options={operators.filter(
(o) =>
check.target === "json" ||
(check.target === "status"
? o.value !== "contains" && o.value !== "exists"
: !["greater_than", "less_than"].includes(o.value)),
)}
onChange={(operator) =>
update(check.id, {
operator,
...(["greater_than", "less_than"].includes(operator)
? { expectedType: "number" }
: operator === "contains"
? { expectedType: "text" }
: {}),
})
}
/>
{check.operator !== "exists" && (
<div className="flex items-start gap-2 min-w-0">
{check.expectedType !== "null" && (
<div className="flex-1 min-w-0">
<PlainInput
label="Expected value"
size="sm"
defaultValue={check.expected}
forceUpdateKey={`${updateKey}:${check.target}`}
placeholder={check.expectedType === "boolean" ? "true or false" : "Value"}
onChange={(expected) => update(check.id, { expected })}
/>
</div>
)}
{check.target === "json" &&
!["greater_than", "less_than", "contains"].includes(check.operator) && (
<div className="w-24 shrink-0">
<Select
name={`type-${check.id}`}
label="Type"
size="sm"
value={check.expectedType}
options={[
{ value: "text", label: "Text" },
{ value: "number", label: "Number" },
{ value: "boolean", label: "Boolean" },
{ value: "null", label: "Null" },
]}
onChange={(expectedType) => update(check.id, { expectedType })}
/>
</div>
)}
</div>
)}
</div>
{validation.data?.[check.id] && (
<p className="text-xs text-danger" role="status">
{validation.data[check.id]}
</p>
)}
</div>
))}
{validation.data?.[""] && (
<p className="text-sm text-danger" role="status">
{validation.data[""]}
</p>
)}
{validation.isError && (
<p className="text-sm text-danger" role="status">
Unable to validate assertions.
</p>
)}
<Button
size="sm"
variant="border"
disabled={definition.version !== 1 || definition.checks.length >= 100}
onClick={() =>
save([
...definition.checks,
{
id: generateId(),
enabled: true,
target: "json",
selector: "",
operator: "equals",
expected: "",
expectedType: "text",
},
])
}
>
Add assertion
</Button>
{definition.checks.some((c) => c.target === "json") && (
<p className="text-xs text-text-subtle">
Use a JSONPath such as $.user.id or user.id. Comparisons select one value; Exists also
accepts multiple matches. Suggestions use the selected response, one level at a time.
Values are literal and types must match.
</p>
)}
<HttpAssertionPreview assertions={definition} response={activeResponse} />
</div>
);
}
@@ -15,6 +15,8 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { convertRequestBody } from "../lib/requestBodyConversion";
import {
@@ -33,17 +35,19 @@ import { resolvedModelName } from "../lib/resolvedModelName";
import { showToast } from "../lib/toast";
import { BinaryFileEditor } from "./BinaryFileEditor";
import { ConfirmLargeRequestBody } from "./ConfirmLargeRequestBody";
import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode, languageFromContentType, CountBadge } from "@yaakapp-internal/ui";
import { InlineCode } from "@yaakapp-internal/ui";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { EmptyStateText } from "./EmptyStateText";
import { FormMultipartEditor } from "./FormMultipartEditor";
import { FormUrlencodedEditor } from "./FormUrlencodedEditor";
import { HeadersEditor } from "./HeadersEditor";
import { HttpAssertionsEditor } from "./HttpAssertionsEditor";
import { HttpAuthenticationEditor } from "./HttpAuthenticationEditor";
import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from "./MarkdownEditor";
@@ -51,7 +55,6 @@ import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor";
import { generateId } from "@yaakapp-internal/lib/generateId";
const GraphQLEditor = lazy(() =>
import("./graphql/GraphQLEditor").then((m) => ({ default: m.GraphQLEditor })),
@@ -70,6 +73,7 @@ const TAB_HEADERS = "headers";
const TAB_AUTH = "auth";
const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TAB_ASSERTIONS = "assertions";
const TABS_STORAGE_KEY = "http_request_tabs";
// Derived from the identity-stable URL list so this only recomputes when a URL
@@ -169,6 +173,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
() => [
{
value: TAB_BODY,
menuLabel: "Body",
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
options: {
value: activeRequest.bodyType,
@@ -262,6 +267,16 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
label: "Info",
rightSlot: hasDescription && <CountBadge count={true} />,
},
{
value: TAB_ASSERTIONS,
label: "Assertions",
rightSlot: (
<CountBadge
count={activeRequest.assertions?.checks.filter((c) => c.enabled).length ?? 0}
/>
),
hiddenByDefault: !activeRequest.assertions?.checks.length,
},
],
[
activeRequest,
@@ -308,11 +323,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
e.preventDefault(); // Prevent input onChange
await patchModel(activeRequest, patch);
await setActiveTab({
storageKey: TABS_STORAGE_KEY,
activeTabKey: activeRequestId,
value: TAB_PARAMS,
});
tabsRef.current?.setActiveTab(TAB_PARAMS);
// Wait for request to update, then refresh the UI
// TODO: Somehow make this deterministic
@@ -366,8 +377,12 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
tabs={tabs}
tabListClassName="mt-1 -mb-1.5"
storageKey={TABS_STORAGE_KEY}
visibilityStorageKey={[TABS_STORAGE_KEY, "visibility", activeRequest.workspaceId]}
activeTabKey={activeRequestId}
>
<TabContent value={TAB_ASSERTIONS}>
<HttpAssertionsEditor key={activeRequest.id} request={activeRequest} />
</TabContent>
<TabContent value={TAB_AUTH}>
<HttpAuthenticationEditor model={activeRequest} />
</TabContent>
@@ -1,24 +1,8 @@
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import {
Banner,
HStack,
Icon,
LoadingIcon,
VStack,
CountBadge,
HttpResponseDurationTag,
HttpStatusTag,
SizeTag,
Tooltip,
AudioViewer,
CsvViewer,
ImageViewer,
SvgViewer,
VideoViewer,
} from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ComponentType, CSSProperties } from "react";
import { lazy, Suspense, useMemo } from "react";
import { lazy, Suspense, useMemo, useRef } from "react";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useCopyHttpResponse } from "../hooks/useCopyHttpResponse";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
@@ -33,20 +17,31 @@ import { getContentTypeFromHeaders, getCookieCounts } from "../lib/model_util";
import { ConfirmLargeResponse } from "./ConfirmLargeResponse";
import { ConfirmLargeResponseRequest } from "./ConfirmLargeResponseRequest";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { HotkeyList } from "./core/HotkeyList";
import { HttpResponseDurationTag } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { PillButton } from "./core/PillButton";
import type { TabItem } from "./core/Tabs/Tabs";
import { SizeTag } from "./core/SizeTag";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { Tooltip } from "./core/Tooltip";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpAssertionResults } from "./HttpAssertionResults";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
import { ResponseCookies } from "./ResponseCookies";
import { ResponseHeaders } from "./ResponseHeaders";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from "./responseViewers/CsvViewer";
import { EventStreamViewer } from "./responseViewers/EventStreamViewer";
import { HTMLOrTextViewer } from "./responseViewers/HTMLOrTextViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
@@ -62,6 +57,7 @@ const TAB_BODY = "body";
const TAB_REQUEST = "request";
const TAB_HEADERS = "headers";
const TAB_COOKIES = "cookies";
const TAB_ASSERTIONS = "assertions";
const TAB_TIMELINE = "timeline";
export type TimelineViewMode = "timeline" | "text";
@@ -73,6 +69,7 @@ interface RedirectDropWarning {
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
const tabsRef = useRef<TabsRef>(null);
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
@@ -89,9 +86,53 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
const saveResponse = useSaveResponse(activeResponse ?? null);
const copyResponse = useCopyHttpResponse(activeResponse ?? null);
const assertionReport = activeResponse?.assertionResults;
// Native sends save the definition before evaluation. Wait for actual outcomes,
// and leave the tab hidden when every check was disabled.
const hasAssertionResults =
activeResponse?.state === "closed" &&
assertionReport != null &&
(assertionReport.error != null ||
assertionReport.results.some((result) => result.outcome !== "skipped"));
const failedAssertions =
assertionReport?.results.filter((result) => result.outcome === "failed").length ?? 0;
const assertionErrors =
assertionReport?.results.filter(
(result) => result.outcome === "error" || result.outcome === "invalid",
).length ?? 0;
const assertionFailures = failedAssertions + assertionErrors;
const assertionFailureLabel = [
failedAssertions > 0
? `${failedAssertions} assertion${failedAssertions === 1 ? "" : "s"} failed`
: null,
assertionErrors > 0
? `${assertionErrors} assertion error${assertionErrors === 1 ? "" : "s"}`
: assertionReport?.error != null
? "Assertion error"
: null,
]
.filter(Boolean)
.join(" · ");
const assertionPasses =
assertionReport?.results.filter((result) => result.outcome === "passed").length ?? 0;
const assertionsHaveErrors = assertionFailures > 0 || assertionReport?.error != null;
const tabs = useMemo<TabItem[]>(
() => [
...(hasAssertionResults
? [
{
value: TAB_ASSERTIONS,
label: "Assertions",
rightSlot: (
<CountBadge
count={assertionsHaveErrors ? assertionFailures || true : assertionPasses}
color={assertionsHaveErrors ? "danger" : "success"}
/>
),
},
]
: []),
{
value: TAB_BODY,
label: "Response",
@@ -168,6 +209,10 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
activeResponse?.requestHeaders.length,
activeResponse?.state,
activeResponse?.status,
hasAssertionResults,
assertionFailures,
assertionPasses,
assertionsHaveErrors,
cookieCounts.sent,
cookieCounts.received,
copyResponse.mutate,
@@ -216,6 +261,17 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
<HStack space={2} className="w-full shrink-0">
{activeResponse.state !== "closed" && <LoadingIcon size="sm" />}
<HttpStatusTag showReason response={activeResponse} />
{hasAssertionResults && assertionsHaveErrors && (
<PillButton
color="danger"
className="font-sans"
title="View assertion results"
leftSlot={<Icon icon="alert_triangle" size="xs" />}
onClick={() => tabsRef.current?.setActiveTab(TAB_ASSERTIONS)}
>
{assertionFailureLabel}
</PillButton>
)}
<span>&bull;</span>
<HttpResponseDurationTag response={activeResponse} />
<span>&bull;</span>
@@ -288,13 +344,18 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
)}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
ref={tabsRef}
tabs={tabs}
defaultValue={TAB_BODY}
label="Response"
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
tabListClassName="mt-0.5 -mb-1.5"
storageKey="http_response_tabs"
activeTabKey={activeRequestId}
>
<TabContent value={TAB_ASSERTIONS}>
<HttpAssertionResults response={activeResponse} />
</TabContent>
<TabContent value={TAB_BODY}>
<ErrorBoundary name="Http Response Viewer">
<Suspense>
@@ -13,8 +13,9 @@ import { resolvedModelName } from "../lib/resolvedModelName";
import { Editor } from "./core/Editor/LazyEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
import { EventViewerRow } from "./core/EventViewerRow";
import { Icon, type IconProps, HttpStatusTagRaw, KeyValueRows } from "@yaakapp-internal/ui";
import { KeyValueRow } from "./core/KeyValueRow";
import { HttpStatusTagRaw } from "./core/HttpStatusTag";
import { Icon, type IconProps } from "@yaakapp-internal/ui";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import type { TimelineViewMode } from "./HttpResponsePane";
interface Props {
@@ -5,10 +5,11 @@ import {
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { Banner, FormattedError, HStack, Icon, VStack, IconTooltip } from "@yaakapp-internal/ui";
import { Banner, FormattedError, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { errorMessage } from "../lib/errorMessage";
import { pluralize } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import {
ImportSourceList,
@@ -21,9 +22,9 @@ import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree";
import { Chip } from "./core/Chip";
import { DialogFooter } from "./core/Dialog";
import { IconTooltip } from "./core/IconTooltip";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
import { pluralize } from "@yaakapp-internal/lib/pluralize";
interface Props {
currentWorkspace: Workspace | null;
@@ -1,7 +1,7 @@
import { linter } from "@codemirror/lint";
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { Banner, Icon, IconTooltip } from "@yaakapp-internal/ui";
import { Banner, Icon } from "@yaakapp-internal/ui";
import { useCallback, useMemo } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { fireAndForget } from "../lib/fireAndForget";
@@ -12,6 +12,7 @@ import type { EditorProps } from "./core/Editor/Editor";
import { jsonParseLinter } from "./core/Editor/json-lint";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
interface Props {
forceUpdateKey: string;
@@ -0,0 +1,76 @@
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
import { EditorView } from "@codemirror/view";
import { useQueryClient } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { useMemo, useRef, useState } from "react";
import { rpc } from "../lib/rpc";
import { Input } from "./core/Input";
import type { InputProps } from "./core/Input";
import { jsonPathCompletion } from "./core/Editor/json/pathCompletion";
import type { JsonPathChildren, LoadJsonPathChildren } from "./core/Editor/json/pathCompletion";
type Props = Pick<InputProps, "defaultValue" | "onChange" | "forceUpdateKey" | "stateKey"> & {
response: HttpResponse | null;
};
export function JsonPathInput({ response, ...props }: Props) {
const client = useQueryClient();
const [sampled, setSampled] = useState(false);
const responseKey = response?.state === "closed" ? `${response.id}:${response.updatedAt}` : null;
const currentResponseKey = useRef(responseKey);
currentResponseKey.current = responseKey;
const load = useRef<LoadJsonPathChildren>(async () => null);
load.current = async (parent) => {
if (!response || !responseKey) return null;
try {
const result = await client.fetchQuery({
queryKey: ["json_path_children", response.id, response.updatedAt, parent],
queryFn: () =>
rpc<JsonPathChildren>("cmd_http_response_json_children", {
responseId: response.id,
parent,
}),
staleTime: Infinity,
gcTime: 60_000,
retry: false,
});
if (responseKey !== currentResponseKey.current) return null;
setSampled(result.truncated);
return result;
} catch {
return null;
} // Non-JSON bodies and incomplete expressions still allow normal typing.
};
// The editor installs extraExtensions once. A ref keeps the response current without rebuilding
// the input and losing focus/selection when a new response arrives.
const extensions = useMemo(
() => [
autocompletion({ override: [jsonPathCompletion((parent) => load.current(parent))] }),
EditorView.domEventHandlers({
focus: (_event, view) => {
startCompletion(view);
},
}),
],
[],
);
return (
<div className="min-w-0">
<Input
{...props}
label="JSONPath"
size="sm"
placeholder="$.user.id"
language="text"
autocompleteFunctions={false}
autocompleteVariables={false}
extraExtensions={extensions}
/>
{sampled && (
<p className="text-xs text-text-subtle mt-1">
Suggestions are sampled. You can enter any path.
</p>
)}
</div>
);
}
@@ -1,20 +1,15 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import {
Banner,
Button,
HStack,
LoadingIcon,
AudioViewer,
ImageViewer,
SvgViewer,
VideoViewer,
} from "@yaakapp-internal/ui";
import { Banner, Button, HStack, LoadingIcon } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { lazy, Suspense, useEffect, useMemo, useState } from "react";
import type { SniffedValue } from "./core/Editor/sniffValue";
import { showDialog } from "../lib/dialog";
import { decodeValue, largeValueActions } from "../lib/largeValue";
import { Dropdown } from "./core/Dropdown";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
@@ -3,12 +3,12 @@ import { patchModel, workspacesAtom } from "@yaakapp-internal/models";
import { InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { pluralizeCount } from "../lib/pluralize";
import { resolvedModelName } from "../lib/resolvedModelName";
import { router } from "../lib/router";
import { showToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Select } from "./core/Select";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
activeWorkspaceId: string;
@@ -1,6 +1,6 @@
import type { GrpcConnection } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
import { HStack, Icon } from "@yaakapp-internal/ui";
import {
differenceInHours,
differenceInMinutes,
@@ -9,9 +9,10 @@ import {
isYesterday,
} from "date-fns";
import { useDeleteGrpcConnections } from "../hooks/useDeleteGrpcConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
connections: GrpcConnection[];
@@ -1,6 +1,6 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { deleteModel } from "@yaakapp-internal/models";
import { HStack, Icon, formatMillis, HttpStatusTag, SizeTag } from "@yaakapp-internal/ui";
import { HStack, Icon } from "@yaakapp-internal/ui";
import {
differenceInHours,
differenceInMinutes,
@@ -12,7 +12,10 @@ import { useDeleteHttpResponses } from "../hooks/useDeleteHttpResponses";
import { useKeyValue } from "../hooks/useKeyValue";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import { IconButton } from "./core/IconButton";
import { SizeTag } from "./core/SizeTag";
interface Props {
responses: HttpResponse[];
@@ -1,6 +1,6 @@
import type { WebsocketConnection } from "@yaakapp-internal/models";
import { deleteModel, getModel } from "@yaakapp-internal/models";
import { HStack, Icon, formatMillis } from "@yaakapp-internal/ui";
import { HStack, Icon } from "@yaakapp-internal/ui";
import {
differenceInHours,
differenceInMinutes,
@@ -9,9 +9,10 @@ import {
isYesterday,
} from "date-fns";
import { deleteWebsocketConnections } from "../commands/deleteWebsocketConnections";
import { pluralizeCount } from "../lib/pluralize";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { formatMillis } from "./core/HttpResponseDurationTag";
import { IconButton } from "./core/IconButton";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
interface Props {
connections: WebsocketConnection[];
@@ -1,20 +1,17 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { lazy, Suspense } from "react";
import { useHttpRequestBody } from "../hooks/useHttpRequestBody";
import { getMimeTypeFromContentType } from "../lib/contentType";
import {
LoadingIcon,
languageFromContentType,
AudioViewer,
CsvViewer,
ImageViewer,
SvgViewer,
VideoViewer,
WebPageViewer,
} from "@yaakapp-internal/ui";
import { getMimeTypeFromContentType, languageFromContentType } from "../lib/contentType";
import { LoadingIcon } from "@yaakapp-internal/ui";
import { EmptyStateText } from "./EmptyStateText";
import { AudioViewer } from "./responseViewers/AudioViewer";
import { CsvViewer } from "./responseViewers/CsvViewer";
import { ImageViewer } from "./responseViewers/ImageViewer";
import { MultipartViewer } from "./responseViewers/MultipartViewer";
import { SvgViewer } from "./responseViewers/SvgViewer";
import { TextViewer } from "./responseViewers/TextViewer";
import { VideoViewer } from "./responseViewers/VideoViewer";
import { WebPageViewer } from "./responseViewers/WebPageViewer";
const PdfViewer = lazy(() =>
import("./responseViewers/PdfViewer").then((m) => ({ default: m.PdfViewer })),
@@ -3,9 +3,9 @@ import classNames from "classnames";
import { useMemo } from "react";
import type { JSX } from "react/jsx-runtime";
import { useHttpResponseEvents } from "../hooks/useHttpResponseEvents";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import { KeyValueRow } from "./core/KeyValueRow";
import { CountBadge, KeyValueRows } from "@yaakapp-internal/ui";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
interface Props {
response: HttpResponse;
+63 -16
View File
@@ -1,16 +1,31 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import { HeadersTable, KeyValueRows } from "@yaakapp-internal/ui";
import { format, formatDistanceToNowStrict } from "date-fns";
import { DetailsBanner, useDetailsBannerStoredValue } from "./core/DetailsBanner";
import { useMemo } from "react";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import { IconButton } from "./core/IconButton";
import { KeyValueRow } from "./core/KeyValueRow";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform";
interface Props {
response: HttpResponse;
}
export function ResponseHeaders({ response }: Props) {
const responseHeaders = useMemo(
() =>
[...response.headers].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.headers],
);
const requestHeaders = useMemo(
() =>
[...response.requestHeaders].sort((a, b) =>
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
),
[response.requestHeaders],
);
return (
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
@@ -43,21 +58,53 @@ export function ResponseHeaders({ response }: Props) {
</KeyValueRow>
</KeyValueRows>
</DetailsBanner>
<HeadersTable
title="Request Headers"
headers={response.requestHeaders}
labelColor="primary"
<DetailsBanner
storageKey={`${response.requestId}.request_headers`}
useStoredValue={useDetailsBannerStoredValue}
/>
<HeadersTable
summary={
<h2 className="flex items-center">
Request Headers <CountBadge showZero count={requestHeaders.length} />
</h2>
}
>
{requestHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{requestHeaders.map((h, i) => (
// oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
<DetailsBanner
defaultOpen
title="Response Headers"
headers={response.headers}
labelColor="info"
storageKey={`${response.requestId}.response_headers`}
useStoredValue={useDetailsBannerStoredValue}
/>
summary={
<h2 className="flex items-center">
Response Headers <CountBadge showZero count={responseHeaders.length} />
</h2>
}
>
{responseHeaders.length === 0 ? (
<NoHeaders />
) : (
<KeyValueRows>
{responseHeaders.map((h, i) => (
// oxlint-disable-next-line react/no-array-index-key
<KeyValueRow labelColor="info" key={i} label={h.name}>
{h.value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</DetailsBanner>
</div>
);
}
function NoHeaders() {
return <span className="text-text-subtlest text-sm italic">No Headers</span>;
}
+1 -2
View File
@@ -1,8 +1,7 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { IconButton } from "./core/IconButton";
import { KeyValueRow } from "./core/KeyValueRow";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform";
import { KeyValueRows } from "@yaakapp-internal/ui";
interface Props {
response: HttpResponse;
+3 -1
View File
@@ -1,5 +1,5 @@
import { platform } from "@yaakapp-internal/platform";
import { HStack, IconTooltip, Label } from "@yaakapp-internal/ui";
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import mime from "mime";
import type { ReactNode } from "react";
@@ -7,6 +7,8 @@ import { useEffect, useRef, useState } from "react";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label";
type Props = Omit<ButtonProps, "type"> & {
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
@@ -1,14 +1,14 @@
import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, HStack, Icon, CountBadge } from "@yaakapp-internal/ui";
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useKeyPressEvent } from "react-use";
import { appInfo } from "../../lib/appInfo";
import { capitalize } from "../../lib/capitalize";
import { CountBadge } from "../core/CountBadge";
import { TabContent, type TabItem, Tabs } from "../core/Tabs/Tabs";
import { SettingsAgents } from "./SettingsAgents";
import { SettingsCertificates } from "./SettingsCertificates";
import { SettingsGeneral } from "./SettingsGeneral";
import { SettingsHotkeys } from "./SettingsHotkeys";
@@ -31,7 +31,6 @@ const TAB_SHORTCUTS = "shortcuts";
const TAB_PROXY = "proxy";
const TAB_CERTIFICATES = "certificates";
const TAB_PLUGINS = "plugins";
const TAB_AGENTS = "agents";
const TAB_LICENSE = "license";
const tabs = [
TAB_GENERAL,
@@ -39,7 +38,6 @@ const tabs = [
TAB_INTERFACE,
TAB_SHORTCUTS,
TAB_PLUGINS,
TAB_AGENTS,
TAB_CERTIFICATES,
TAB_PROXY,
TAB_LICENSE,
@@ -95,9 +93,7 @@ export default function Settings({ tab, hide }: Props) {
(value): TabItem => ({
value,
label: capitalize(value),
hidden:
(!appInfo.featureLicense && value === TAB_LICENSE) ||
(!platform.capabilities.agents && value === TAB_AGENTS),
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
leftSlot:
value === TAB_GENERAL ? (
<Icon icon="settings" className="text-secondary" />
@@ -113,8 +109,6 @@ export default function Settings({ tab, hide }: Props) {
<Icon icon="wifi" className="text-secondary" />
) : value === TAB_PLUGINS ? (
<Icon icon="puzzle" className="text-secondary" />
) : value === TAB_AGENTS ? (
<Icon icon="sparkles" className="text-secondary" />
) : value === TAB_LICENSE ? (
<Icon icon="key_round" className="text-secondary" />
) : null,
@@ -123,8 +117,6 @@ export default function Settings({ tab, hide }: Props) {
<CountBadge count={settings.clientCertificates.length} />
) : value === TAB_PLUGINS ? (
<CountBadge count={plugins.filter((p) => p.source !== "bundled").length} />
) : value === TAB_AGENTS && settings.mcpServerEnabled ? (
<CountBadge count />
) : value === TAB_PROXY && settings.proxy?.type === "enabled" ? (
<CountBadge count />
) : value === TAB_LICENSE && licenseCheck.check.data?.status === "personal_use" ? (
@@ -148,9 +140,6 @@ export default function Settings({ tab, hide }: Props) {
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
</TabContent>
<TabContent value={TAB_AGENTS} className="overflow-y-auto h-full px-6 py-4!">
<SettingsAgents />
</TabContent>
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 py-4!">
<SettingsProxy />
</TabContent>
@@ -1,207 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { settingsAtom } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import { Heading, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import type { AppMetaData } from "@yaakapp-internal/rpc-schema";
import { useAtomValue } from "jotai";
import { useKeyValue } from "../../hooks/useKeyValue";
import { appInfo } from "../../lib/appInfo";
import {
AGENT_CLIENTS,
type AgentClient,
type AgentSetup,
appSetup,
cliSetup,
} from "../../lib/agentSetup";
import { rpc } from "../../lib/rpc";
import { CopyIconButton } from "../CopyIconButton";
import { Button } from "../core/Button";
import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import { SegmentedControl } from "../core/SegmentedControl";
import {
ModelSettingRowBoolean,
SettingRow,
SettingValue,
SettingsList,
SettingsSection,
} from "../core/SettingRow";
const MCP_URL = "http://127.0.0.1:64343/mcp";
const CLI_INSTALL_COMMAND = "npm install -g @yaakapp/cli";
const SKILL_INSTALL_COMMAND = "yaak agent install";
export function SettingsAgents() {
const settings = useAtomValue(settingsAtom);
const { value: token } = useKeyValue<string | null>({
namespace: "no_sync",
key: "mcp_server_token",
fallback: null,
});
const { value: storedClient, set: setClient } = useKeyValue<AgentClient>({
key: "agents_setup_client",
fallback: "claude_code",
});
const client = AGENT_CLIENTS.find((c) => c.value === storedClient)?.value ?? "claude_code";
const cliVersion = useQuery({
queryKey: ["cli_version"],
queryFn: async () => (await rpc<AppMetaData>("cmd_metadata")).cliVersion,
initialData: appInfo.cliVersion,
});
return (
<VStack space={1.5} className="mb-4">
<div className="mb-3">
<Heading>Agents</Heading>
<p className="text-text-subtle">
Connect AI coding agents like Claude Code and Cursor to Yaak, so they can read, edit, and
send your requests.
</p>
</div>
<SettingsList className="space-y-8">
<SettingsSection
title="Yaak CLI"
description="Lets agents use Yaak from the command line, even with the app closed."
>
<SettingRow
title="Status"
description={
cliVersion.data == null
? "The Yaak CLI is not installed."
: `Yaak CLI ${cliVersion.data} is installed.`
}
controlClassName="gap-2"
>
<IconButton
size="sm"
icon="refresh"
title="Check again"
spin={cliVersion.isFetching}
onClick={() => cliVersion.refetch()}
/>
{cliVersion.data == null && (
<Button
variant="border"
color="secondary"
size="sm"
onClick={() => platform.openUrl("https://yaak.app/docs/cli")}
rightSlot={<Icon icon="external_link" />}
>
Install CLI
</Button>
)}
</SettingRow>
{cliVersion.data == null ? (
<SettingRow title="Install" description="Install it with npm.">
<SettingValue value={CLI_INSTALL_COMMAND} />
</SettingRow>
) : (
<SettingRow
title="Agent skill"
description={
<>
Teach Claude Code, Cursor, Codex, and opencode to drive the{" "}
<InlineCode>yaak</InlineCode> command, wherever they're installed.
</>
}
>
<SettingValue value={SKILL_INSTALL_COMMAND} />
</SettingRow>
)}
</SettingsSection>
<SettingsSection
title="MCP server in the app"
description="For agents that connect over HTTP, or to act on the workspaces open in Yaak."
>
<ModelSettingRowBoolean
model={settings}
modelKey="mcpServerEnabled"
title="Enable MCP server"
description={
<>
Serve Yaak's tools at <InlineCode>{MCP_URL}</InlineCode> while Yaak is open.
</>
}
/>
{settings.mcpServerEnabled && token != null && (
<SettingRow
title="Token"
description="Agents send this as a bearer token."
controlClassName="min-w-0 max-w-[min(24rem,40vw)] gap-2"
>
<SettingValue value={token} />
</SettingRow>
)}
</SettingsSection>
<SettingsSection
title="Connect an agent"
description="Pick your agent or editor to see how to add Yaak's MCP tools to it."
>
<div className="py-4 border-b border-border-subtle">
<SegmentedControl
name="agents_setup_client"
label="Agent"
hideLabel
value={client}
onChange={setClient}
options={AGENT_CLIENTS}
/>
</div>
<SetupRow
title="Through the Yaak CLI"
description={
cliVersion.data == null
? "Recommended. Install the Yaak CLI above first."
: `Recommended. ${cliSetup(client).where}`
}
setup={cliSetup(client)}
/>
{settings.mcpServerEnabled && token != null ? (
<SetupRow
title="Through the app"
description={appSetup(client, MCP_URL, token).where}
setup={appSetup(client, MCP_URL, token)}
/>
) : (
<SettingRow
title="Through the app"
description="Turn on the MCP server above to connect this way."
>
<span />
</SettingRow>
)}
</SettingsSection>
</SettingsList>
</VStack>
);
}
function SetupRow({
title,
description,
setup,
}: {
title: string;
description: string;
setup: AgentSetup;
}) {
return (
<div className="py-4 border-b border-border-subtle">
<div className="text text-text">{title}</div>
<div className="mt-1 text-sm text-text-subtle">{description}</div>
<Editor
readOnly
hideGutter
wrapLines
heightMode="auto"
stateKey={null}
language={setup.language}
defaultValue={setup.snippet}
className="mt-3 border border-border-subtle rounded-md px-2"
actions={<CopyIconButton size="sm" variant="border" text={setup.snippet} title="Copy setup" />}
/>
</div>
);
}
@@ -4,6 +4,7 @@ import { differenceInDays } from "date-fns";
import { formatDate } from "date-fns/format";
import { useState } from "react";
import { useToggle } from "../../hooks/useToggle";
import { pluralizeCount } from "../../lib/pluralize";
import { pricingUrl } from "../../lib/pricingUrl";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
@@ -11,7 +12,6 @@ import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput";
import { Separator } from "../core/Separator";
import { platform } from "@yaakapp-internal/platform";
import { pluralizeCount } from "@yaakapp-internal/lib/pluralize";
export function SettingsLicense() {
return (
@@ -20,7 +20,6 @@ import {
TableHeaderCell,
TableRow,
useDebouncedValue,
CountBadge,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
@@ -32,6 +31,7 @@ import { showConfirmDelete } from "../../lib/confirm";
import { minPromiseMillis } from "../../lib/minPromiseMillis";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
import { CountBadge } from "../core/CountBadge";
import { IconButton } from "../core/IconButton";
import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput";
@@ -82,9 +82,10 @@ export function SettingsDropdown() {
},
{
label: "Install CLI",
hidden: appInfo.cliVersion != null || !platform.capabilities.agents,
hidden: appInfo.cliVersion != null,
leftSlot: <Icon icon="square_terminal" />,
onSelect: () => openSettings.mutate("agents"),
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
},
{
label: "Feedback",
+1 -1
View File
@@ -69,6 +69,7 @@ import type { Ast } from "./core/Editor/filter/query";
import { evaluate, parseQuery } from "./core/Editor/filter/query";
import { formatFieldFilter } from "./core/Editor/filter/format";
import { HttpMethodTag } from "./core/HttpMethodTag";
import { HttpStatusTag } from "./core/HttpStatusTag";
import {
Icon,
LoadingIcon,
@@ -76,7 +77,6 @@ import {
isSelectedFamily,
selectedIdsFamily,
InlineCode,
HttpStatusTag,
} from "@yaakapp-internal/ui";
import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
@@ -22,9 +22,11 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { languageFromContentType } from "../lib/contentType";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
@@ -38,7 +40,6 @@ import { MarkdownEditor } from "./MarkdownEditor";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor";
import { languageFromContentType, CountBadge } from "@yaakapp-internal/ui";
interface Props {
style: CSSProperties;
@@ -1,12 +1,5 @@
import type { WebsocketEvent, WebsocketRequest } from "@yaakapp-internal/models";
import {
HStack,
Icon,
LoadingIcon,
VStack,
useStateWithDeps,
languageFromContentType,
} from "@yaakapp-internal/ui";
import { HStack, Icon, LoadingIcon, VStack } from "@yaakapp-internal/ui";
import { hexy } from "hexy";
import { useAtomValue } from "jotai";
import { useMemo, useState } from "react";
@@ -17,6 +10,8 @@ import {
setPinnedWebsocketConnectionId,
useWebsocketEvents,
} from "../hooks/usePinnedWebsocketConnection";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { languageFromContentType } from "../lib/contentType";
import { Button } from "./core/Button";
import { Editor } from "./core/Editor/LazyEditor";
import { type EventDetailAction, EventDetailHeader, EventViewer } from "./core/EventViewer";
@@ -5,17 +5,20 @@ import {
setWorkspaceKey,
} from "@yaakapp-internal/crypto";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { Banner, HStack, VStack, useStateWithDeps, IconTooltip, Label } from "@yaakapp-internal/ui";
import { Banner, HStack, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { useStateWithDeps } from "../hooks/useStateWithDeps";
import { showConfirm } from "../lib/confirm";
import { CopyIconButton } from "./CopyIconButton";
import type { ButtonProps } from "./core/Button";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { IconTooltip } from "./core/IconTooltip";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { SettingRow } from "./core/SettingRow";
import { EncryptionHelp } from "./EncryptionHelp";
@@ -1,5 +1,5 @@
import { patchModel, workspaceMetasAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HStack, InlineCode, CountBadge } from "@yaakapp-internal/ui";
import { Banner, HStack, InlineCode } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useAuthTab } from "../hooks/useAuthTab";
import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -9,6 +9,7 @@ import { showDialog } from "../lib/dialog";
import { router } from "../lib/router";
import { CopyIconButton } from "./CopyIconButton";
import { Button } from "./core/Button";
import { CountBadge } from "./core/CountBadge";
import { PlainInput } from "./core/PlainInput";
import { SettingsList, SettingsSection } from "./core/SettingRow";
import { TabContent, Tabs } from "./core/Tabs/Tabs";
@@ -1,10 +1,5 @@
import { describe, expect, test } from "vite-plus/test";
import {
formatBulkPairLine,
formatBulkPairs,
parseBulkPairLine,
parseBulkPairs,
} from "./BulkPairEditor";
import { parseBulkPairLine } from "./BulkPairEditor";
describe("parseBulkPairLine", () => {
test("parses colon-space pairs as name and value", () => {
@@ -38,130 +33,4 @@ describe("parseBulkPairLine", () => {
value: "bar\nbaz",
});
});
test("parses commented-out pairs as disabled", () => {
expect(parseBulkPairLine("# foo: bar")).toMatchObject({
enabled: false,
name: "foo",
value: "bar",
});
});
test("allows any whitespace after the comment marker", () => {
expect(parseBulkPairLine("# foo: bar")).toMatchObject({
enabled: false,
name: "foo",
value: "bar",
});
expect(parseBulkPairLine("#\tfoo: bar")).toMatchObject({
enabled: false,
name: "foo",
value: "bar",
});
});
test("treats a # without whitespace after it as part of the name", () => {
expect(parseBulkPairLine("#foo: bar")).toMatchObject({
enabled: true,
name: "#foo",
value: "bar",
});
});
test("parses commented-out pairs with an empty value as disabled", () => {
expect(parseBulkPairLine("# token:")).toMatchObject({
enabled: false,
name: "token",
value: "",
});
expect(parseBulkPairLine("# token: ")).toMatchObject({
enabled: false,
name: "token",
value: "",
});
});
test("drops comments that are not pairs", () => {
expect(parseBulkPairLine("# just a comment")).toBeNull();
expect(parseBulkPairLine("# see http://example.com")).toBeNull();
expect(parseBulkPairLine("#")).toBeNull();
});
test("leaves values that start with # alone", () => {
expect(parseBulkPairLine("color: #fff")).toMatchObject({
enabled: true,
name: "color",
value: "#fff",
});
});
});
describe("formatBulkPairLine", () => {
test("formats enabled pairs as name and value", () => {
expect(formatBulkPairLine({ enabled: true, name: "foo", value: "bar" })).toBe("foo: bar");
});
test("treats pairs without an enabled flag as enabled", () => {
expect(formatBulkPairLine({ name: "foo", value: "bar" })).toBe("foo: bar");
});
test("comments out disabled pairs", () => {
expect(formatBulkPairLine({ enabled: false, name: "foo", value: "bar" })).toBe("# foo: bar");
});
test("escapes newlines in values", () => {
expect(formatBulkPairLine({ enabled: false, name: "foo", value: "a\nb" })).toBe("# foo: a\\nb");
});
});
describe("bulk pair round trip", () => {
test("preserves order and enabled state", () => {
const pairs = [
{ enabled: true, name: "host", value: "example.com" },
{ enabled: false, name: "token", value: "old" },
{ enabled: true, name: "color", value: "#fff" },
{ enabled: false, name: "multi", value: "a\nb" },
{ enabled: true, name: "token", value: "new" },
];
const text = formatBulkPairs(pairs);
expect(text).toBe(
["host: example.com", "# token: old", "color: #fff", "# multi: a\\nb", "token: new"].join(
"\n",
),
);
const parsed = parseBulkPairs(text);
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual(pairs);
});
test("preserves enabled pairs whose name starts with #", () => {
const pairs = [{ enabled: true, name: "#foo", value: "bar" }];
const text = formatBulkPairs(pairs);
expect(text).toBe("#foo: bar");
const parsed = parseBulkPairs(text);
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual(pairs);
});
test("preserves disabled pairs whose name starts with #", () => {
const pairs = [{ enabled: false, name: "#foo", value: "bar" }];
const text = formatBulkPairs(pairs);
expect(text).toBe("# #foo: bar");
const parsed = parseBulkPairs(text);
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual(pairs);
});
test("preserves disabled pairs with empty values", () => {
const pairs = [{ enabled: false, name: "token", value: "" }];
const parsed = parseBulkPairs(formatBulkPairs(pairs));
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual(pairs);
});
test("drops free-text comments and blank lines between pairs", () => {
const parsed = parseBulkPairs("# Auth\nfoo: bar\n\n# baz: qux\n# see the docs");
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual([
{ enabled: true, name: "foo", value: "bar" },
{ enabled: false, name: "baz", value: "qux" },
]);
});
});
@@ -1,7 +1,7 @@
import { useCallback, useMemo } from "react";
import { generateId } from "../../lib/generateId";
import { Editor } from "./Editor/LazyEditor";
import type { Pair, PairEditorProps, PairWithId } from "./PairEditor";
import { generateId } from "@yaakapp-internal/lib/generateId";
type Props = PairEditorProps;
@@ -14,11 +14,20 @@ export function BulkPairEditor({
forcedEnvironmentId,
stateKey,
}: Props) {
const pairsText = useMemo(() => formatBulkPairs(pairs), [pairs]);
const pairsText = useMemo(() => {
return pairs
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
.map(formatBulkPairLine)
.join("\n");
}, [pairs]);
const handleChange = useCallback(
(text: string) => {
onChange(parseBulkPairs(text));
const pairs = text
.split("\n")
.filter((l: string) => l.trim())
.map(parseBulkPairLine);
onChange(pairs);
},
[onChange],
);
@@ -38,57 +47,13 @@ export function BulkPairEditor({
);
}
export function formatBulkPairs(pairs: Pair[]): string {
return pairs
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
.map(formatBulkPairLine)
.join("\n");
}
export function parseBulkPairs(text: string): PairWithId[] {
return text
.split("\n")
.filter((l: string) => l.trim())
.map(parseBulkPairLine)
.filter((p) => p != null);
}
/**
* Format a pair as a `name: value` line. Disabled pairs are commented out dotenv-style, as
* `# name: value`, so the enabled state survives a round trip through {@link parseBulkPairLine}.
*/
export function formatBulkPairLine(pair: Pair) {
const value = pair.value.replaceAll("\n", "\\n");
const line = `${pair.name}: ${value}`;
return pair.enabled === false ? `# ${line}` : line;
return `${pair.name}: ${value}`;
}
const PAIR_REGEX = /^([^:]+):\s+(.*)$/;
// Commented-out pairs may also end at the colon, so a hand-typed `# name:` isn't dropped
const COMMENTED_PAIR_REGEX = /^([^:]+):(?:\s+(.*))?$/;
// A # only marks a comment when followed by whitespace (or nothing), so `#foo: bar` stays an
// enabled pair named `#foo`
const COMMENT_PREFIX_REGEX = /^\s*#(?:\s+|$)/;
/**
* Parse a `name: value` line into an enabled pair. A line starting with `# ` is a disabled pair
* if the rest of it parses as `name: value`, otherwise it's a free-text comment and `null` is
* returned so it can be dropped.
*/
export function parseBulkPairLine(line: string): PairWithId | null {
const commentPrefix = line.match(COMMENT_PREFIX_REGEX);
if (commentPrefix != null) {
const uncommented = line.slice(commentPrefix[0].length);
const match = uncommented.match(COMMENTED_PAIR_REGEX);
if (match == null) return null;
return { ...pairFromMatch(uncommented, match), enabled: false };
}
return pairFromMatch(line, line.match(PAIR_REGEX));
}
function pairFromMatch(line: string, match: RegExpMatchArray | null): PairWithId {
const [, name, value] = match ?? [];
export function parseBulkPairLine(line: string): PairWithId {
const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
return {
enabled: true,
name: (name ?? line).trim(),
@@ -1,6 +1,7 @@
import { HStack, Icon, IconTooltip } from "@yaakapp-internal/ui";
import { HStack, Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
import { IconTooltip } from "./IconTooltip";
export interface CheckboxProps {
checked: boolean | "indeterminate";
@@ -1,27 +1,60 @@
import {
DetailsBanner as BaseDetailsBanner,
type DetailsBannerProps,
type StoredValue,
} from "@yaakapp-internal/ui";
import { atom, useAtom, type WritableAtom } from "jotai";
import { type SetStateAction, useMemo } from "react";
import classNames from "classnames";
import { atom, useAtom } from "jotai";
import type { HTMLAttributes, ReactNode } from "react";
import { useMemo } from "react";
import { atomWithKVStorage } from "../../lib/atoms/atomWithKVStorage";
import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner } from "@yaakapp-internal/ui";
export function DetailsBanner(props: Omit<DetailsBannerProps, "useStoredValue">) {
return <BaseDetailsBanner {...props} useStoredValue={useDetailsBannerStoredValue} />;
interface Props extends HTMLAttributes<HTMLDetailsElement> {
summary: ReactNode;
color?: BannerProps["color"];
defaultOpen?: boolean;
storageKey?: string;
}
export function useDetailsBannerStoredValue<T extends StoredValue>(
key: string | string[] | null,
fallback: T,
) {
// oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when the key changes
const valueAtom = useMemo<WritableAtom<T, [SetStateAction<T>], void>>(
export function DetailsBanner({
className,
color,
summary,
children,
defaultOpen,
storageKey,
...extraProps
}: Props) {
// oxlint-disable-next-line react-hooks/exhaustive-deps -- We only want to recompute the atom when storageKey changes
const openAtom = useMemo(
() =>
key != null
? atomWithKVStorage<T>(["details_banner", ...[key].flat()], fallback)
: atom(fallback),
[key],
storageKey
? atomWithKVStorage<boolean>(["details_banner", storageKey], defaultOpen ?? false)
: atom(defaultOpen ?? false),
[storageKey],
);
const [isOpen, setIsOpen] = useAtom(openAtom);
const handleToggle = (e: React.SyntheticEvent<HTMLDetailsElement>) => {
if (storageKey) {
setIsOpen(e.currentTarget.open);
}
};
return (
<Banner color={color} className={className}>
<details className="group list-none" open={isOpen} onToggle={handleToggle} {...extraProps}>
<summary className="cursor-default! select-none! list-none flex items-center gap-3 focus:outline-hidden opacity-70">
<div
className={classNames(
"transition-transform",
"group-open:rotate-90",
"w-0 h-0 border-t-[0.3em] border-b-[0.3em] border-l-[0.5em] border-r-0",
"border-t-transparent border-b-transparent border-l-text-subtle",
)}
/>
{summary}
</summary>
<div className="mt-1.5 pb-2">{children}</div>
</details>
</Banner>
);
return useAtom(valueAtom);
}
+3 -10
View File
@@ -1,12 +1,4 @@
import {
HStack,
Icon,
type IconProps,
LoadingIcon,
Overlay,
VStack,
useStateWithDeps,
} from "@yaakapp-internal/ui";
import { HStack, Icon, type IconProps, LoadingIcon, Overlay, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { atom } from "jotai";
import * as m from "motion/react-m";
@@ -36,6 +28,8 @@ import { useKey, useWindowSize } from "react-use";
import { useClickOutside } from "../../hooks/useClickOutside";
import type { HotkeyAction } from "../../hooks/useHotKey";
import { useHotKey } from "../../hooks/useHotKey";
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { generateId } from "../../lib/generateId";
import { getNodeText } from "../../lib/getNodeText";
import { jotaiStore } from "../../lib/jotai";
import { fireAndForget } from "../../lib/fireAndForget";
@@ -45,7 +39,6 @@ import { Hotkey } from "./Hotkey";
import { IconButton } from "./IconButton";
import type { SeparatorAction } from "./Separator";
import { Separator } from "./Separator";
import { generateId } from "@yaakapp-internal/lib/generateId";
export type DropdownItemSeparator = {
type: "separator";
@@ -5,8 +5,7 @@ import { EditorView } from "@codemirror/view";
import classNames from "classnames";
import { useEffect, useRef } from "react";
import "./DiffViewer.css";
import { syntaxHighlightStyle } from "@yaakapp-internal/ui/editor";
import { readonlyExtensions } from "./extensions";
import { readonlyExtensions, syntaxHighlightStyle } from "./extensions";
interface Props {
/** Original/previous version (left side) */
@@ -1,5 +1,4 @@
@reference "tailwindcss";
@reference "../../../../tailwind-config/index.css";
@reference "../../../main.css";
.cm-wrapper {
@apply h-full overflow-hidden;
@@ -1,53 +1,147 @@
import { startCompletion } from "@codemirror/autocomplete";
import type { Extension } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands";
import { foldState, forceParsing } from "@codemirror/language";
import type { EditorStateConfig, Extension } from "@codemirror/state";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView, keymap, placeholder as placeholderExt, tooltips } from "@codemirror/view";
import { emacs } from "@replit/codemirror-emacs";
import { vim } from "@replit/codemirror-vim";
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
import { debounce } from "@yaakapp-internal/lib";
import type { EditorKeymap } from "@yaakapp-internal/models";
import { settingsAtom } from "@yaakapp-internal/models";
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
import { CodeEditor, type CodeEditorProps } from "@yaakapp-internal/ui/editor";
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { GraphQLSchema } from "graphql";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useRef } from "react";
import type { ReactNode, RefObject } from "react";
import {
Children,
cloneElement,
isValidElement,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
} from "react";
import { activeEnvironmentAtom } from "../../../hooks/useActiveEnvironment";
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables";
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
import { useRequestEditor } from "../../../hooks/useRequestEditor";
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
import { docFingerprint } from "../../../lib/docFingerprint";
import { editEnvironment } from "../../../lib/editEnvironment";
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
import { jotaiStore } from "../../../lib/jotai";
import { withEncryptionEnabled } from "../../../lib/setupOrConfigureEncryption";
import { TemplateFunctionDialog } from "../../TemplateFunctionDialog";
import { getLanguageExtension } from "./extensions";
import { IconButton } from "../IconButton";
import "./Editor.css";
import {
baseExtensions,
editableExtensions,
getLanguageExtension,
multiLineExtensions,
readonlyExtensions,
} from "./extensions";
import type { GenericCompletionConfig } from "./genericCompletion";
import { largeValues, mediaValues } from "./largeValues";
import { singleLineExtensions } from "./singleLine";
export type EditorProps = Omit<CodeEditorProps, "language" | "extensions" | "keymap"> & {
// VSCode's Tab actions mess with the single-line editor tab actions, so remove it.
const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== "Tab");
const keymapExtensions: Record<EditorKeymap, Extension> = {
vim: vim(),
emacs: emacs(),
vscode: keymap.of(vsCodeWithoutTab),
default: [],
};
export interface EditorProps {
actions?: ReactNode;
autoFocus?: boolean;
autoSelect?: boolean;
autocomplete?: GenericCompletionConfig;
autocompleteFunctions?: boolean;
autocompleteVariables?: boolean | ((v: WrappedEnvironmentVariable) => boolean);
className?: string;
defaultValue?: string | null;
disableTabIndent?: boolean;
disabled?: boolean;
extraExtensions?: Extension[] | Extension;
forcedEnvironmentId?: string;
graphQLSchema?: GraphQLSchema | null;
forceUpdateKey?: string | number;
format?: (v: string) => Promise<string>;
heightMode?: "auto" | "full";
hideGutter?: boolean;
id?: string;
language?: EditorLanguage | "pairs" | "url" | "timeline" | null;
};
lintExtension?: Extension;
graphQLSchema?: GraphQLSchema | null;
onBlur?: () => void;
onChange?: (value: string) => void;
onFocus?: () => void;
onKeyDown?: (e: KeyboardEvent) => void;
onPaste?: (value: string) => void;
onPasteOverwrite?: (e: ClipboardEvent, value: string) => void;
placeholder?: string;
readOnly?: boolean;
singleLine?: boolean;
containerOnly?: boolean;
stateKey: string | null;
tooltipContainer?: HTMLElement;
type?: "text" | "password";
wrapLines?: boolean;
setRef?: (view: EditorView | null) => void;
}
const stateFields = { history: historyField, folds: foldState };
const emptyVariables: WrappedEnvironmentVariable[] = [];
const emptyExtension: Extension = [];
export function Editor({
export function Editor(props: EditorProps) {
return <EditorInner key={props.stateKey} {...props} />;
}
function EditorInner({
actions,
autoFocus,
autoSelect,
autocomplete,
autocompleteFunctions,
autocompleteVariables,
className,
defaultValue,
disableTabIndent,
disabled,
extraExtensions,
forcedEnvironmentId,
forceUpdateKey,
format,
graphQLSchema,
heightMode,
hideGutter,
graphQLSchema,
language,
lintExtension,
onBlur,
onChange,
onFocus,
onKeyDown,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
singleLine,
containerOnly,
stateKey,
type,
wrapLines,
...props
setRef,
}: EditorProps) {
const settings = useAtomValue(settingsAtom);
@@ -64,10 +158,20 @@ export function Editor({
wrapLines = settings.editorSoftWrap;
}
if (props.disabled) {
if (disabled) {
readOnly = true;
}
if (
singleLine ||
language == null ||
language === "text" ||
language === "url" ||
language === "pairs"
) {
disableTabIndent = true;
}
if (format == null && !readOnly) {
format =
language === "json"
@@ -77,22 +181,130 @@ export function Editor({
: undefined;
}
// Template dialogs are opened against the editor the tag was clicked in
const viewRef = useRef<EditorView | null>(null);
const { setRef } = props;
const handleSetRef = useCallback(
(view: EditorView | null) => {
viewRef.current = view;
setRef?.(view);
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
// Use ref so we can update the handler without re-initializing the editor
const handleChange = useRef<EditorProps["onChange"]>(onChange);
useEffect(() => {
handleChange.current = onChange;
}, [onChange]);
// Use ref so we can update the handler without re-initializing the editor
const handlePaste = useRef<EditorProps["onPaste"]>(onPaste);
useEffect(() => {
handlePaste.current = onPaste;
}, [onPaste]);
// Use ref so we can update the handler without re-initializing the editor
const handlePasteOverwrite = useRef<EditorProps["onPasteOverwrite"]>(onPasteOverwrite);
useEffect(() => {
handlePasteOverwrite.current = onPasteOverwrite;
}, [onPasteOverwrite]);
// Use ref so we can update the handler without re-initializing the editor
const handleFocus = useRef<EditorProps["onFocus"]>(onFocus);
useEffect(() => {
handleFocus.current = onFocus;
}, [onFocus]);
// Use ref so we can update the handler without re-initializing the editor
const handleBlur = useRef<EditorProps["onBlur"]>(onBlur);
useEffect(() => {
handleBlur.current = onBlur;
}, [onBlur]);
// Use ref so we can update the handler without re-initializing the editor
const handleKeyDown = useRef<EditorProps["onKeyDown"]>(onKeyDown);
useEffect(() => {
handleKeyDown.current = onKeyDown;
}, [onKeyDown]);
// Update placeholder
const placeholderCompartment = useRef(new Compartment());
useEffect(
function configurePlaceholder() {
if (cm.current === null) return;
const ext = placeholderExt(placeholderElFromText(placeholder));
const effects = placeholderCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
[setRef],
[placeholder],
);
// Update vim
const keymapCompartment = useRef(new Compartment());
useEffect(
function configureKeymap() {
if (cm.current === null) return;
const current = keymapCompartment.current.get(cm.current.view.state) ?? [];
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (settings.editorKeymap === "default" && current === keymapExtensions.default) return; // Nothing to do
if (settings.editorKeymap === "vim" && current === keymapExtensions.vim) return; // Nothing to do
if (settings.editorKeymap === "vscode" && current === keymapExtensions.vscode) return; // Nothing to do
if (settings.editorKeymap === "emacs" && current === keymapExtensions.emacs) return; // Nothing to do
const ext = keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default;
const effects = keymapCompartment.current.reconfigure(ext);
cm.current.view.dispatch({ effects });
},
[settings.editorKeymap],
);
// Update wrap lines
const wrapLinesCompartment = useRef(new Compartment());
useEffect(
function configureWrapLines() {
if (cm.current === null) return;
const current = wrapLinesCompartment.current.get(cm.current.view.state) ?? emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (wrapLines && current !== emptyExtension) return; // Nothing to do
if (!wrapLines && current === emptyExtension) return; // Nothing to do
const ext = wrapLines ? EditorView.lineWrapping : emptyExtension;
const effects = wrapLinesCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
[wrapLines],
);
// Update tab indent
const tabIndentCompartment = useRef(new Compartment());
useEffect(
function configureTabIndent() {
if (cm.current === null) return;
const current = tabIndentCompartment.current.get(cm.current.view.state) ?? emptyExtension;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (disableTabIndent && current !== emptyExtension) return; // Nothing to do
if (!disableTabIndent && current === emptyExtension) return; // Nothing to do
const ext = !disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension;
const effects = tabIndentCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
[disableTabIndent],
);
// Update read-only
const readOnlyCompartment = useRef(new Compartment());
useEffect(
function configureReadOnly() {
if (cm.current === null) return;
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
const next = readOnly ? readonlyExtensions : editableExtensions;
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (current === next) return;
const effects = readOnlyCompartment.current.reconfigure(next);
cm.current?.view.dispatch({ effects });
},
[readOnly],
);
const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const show = () => {
if (viewRef.current === null) return;
TemplateFunctionDialog.show(fn, tagValue, startPos, viewRef.current);
if (cm.current === null) return;
TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view);
};
if (fn.name === "secure") {
@@ -132,64 +344,427 @@ export function Editor({
!!autocompleteFunctions,
);
const languageExtension = useMemo(
() =>
getLanguageExtension({
useTemplating,
language,
lintExtension,
hideGutter,
environmentVariables,
autocomplete,
completionOptions,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
graphQLSchema: graphQLSchema ?? null,
}),
[
// Update the language extension when the language changes
// oxlint-disable-next-line react-hooks/exhaustive-deps -- intentionally limited deps
useEffect(() => {
if (cm.current === null) return;
const { view, languageCompartment } = cm.current;
const ext = getLanguageExtension({
useTemplating,
language,
lintExtension,
autocomplete,
hideGutter,
environmentVariables,
autocomplete,
completionOptions,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
completionOptions,
useTemplating,
graphQLSchema,
hideGutter,
],
graphQLSchema: graphQLSchema ?? null,
});
view.dispatch({ effects: languageCompartment.reconfigure(ext) });
}, [
language,
lintExtension,
autocomplete,
environmentVariables,
onClickFunction,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
completionOptions,
useTemplating,
graphQLSchema,
hideGutter,
]);
// Initialize the editor when ref mounts
// oxlint-disable-next-line react-hooks/exhaustive-deps -- only reinitialize when necessary
const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) {
if (container === null) {
flushCachedEditorState(stateKey);
cm.current?.view.destroy();
cm.current = null;
return;
}
try {
const languageCompartment = new Compartment();
const langExt = getLanguageExtension({
useTemplating,
language,
lintExtension,
completionOptions,
autocomplete,
environmentVariables,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
graphQLSchema: graphQLSchema ?? null,
});
const extensions = [
languageCompartment.of(langExt),
placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))),
wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension),
tabIndentCompartment.current.of(
!disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension,
),
keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions),
...getExtensions({
container,
singleLine,
hideGutter,
stateKey,
onChange: handleChange,
onPaste: handlePaste,
onPasteOverwrite: handlePasteOverwrite,
onFocus: handleFocus,
onBlur: handleBlur,
onKeyDown: handleKeyDown,
}),
...(Array.isArray(extraExtensions)
? extraExtensions
: extraExtensions
? [extraExtensions]
: []),
];
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly);
const doc = `${defaultValue ?? ""}`;
const config: EditorStateConfig = { extensions, doc };
const state = cachedJsonState
? EditorState.fromJSON(cachedJsonState, config, stateFields)
: EditorState.create(config);
const view = new EditorView({ state, parent: container });
// For large documents, the parser may parse the max number of lines and fail to add
// things like fold markers because of it.
// This forces it to parse more but keeps the timeout to the default of 100 ms.
forceParsing(view, 9e6, 100);
cm.current = { view, languageCompartment };
if (autoFocus) {
view.focus();
}
if (autoSelect) {
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
setRef?.(view);
} catch (e) {
console.log("Failed to initialize Codemirror", e);
}
},
[forceUpdateKey],
);
return (
<CodeEditor
{...props}
editableExtensions={mediaValues}
extensions={extraExtensions}
format={format}
hideGutter={hideGutter}
keyDownHandler={startCompletionOnHotkey}
keymap={settings.editorKeymap}
language={codeEditorLanguage(language)}
languageExtension={languageExtension}
lintExtension={lintExtension}
readOnly={readOnly}
readOnlyExtensions={largeValues}
setRef={handleSetRef}
wrapLines={wrapLines}
// For read-only mode, update content when `defaultValue` changes
useEffect(
function updateReadOnlyEditor() {
if (readOnly && cm.current?.view != null) {
updateContents(cm.current.view, defaultValue || "");
}
},
[defaultValue, readOnly],
);
// Force input to update when receiving change and not in focus
useLayoutEffect(
function updateNonFocusedEditor() {
const notFocused = !cm.current?.view.hasFocus;
if (notFocused && cm.current != null) {
updateContents(cm.current.view, defaultValue || "");
}
},
[defaultValue],
);
// Add bg classes to actions, so they appear over the text
const decoratedActions = useMemo(() => {
const results = [];
const actionClassName = classNames(
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:opacity-100! shadow",
);
if (format) {
results.push(
<IconButton
showConfirm
key="format"
size="sm"
title="Reformat contents"
icon="magic_wand"
variant="border"
className={classNames(actionClassName)}
onClick={async () => {
if (cm.current === null) return;
const { doc } = cm.current.view.state;
const formatted = await format(doc.toString());
// Update editor and blur because the cursor will reset anyway
cm.current.view.dispatch({
changes: { from: 0, to: doc.length, insert: formatted },
});
cm.current.view.contentDOM.blur();
// Fire change event
onChange?.(formatted);
}}
/>,
);
}
results.push(
Children.map(actions, (existingChild) => {
if (!isValidElement<{ className?: string }>(existingChild)) return null;
const existingProps = existingChild.props;
return cloneElement(existingChild, {
...existingProps,
className: classNames(existingProps.className, actionClassName),
});
}),
);
return results;
}, [actions, format, onChange]);
const cmContainer = (
<div
ref={initEditorRef}
className={classNames(
className,
"cm-wrapper text-base",
disabled && "opacity-disabled",
type === "password" && "cm-obscure-text",
heightMode === "auto" ? "cm-auto-height" : "cm-full-height",
singleLine ? "cm-singleline" : "cm-multiline",
readOnly && "cm-readonly",
)}
/>
);
if (singleLine || containerOnly) {
return cmContainer;
}
return (
<div className="group relative h-full w-full x-theme-editor bg-surface">
{cmContainer}
{decoratedActions && (
<HStack
space={1}
justifyContent="end"
className={classNames(
"absolute bottom-2 left-0 right-0",
"pointer-events-none", // No pointer events, so we don't block the editor
)}
>
{decoratedActions}
</HStack>
)}
</div>
);
}
/** The client's own languages bring their extension along, so CodeEditor needn't know them */
function codeEditorLanguage(language: EditorProps["language"]): CodeEditorProps["language"] {
return language === "url" || language === "pairs" || language === "timeline" ? null : language;
function getExtensions({
stateKey,
container,
singleLine,
hideGutter,
onChange,
onPaste,
onPasteOverwrite,
onFocus,
onBlur,
onKeyDown,
}: Pick<EditorProps, "singleLine" | "hideGutter"> & {
stateKey: EditorProps["stateKey"];
container: HTMLDivElement | null;
onChange: RefObject<EditorProps["onChange"]>;
onPaste: RefObject<EditorProps["onPaste"]>;
onPasteOverwrite: RefObject<EditorProps["onPasteOverwrite"]>;
onFocus: RefObject<EditorProps["onFocus"]>;
onBlur: RefObject<EditorProps["onBlur"]>;
onKeyDown: RefObject<EditorProps["onKeyDown"]>;
}) {
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
container?.closest<HTMLDivElement>('[role="dialog"]') ??
document.querySelector<HTMLDivElement>("#cm-portal") ??
undefined;
return [
...baseExtensions, // Must be first
EditorView.contentAttributes.of({
autocapitalize: "off",
autocorrect: "off",
// Keeps macOS Writing Tools from offering to write code for us
writingsuggestions: "false",
}),
EditorView.domEventHandlers({
focus: () => {
onFocus.current?.();
},
blur: () => {
onBlur.current?.();
},
keydown: (e, view) => {
// Check if the hotkey matches the editor.autocomplete action
if (eventMatchesHotkey(e, "editor.autocomplete")) {
e.preventDefault();
startCompletion(view);
return true;
}
onKeyDown.current?.(e);
},
paste: (e, v) => {
const textData = e.clipboardData?.getData("text/plain") ?? "";
onPaste.current?.(textData);
if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) {
onPasteOverwrite.current?.(e, textData);
}
},
}),
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
// ------------------------ //
// Things that must be last //
// ------------------------ //
EditorView.updateListener.of((update) => {
if (update.startState === update.state) return;
if (onChange && update.docChanged) {
onChange.current?.(update.state.doc.toString());
}
saveCachedEditorStateDebounced(stateKey, update.state);
}),
];
}
function startCompletionOnHotkey(e: KeyboardEvent, view: EditorView): boolean {
if (!eventMatchesHotkey(e, "editor.autocomplete")) return false;
e.preventDefault();
startCompletion(view);
return true;
const placeholderElFromText = (text: string | undefined) => {
const el = document.createElement("div");
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
// to do with how Yaak "hacks" it with CSS for single line input.
el.innerHTML = text ? text.replaceAll("\n", "<br/>") : " ";
return el;
};
// Caching the state is too expensive to do on every update (every keystroke and cursor move),
// so debounce it per state key and flush when the editor unmounts.
//
// The cost scales with the document, and every update pays it in full:
// - `state.toJSON` flattens the whole rope into a string, ~0.13 ms per 200 KB
// - the fingerprint is an exact md5 for editable documents, ~0.3 ms per 200 KB. `docFingerprint`
// only samples read-only ones, so editing a large body still hashes all of it
// - `sessionStorage.setItem` is synchronous and blocks the main thread
// Typing in a 200 KB body costs ~0.4 ms per keystroke before the storage write, and a 1 MB one
// ~2.3 ms. Read-only documents skip most of the hashing but still serialize, which is the larger
// half once documents get into the megabytes.
const SAVE_STATE_DEBOUNCE_MS = 500;
const stateSavers = new Map<string, ReturnType<typeof debounce>>();
function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) {
if (!stateKey) return;
let saver = stateSavers.get(stateKey);
if (saver == null) {
saver = debounce(
(s: EditorState) => saveCachedEditorState(stateKey, s),
SAVE_STATE_DEBOUNCE_MS,
);
stateSavers.set(stateKey, saver);
}
saver(state);
}
// NOTE: Only called when an editor unmounts, so the saver is dropped rather than left in the map
// for every state key the session has ever shown. A pending saver holds the last EditorState,
// which holds the whole document.
function flushCachedEditorState(stateKey: string | null) {
if (!stateKey) return;
const saver = stateSavers.get(stateKey);
if (saver == null) return;
saver.flush();
stateSavers.delete(stateKey);
}
function saveCachedEditorState(stateKey: string | null, state: EditorState | null) {
if (!stateKey || state == null) return;
const stateObj = state.toJSON(stateFields);
// Save state in sessionStorage by removing doc and saving a fingerprint of it instead.
// This will be checked on restore and put back in if it matches.
// Editable documents get the exact hash, so a collision can never restore undo history
// belonging to other content
stateObj.docHash = docFingerprint(stateObj.doc, { exact: !state.readOnly });
stateObj.doc = undefined;
try {
sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj));
} catch (err) {
console.log("Failed to save to editor state", stateKey, err);
}
}
function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) {
if (stateKey == null) return;
try {
const stateStr = sessionStorage.getItem(computeFullStateKey(stateKey));
if (stateStr == null) return null;
const { docHash, ...state } = JSON.parse(stateStr);
// Ensure the doc matches the one that was used to save the state
if (docHash !== docFingerprint(doc, { exact: !readOnly })) {
return null;
}
state.doc = doc;
return state;
} catch (err) {
console.log("Failed to restore editor storage", stateKey, err);
}
return null;
}
function computeFullStateKey(stateKey: string): string {
return `editor.${stateKey}`;
}
function updateContents(view: EditorView, text: string) {
// Replace codemirror contents
const currentDoc = view.state.doc.toString();
if (currentDoc === text) {
return;
}
if (text.startsWith(currentDoc)) {
// If we're just appending, append only the changes. This preserves
// things like scroll position.
view.dispatch({
changes: view.state.changes({
from: currentDoc.length,
insert: text.slice(currentDoc.length),
}),
});
} else {
// If we're replacing everything, reset the entire content
view.dispatch({
changes: view.state.changes({
from: 0,
to: currentDoc.length,
insert: text,
}),
});
}
}
@@ -1,30 +1,141 @@
import { linter } from "@codemirror/lint";
import type { Extension } from "@codemirror/state";
import {
closeBracketsExtensions,
getLanguageExtension as getBaseLanguageExtension,
type LanguageSupports,
readonlyExtensions as baseReadonlyExtensions,
syntaxExtensions,
} from "@yaakapp-internal/ui/editor";
autocompletion,
closeBrackets,
closeBracketsKeymap,
completionKeymap,
} from "@codemirror/autocomplete";
import { history, historyKeymap } from "@codemirror/commands";
import { go } from "@codemirror/lang-go";
import { java } from "@codemirror/lang-java";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
import { php } from "@codemirror/lang-php";
import { python } from "@codemirror/lang-python";
import { xml } from "@codemirror/lang-xml";
import {
bracketMatching,
codeFolding,
foldGutter,
foldKeymap,
HighlightStyle,
indentOnInput,
LanguageSupport,
StreamLanguage,
syntaxHighlighting,
} from "@codemirror/language";
import { c, csharp, kotlin, objectiveC } from "@codemirror/legacy-modes/mode/clike";
import { clojure } from "@codemirror/legacy-modes/mode/clojure";
import { http } from "@codemirror/legacy-modes/mode/http";
import { oCaml } from "@codemirror/legacy-modes/mode/mllike";
import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
import { r } from "@codemirror/legacy-modes/mode/r";
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { swift } from "@codemirror/legacy-modes/mode/swift";
import { linter, lintGutter, lintKeymap } from "@codemirror/lint";
import { search, searchKeymap } from "@codemirror/search";
import type { Extension } from "@codemirror/state";
import { EditorState } from "@codemirror/state";
import {
crosshairCursor,
drawSelection,
dropCursor,
EditorView,
highlightActiveLineGutter,
highlightSpecialChars,
keymap,
lineNumbers,
rectangularSelection,
} from "@codemirror/view";
import { tags as t } from "@lezer/highlight";
import { jsonc, jsoncLanguage } from "@shopify/lang-jsonc";
import { graphql } from "cm6-graphql";
import type { GraphQLSchema } from "graphql";
import { activeRequestIdAtom } from "../../../hooks/useActiveRequestId";
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
import { jotaiStore } from "../../../lib/jotai";
import { renderMarkdown } from "../../../lib/markdown";
import { pluralizeCount } from "../../../lib/pluralize";
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
import type { EditorProps } from "./Editor";
import { jsonParseLinter } from "./json-lint";
import { largeValues } from "./largeValues";
import { largeValues, mediaValues } from "./largeValues";
import { pairs } from "./pairs/extension";
import { searchMatchCount } from "./searchMatchCount";
import { text } from "./text/extension";
import { timeline } from "./timeline/extension";
import type { TwigCompletionOption } from "./twig/completion";
import { twig } from "./twig/extension";
import { pathParametersPlugin } from "./twig/pathParameters";
import { url } from "./url/extension";
const languages: LanguageSupports = { ...syntaxExtensions, url, pairs, timeline };
export const syntaxHighlightStyle = HighlightStyle.define([
{
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
color: "var(--textSubtlest)",
},
{
tag: [t.emphasis],
textDecoration: "underline",
},
{
tag: [t.angleBracket, t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation],
color: "var(--textSubtle)",
},
{
tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number],
color: "var(--info)",
},
{ tag: [t.variableName], color: "var(--success)" },
{ tag: [t.bool], color: "var(--warning)" },
{ tag: [t.attributeName, t.propertyName], color: "var(--primary)" },
{ tag: [t.attributeValue], color: "var(--warning)" },
{ tag: [t.string], color: "var(--notice)" },
{ tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: "var(--danger)" },
]);
const syntaxTheme = EditorView.theme({}, { dark: true });
const closeBracketsExtensions: Extension = [closeBrackets(), keymap.of([...closeBracketsKeymap])];
const legacyLang = (mode: Parameters<typeof StreamLanguage.define>[0]) => {
return () => new LanguageSupport(StreamLanguage.define(mode));
};
const syntaxExtensions: Record<
NonNullable<EditorProps["language"]>,
null | (() => LanguageSupport)
> = {
graphql: null,
json: jsonc,
javascript: javascript,
// HTML as XML because HTML is oddly slow
html: xml,
xml: xml,
url: url,
pairs: pairs,
text: text,
timeline: timeline,
markdown: markdown,
c: legacyLang(c),
clojure: legacyLang(clojure),
csharp: legacyLang(csharp),
go: go,
http: legacyLang(http),
java: java,
kotlin: legacyLang(kotlin),
objective_c: legacyLang(objectiveC),
ocaml: legacyLang(oCaml),
php: php,
powershell: legacyLang(powerShell),
python: python,
r: legacyLang(r),
ruby: legacyLang(ruby),
shell: legacyLang(shell),
swift: legacyLang(swift),
};
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ["json", "javascript", "graphql"];
export function getLanguageExtension({
useTemplating,
@@ -46,7 +157,18 @@ export function getLanguageExtension({
onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[];
graphQLSchema: GraphQLSchema | null;
} & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">): Extension {
} & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">) {
const extraExtensions: Extension[] = [];
if (language === "url") {
extraExtensions.push(pathParametersPlugin(onClickPathParameter));
}
// Only close brackets on languages that need it
if (language && closeBracketsFor.includes(language)) {
extraExtensions.push(closeBracketsExtensions);
}
// GraphQL is a special exception
if (language === "graphql") {
return [
@@ -67,31 +189,155 @@ export function getLanguageExtension({
}));
},
}),
[closeBracketsExtensions],
extraExtensions,
];
}
return getBaseLanguageExtension({
language,
hideGutter,
languages,
lintExtension: language === "json" ? (lintExtension ?? linter(jsonParseLinter())) : undefined,
extraExtensions: language === "url" ? [pathParametersPlugin(onClickPathParameter)] : [],
wrap: useTemplating
? (base, extraExtensions) =>
twig({
base,
environmentVariables,
completionOptions,
autocomplete,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
extraExtensions,
})
: undefined,
if (language === "json") {
extraExtensions.push(lintExtension ?? linter(jsonParseLinter()));
extraExtensions.push(
jsoncLanguage.data.of({
commentTokens: { line: "//", block: { open: "/*", close: "*/" } },
}),
);
if (!hideGutter) {
extraExtensions.push(lintGutter());
}
}
const maybeBase = language ? syntaxExtensions[language] : null;
const base = typeof maybeBase === "function" ? maybeBase() : null;
if (base == null) {
return [];
}
if (!useTemplating) {
return [base, extraExtensions];
}
return twig({
base,
environmentVariables,
completionOptions,
autocomplete,
onClickVariable,
onClickMissingVariable,
onClickPathParameter,
extraExtensions,
});
}
// Nobody is editing this, so every rule applies
export const readonlyExtensions = [...baseReadonlyExtensions, largeValues];
// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys.
// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings.
const filteredCompletionKeymap = completionKeymap.filter((binding) => {
const key = binding.key?.toLowerCase() ?? "";
const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? "";
// Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i)
const isStartTrigger = key.includes("space") || mac.includes("alt-") || mac.includes("`");
return !isStartTrigger;
});
export const baseExtensions = [
highlightSpecialChars(),
history(),
dropCursor(),
drawSelection(),
autocompletion({
tooltipClass: () => "x-theme-menu",
closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
defaultKeymap: false, // We handle the trigger via configurable hotkeys
compareCompletions: (a, b) => {
// Don't sort completions at all, only on boost
return (a.boost ?? 0) - (b.boost ?? 0);
},
}),
syntaxHighlighting(syntaxHighlightStyle),
syntaxTheme,
keymap.of([...historyKeymap, ...filteredCompletionKeymap]),
];
export const readonlyExtensions = [
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({ tabindex: "-1" }),
// Nobody is editing this, so every rule applies
largeValues,
];
/**
* The counterpart for a document being edited.
*
* Only encoded media collapses here: it is pasted rather than typed, and replaced rather than
* edited, so a tag stands in for it without hiding anything anyone meant to read. The rules that
* hide text by length alone stay out. See {@link mediaValues}.
*/
export const editableExtensions = [mediaValues];
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
search({ top: true }),
searchMatchCount(),
hideGutter
? []
: [
lineNumbers(),
foldGutter({
markerDOM: (open) => {
const el = document.createElement("div");
el.classList.add("fold-gutter-icon");
el.tabIndex = -1;
if (open) {
el.setAttribute("data-open", "");
}
return el;
},
}),
],
codeFolding({
placeholderDOM(_view, onclick, prepared) {
const el = document.createElement("span");
el.onclick = onclick;
el.className = "cm-foldPlaceholder";
el.innerText = prepared || "…";
el.title = "unfold";
el.ariaLabel = "folded code";
return el;
},
/**
* Show the number of items when code folded. NOTE: this doesn't get called when restoring
* a previous serialized editor state, which is a bummer
*/
preparePlaceholder(state, range) {
let count: number | undefined;
let startToken = "{";
let endToken = "}";
const prevLine = state.doc.lineAt(range.from).text;
const isArray = prevLine.lastIndexOf("[") > prevLine.lastIndexOf("{");
if (isArray) {
startToken = "[";
endToken = "]";
}
const internal = state.sliceDoc(range.from, range.to);
const toParse = startToken + internal + endToken;
try {
const parsed = JSON.parse(toParse);
count = Object.keys(parsed).length;
} catch {
/* empty */
}
if (count !== undefined) {
const label = isArray ? "item" : "key";
return pluralizeCount(label, count);
}
},
}),
indentOnInput(),
rectangularSelection(),
crosshairCursor(),
bracketMatching(),
highlightActiveLineGutter(),
keymap.of([...searchKeymap, ...foldKeymap, ...lintKeymap]),
];
@@ -1,46 +0,0 @@
import type { EditorView } from "@codemirror/view";
import { describe, expect, test } from "vite-plus/test";
import { jsonParseLinter } from "./json-lint";
// The linter only reads the document text off the view
function view(doc: string): EditorView {
return { state: { doc: { toString: () => doc } } } as EditorView;
}
const lint = jsonParseLinter();
describe("jsonParseLinter", () => {
test("reports nothing for an empty document", () => {
expect(lint(view(" "))).toEqual([]);
});
test("reports nothing for valid JSON", () => {
expect(lint(view('{"a": 1}'))).toEqual([]);
});
test("reports a message for invalid JSON", () => {
expect(lint(view('{"a": }'))).toHaveLength(1);
});
test("ignores a template tag standing in for a value", () => {
expect(lint(view('{"a": "${[ my_var ]}"}'))).toEqual([]);
});
test("ignores a tag whose quoted argument contains `]}`", () => {
expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}"}`))).toEqual([]);
});
test("ignores a tag whose quoted argument contains an escaped quote", () => {
expect(lint(view(`{"a": "\${[ fn(arg='it\\'s ]}') ]}"}`))).toEqual([]);
});
test("still reports errors around a tag with a quoted `]}`", () => {
expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}",}`))).toHaveLength(1);
});
test("keeps the tag's length so error offsets stay correct", () => {
const doc = `{"a" "\${[ fn(arg='x]}y') ]}"}`;
const [error] = lint(view(doc));
expect(error?.from).toEqual(doc.indexOf('"${['));
});
});
@@ -1,15 +1,60 @@
import {
type JsonLintOptions,
jsonParseLinter as baseJsonParseLinter,
} from "@yaakapp-internal/ui/editor";
import { replaceTemplateTags } from "../../../lib/templateTags";
import type { Diagnostic } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
export function jsonParseLinter(options?: Omit<JsonLintOptions, "escape">) {
return baseJsonParseLinter({ ...options, escape: escapeTemplateTags });
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
// jsonc-parser reports error codes, so these are the words the editor shows for them
const MESSAGES: Record<string, string> = {
InvalidSymbol: "Invalid symbol",
InvalidNumberFormat: "Invalid number format",
PropertyNameExpected: "Property name expected",
ValueExpected: "Value expected",
ColonExpected: "Colon expected",
CommaExpected: "Comma expected",
CloseBraceExpected: "Closing brace expected",
CloseBracketExpected: "Closing bracket expected",
EndOfFileExpected: "End of file expected",
InvalidCommentToken: "Comments are not allowed",
UnexpectedEndOfComment: "Unexpected end of comment",
UnexpectedEndOfString: "Unexpected end of string",
UnexpectedEndOfNumber: "Unexpected end of number",
InvalidUnicode: "Invalid unicode sequence",
InvalidEscapeCharacter: "Invalid escape character",
InvalidCharacter: "Invalid character",
};
interface JsonLintOptions {
allowComments?: boolean;
allowTrailingCommas?: boolean;
}
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
function escapeTemplateTags(doc: string): string {
return replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start));
export function jsonParseLinter(options?: JsonLintOptions) {
return (view: EditorView): Diagnostic[] => {
const doc = view.state.doc.toString();
if (doc.trim() === "") return [];
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
const errors: ParseError[] = [];
parse(escapedDoc, errors, {
allowTrailingComma: options?.allowTrailingCommas ?? false,
disallowComments: !(options?.allowComments ?? true),
});
// Later errors are mostly consequences of the first one, so only that one is shown
const error = errors[0];
if (error == null) return [];
return [
{
from: error.offset,
to: error.offset + error.length,
severity: "error",
message: MESSAGES[printParseErrorCode(error.error)] ?? "Invalid JSON",
},
];
};
}
@@ -107,29 +107,6 @@ describe("segmentsToJsonPath", () => {
});
describe("jsonPathToSegments", () => {
// The filter side of these keys is tested in crates/yaak-filter.
test.each([
"identifier",
"a.b",
"space key",
"",
'quote"key',
"back\\slash",
"combined\"\\]'key",
"*",
"$",
"$foo",
"?(true)",
"line\nbreak\t\u0000",
"emoji 🤔",
"__proto__",
])("round-trips the caret path to the literal key %j", (key) => {
const doc = JSON.stringify({ [key]: "needle-value", decoy: "wrong-value" }, null, 2);
const segments = jsonPathSegmentsAt(stateFor(doc), doc.indexOf('"needle-value"') + 4);
expect(segments).toEqual([{ kind: "key", key }]);
expect(jsonPathToSegments(segmentsToJsonPath(segments ?? []))).toEqual(segments);
});
test("round-trips paths built by segmentsToJsonPath", () => {
for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) {
const segments = jsonPathToSegments(path);
@@ -0,0 +1,93 @@
import { CompletionContext } from "@codemirror/autocomplete";
import { EditorState } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { describe, expect, it, vi } from "vite-plus/test";
import { jsonPathCompletion, jsonPathCompletionTarget } from "./pathCompletion";
import type { JsonPathChildren } from "./pathCompletion";
const root: JsonPathChildren = {
children: [
{ label: "users", selector: ".users", kind: "array" },
{ label: "total", selector: ".total", kind: "number" },
{ label: "a.b", selector: '["a.b"]', kind: "text" },
{ label: "a,b", selector: '["a,b"]', kind: "text" },
],
truncated: false,
};
function context(text: string, explicit = false, pos = text.length) {
return new CompletionContext(EditorState.create({ doc: text }), pos, explicit);
}
describe("progressive JSONPath completion", () => {
it.each([
["", "$", ""],
["$", "$", ""],
["$.", "$", "."],
["us", "$", "us"],
["users.na", "$.users", ".na"],
["$.users[", "$.users", "["],
["$.users[0].na", "$.users[0]", ".na"],
["$.users[*].", "$.users[*]", "."],
['$["a.b"].', '$["a.b"]', "."],
['$["a.b', "$", '["a.b'],
["$['a.b", "$", "['a.b"],
['$["a,b', "$", '["a,b'],
['$.users[?(@.name == "a.b")].', '$.users[?(@.name == "a.b")]', "."],
])("finds the next level in %s", (text, parent, fragment) => {
expect(jsonPathCompletionTarget(text)).toEqual({ parent, fragment });
});
it("loads only the parent, filters the typed prefix, and inserts a complete escaped path", async () => {
const load = vi.fn(async () => root);
const complete = jsonPathCompletion(load);
const result = await complete(context("us"));
expect(load).toHaveBeenCalledExactlyOnceWith("$");
expect(result?.options.map((o) => o.label)).toEqual(["users"]);
const quoted = await complete(context('$["a.b'));
const option = quoted!.options[0]!;
const dispatch = vi.fn();
expect(option.label).toBe("a.b");
if (typeof option.apply !== "function") throw new Error("Expected a completion function");
option.apply({ dispatch } as unknown as EditorView, option, 0, 6);
expect(dispatch.mock.calls[0]?.[0].changes).toEqual({ from: 0, to: 6, insert: '$["a.b"]' });
expect((await complete(context("$['a.b")))?.options.map((o) => o.label)).toEqual(["a.b"]);
});
it("explores a completed container without turning the path into an invalid draft", async () => {
const array: JsonPathChildren = {
children: [
{ label: "[*]", selector: "[*]", kind: "all items" },
{ label: "[0]", selector: "[0]", kind: "object" },
],
truncated: false,
};
const load = vi.fn(async (parent: string) => (parent === "$.users" ? array : root));
const result = await jsonPathCompletion(load)(context("$.users", true));
expect(load).toHaveBeenCalledExactlyOnceWith("$.users");
expect(result?.options.map((o) => o.label)).toEqual(["[*]", "[0]"]);
expect(
(await jsonPathCompletion(load)(context("$.users[0")))?.options.map((o) => o.label),
).toEqual(["[0]"]);
});
it("does not suggest descendants at the root or duplicate the current scalar path", async () => {
const load = vi.fn(async (parent: string) =>
parent === "$" ? root : { children: [], truncated: false },
);
const result = await jsonPathCompletion(load)(context(""));
expect(result?.options.map((o) => o.label)).toEqual(["users", "total", "a.b", "a,b"]);
expect(await jsonPathCompletion(load)(context("$.total", true))).toBeNull();
});
it("leaves complex expressions, mid-path editing, unavailable data, and stale results alone", async () => {
const load = vi.fn(async () => root);
expect(await jsonPathCompletion(load)(context("$.users[?(@.na"))).toBeNull();
expect(await jsonPathCompletion(load)(context("$..na"))).toBeNull();
expect(await jsonPathCompletion(load)(context("$.users[0].name", false, 5))).toBeNull();
expect(load).not.toHaveBeenCalled();
expect(await jsonPathCompletion(async () => null)(context("$"))).toBeNull();
const stale = context("$.");
Object.defineProperty(stale, "aborted", { get: () => true });
expect(await jsonPathCompletion(load)(stale)).toBeNull();
});
});
@@ -0,0 +1,119 @@
import type { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { startCompletion } from "@codemirror/autocomplete";
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
export type JsonPathChildren = RpcSchema["cmd_http_response_json_children"][1];
export type LoadJsonPathChildren = (parent: string) => Promise<JsonPathChildren | null>;
export function normalizeJsonPath(value: string): string {
const text = value.trim();
if (!text) return "$";
return text.startsWith("$") ? text : text.startsWith("[") ? `$${text}` : `$.${text}`;
}
/** Locate the unfinished child accessor, leaving complete JSONPath expressions to Rust. */
export function jsonPathCompletionTarget(
text: string,
): { parent: string; fragment: string } | null {
text = text.trim();
if (text.length > 1024) return null;
if (!text || text === "$") return { parent: "$", fragment: "" };
let quote = "";
let escaped = false;
let depth = 0;
let boundary = -1;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (quote) {
if (escaped) escaped = false;
else if (c === "\\") escaped = true;
else if (c === quote) quote = "";
continue;
}
if (depth > 0 && (c === '"' || c === "'")) quote = c;
else if (c === "[") {
if (depth === 0) boundary = i;
depth++;
} else if (c === "]") {
if (--depth < 0) return null;
} else if (depth === 0 && c === ".") boundary = i;
}
if (depth === 0 && text.endsWith("]")) return { parent: normalizeJsonPath(text), fragment: "" };
if (boundary === -1) return text.startsWith("$") ? null : { parent: "$", fragment: text };
const fragment = text.slice(boundary);
// Don't try to complete inside filters, unions, or recursive descent.
if (
depth > 1 ||
fragment.startsWith("[?") ||
fragment.startsWith("[(") ||
text[boundary - 1] === "." ||
fragment.startsWith("[...") ||
(fragment.includes(",") && !fragment.startsWith('["') && !fragment.startsWith("['"))
)
return null;
return { parent: normalizeJsonPath(text.slice(0, boundary)), fragment };
}
function matches(child: JsonPathChildren["children"][number], fragment: string): boolean {
if (!fragment) return true;
if (!fragment.startsWith("[")) return child.label.startsWith(fragment.replace(/^\./, ""));
if (child.selector.startsWith("[") && !child.selector.startsWith('["'))
return child.selector.startsWith(fragment);
const quoted = JSON.stringify(child.label);
const accessor = fragment.startsWith("['")
? `['${quoted.slice(1, -1).replace(/\\"/g, '"').replace(/'/g, "\\'")}']`
: `[${quoted}]`;
return accessor.startsWith(fragment);
}
/** Complete a path progressively. Discovery is cached by response + parent in the caller. */
export function jsonPathCompletion(load: LoadJsonPathChildren) {
return async (context: CompletionContext): Promise<CompletionResult | null> => {
// Never replace later path segments while the user edits the middle of an expression.
if (context.pos !== context.state.doc.length) return null;
context.addEventListener("abort", () => {}, { onDocChange: true });
const text = context.state.doc.toString();
let target = jsonPathCompletionTarget(text);
if (!target) return null;
let children: JsonPathChildren | null = null;
if (context.explicit && text.trim()) {
// Accepting a container or explicitly opening completion explores its children, without
// inserting an unfinished dot/bracket (the container itself may be the assertion target).
const parent = normalizeJsonPath(text);
children = await load(parent);
if (context.aborted) return null;
if (children?.children.length) target = { parent, fragment: "" };
else children = null;
}
children ??= await load(target.parent);
if (context.aborted || !children) return null;
const parent = target.parent;
const options = children.children
.filter((child) => matches(child, target.fragment))
.filter((child) => `${parent}${child.selector}` !== normalizeJsonPath(text))
.map((child) => {
const path = `${parent}${child.selector}`;
return {
label: child.label,
displayLabel: child.selector.startsWith('["') ? child.selector : child.label,
detail: child.kind,
type: child.selector.startsWith("[") ? "variable" : "property",
apply(
view: Parameters<typeof startCompletion>[0],
_completion: unknown,
from: number,
to: number,
) {
view.dispatch({
changes: { from, to, insert: path },
selection: { anchor: from + path.length },
userEvent: "input.complete",
});
if (["object", "array", "all items", "mixed"].includes(child.kind))
startCompletion(view);
},
};
});
return options.length ? { from: 0, to: context.pos, options, filter: false } : null;
};
}
@@ -1,5 +1,6 @@
import { EditorState } from "@codemirror/state";
import { jsonc } from "@shopify/lang-jsonc";
import { text } from "./text/extension";
import { twig } from "./twig/extension";
import { describe, expect, test } from "vite-plus/test";
import {
@@ -10,7 +11,6 @@ import {
MAX_VISIBLE_LINE_CHARS,
} from "./largeValues";
import type { SniffedValue } from "./sniffValue";
import { text } from "@yaakapp-internal/ui/editor";
const BIG = "A".repeat(1_000_000);
@@ -1,7 +0,0 @@
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
import { Editor } from "./LazyEditor";
/** Renders a ui component's editor as the client's, with its settings and value handling */
export function renderEditor({ extensions, ...props }: CodeEditorProps) {
return <Editor {...props} extraExtensions={extensions} />;
}
@@ -4,9 +4,9 @@ import { json } from "@codemirror/lang-json";
import { EditorState, type Extension, type TransactionSpec } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { describe, expect, test } from "vite-plus/test";
import { singleLineExtensions } from "../singleLine";
import { twigCompletion, type TwigCompletionOption } from "./completion";
import { parser } from "./twig";
import { singleLineExtensions } from "@yaakapp-internal/ui/editor";
const options: TwigCompletionOption[] = [
{ name: "someVar", label: "someVar", type: "variable", value: "value", onClick: () => {} },
@@ -5,11 +5,11 @@ import { parseMixed } from "@lezer/common";
import type { WrappedEnvironmentVariable } from "../../../../hooks/useEnvironmentVariables";
import type { GenericCompletionConfig } from "../genericCompletion";
import { genericCompletion } from "../genericCompletion";
import { textLanguage } from "../text/extension";
import type { TwigCompletionOption } from "./completion";
import { twigCompletion } from "./completion";
import { templateTagsPlugin } from "./templateTags";
import { parser as twigParser } from "./twig";
import { textLanguage } from "@yaakapp-internal/ui/editor";
export function twig({
base,
@@ -4,6 +4,4 @@ export const highlight = styleTags({
TagOpen: t.bracket,
TagClose: t.bracket,
TagContent: t.keyword,
// Strings are part of the tag body, so they get the same color as the rest of it
TagString: t.keyword,
});
@@ -1,17 +1,13 @@
@top Template { (Tag | Text)* }
// `TagString` keeps a quoted argument from closing the tag, so `${[ fn(a='x]}y') ]}` stays a
// single Tag. Only a terminated string matches, so a half-typed `'` degrades to TagContent and
// the tag still closes at the next `]}`.
@local tokens {
TagClose { "]}" }
TagString { "'" (![\\'] | "\\" _)* "'" }
@else TagContent
}
@skip { } {
TagOpen { "${[" }
Tag { TagOpen (TagContent | TagString)+ TagClose }
Tag { TagOpen (TagContent)+ TagClose }
}
@tokens {
@@ -3,6 +3,5 @@ export const Template = 1,
Tag = 2,
TagOpen = 3,
TagContent = 4,
TagString = 5,
TagClose = 6,
Text = 7;
TagClose = 5,
Text = 6;
@@ -23,10 +23,6 @@ function hasError(input: string): boolean {
return getNodeNames(input).includes("⚠");
}
function countTags(input: string): number {
return getNodeNames(input).filter((n) => n === "Tag").length;
}
describe("twig grammar", () => {
describe("${[var]} format (valid template tags)", () => {
test("parses simple variable as Tag", () => {
@@ -87,50 +83,6 @@ describe("twig grammar", () => {
});
});
describe("quoted strings inside tags", () => {
test("keeps a quoted `]}` inside the tag", () => {
const input = "${[ fn(a='x]}y') ]}";
expect(countTags(input)).toBe(1);
expect(hasError(input)).toBe(false);
});
test("keeps an escaped quote inside the string", () => {
const input = "${[ fn(a='it\\'s ]}') ]}";
expect(countTags(input)).toBe(1);
expect(hasError(input)).toBe(false);
});
test("parses a b64 string as one Tag", () => {
const input = "${[ b64'Zm9v' ]}";
expect(countTags(input)).toBe(1);
expect(hasError(input)).toBe(false);
});
test("closes at the first `]}` when a quote is left unterminated", () => {
// Half-typed strings must not swallow the rest of the document
const input = "${[ fn(a='unterminated ]} after";
expect(countTags(input)).toBe(1);
expect(hasError(input)).toBe(false);
const tag = parser.parse(input).topNode.getChild("Tag");
expect(input.slice(tag?.from ?? 0, tag?.to ?? 0)).toBe("${[ fn(a='unterminated ]}");
});
test("handles two tags with quoted `]}` in the same string", () => {
const input = "${[ a(x='1]}') ]} and ${[ b(y='2]}') ]}";
expect(countTags(input)).toBe(2);
expect(hasError(input)).toBe(false);
});
test("keeps JSON around a tag with a quoted `]}` intact", () => {
const tag = "${[ fn(a='x]}y') ]}";
const json = '{"a": "' + tag + '"}';
expect(countTags(json)).toBe(1);
expect(hasError(json)).toBe(false);
const node = parser.parse(json).topNode.getChild("Tag");
expect(json.slice(node?.from ?? 0, node?.to ?? 0)).toBe(tag);
});
});
describe("edge cases", () => {
test("handles $ at end of string", () => {
expect(hasError("hello$")).toBe(false);
@@ -4,24 +4,17 @@ import { highlight } from "./highlight";
export const parser = LRParser.deserialize({
version: 14,
states:
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cd'#CdQQOPOOOOOO'#Ce'#CeObOQO,58xOOOO-E6b-E6bOOOO-E6c-E6cOOOO1G.d1G.d",
stateData: "m~OVROZPO~OSTOTTO~OSTOTTOUXO~O",
goto: "oYPPZ_PPPPciTROSTQOSQSORVSQUQRWU",
nodeNames: "⚠ Template Tag TagOpen TagContent TagString TagClose Text",
maxTerm: 11,
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
stateData: "g~OUROYPO~OSTO~OSTOTXO~O",
goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU",
nodeNames: "⚠ Template Tag TagOpen TagContent TagClose Text",
maxTerm: 10,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 2,
tokenData:
"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTV~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOV~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OZ~",
tokenizers: [
1,
new LocalTokenGroup(
"#R~RQwxX#P#Q!u~[VOwXwxqx#OX#O#Pv#P;'SX;'S;=`!o<%lOX~vOT~~yRO;'SX;'S;=`!S;=`OX~!VWOwXwxqx#OX#O#Pv#P;'SX;'S;=`!o;=`<%lX<%lOX~!rP;=`<%lX~!xP#q#r!{~#QOU~~",
94,
4,
),
],
"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~",
tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
topRules: { Template: [0, 1] },
tokenPrec: 0,
});
@@ -1,22 +1,91 @@
import type { GrpcRequest, HttpRequest, WebsocketRequest } from "@yaakapp-internal/models";
import { settingsAtom } from "@yaakapp-internal/models";
import {
HttpMethodTag as BaseHttpMethodTag,
HttpMethodTagRaw as BaseHttpMethodTagRaw,
} from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { type ComponentProps, memo } from "react";
import { memo } from "react";
export const HttpMethodTag = memo(function HttpMethodTag(
props: Omit<ComponentProps<typeof BaseHttpMethodTag>, "colored">,
) {
const settings = useAtomValue(settingsAtom);
return <BaseHttpMethodTag {...props} colored={settings.coloredMethods} />;
interface Props {
request: HttpRequest | GrpcRequest | WebsocketRequest;
className?: string;
short?: boolean;
noAlias?: boolean;
}
const methodNames: Record<string, string> = {
get: "GET",
put: "PUT",
post: "POST",
patch: "PTCH",
delete: "DELE",
options: "OPTN",
head: "HEAD",
query: "QURY",
graphql: "GQL",
grpc: "GRPC",
websocket: "WS",
};
export const HttpMethodTag = memo(function HttpMethodTag({
request,
className,
short,
noAlias,
}: Props) {
const method =
request.model === "http_request" && request.bodyType === "graphql" && !noAlias
? "graphql"
: request.model === "grpc_request"
? "grpc"
: request.model === "websocket_request"
? "websocket"
: request.method;
return <HttpMethodTagRaw method={method} className={className} short={short} />;
});
export function HttpMethodTagRaw({
className,
method,
short,
forceColor,
...props
}: Omit<ComponentProps<typeof BaseHttpMethodTagRaw>, "colored"> & { forceColor?: boolean }) {
}: {
method: string;
className?: string;
short?: boolean;
forceColor?: boolean;
}) {
let label = method.toUpperCase();
if (short) {
label = methodNames[method.toLowerCase()] ?? method.slice(0, 4);
label = label.padEnd(4, " ");
}
const m = method.toUpperCase();
const settings = useAtomValue(settingsAtom);
return <BaseHttpMethodTagRaw {...props} colored={forceColor || settings.coloredMethods} />;
const colored = forceColor || settings.coloredMethods;
return (
<span
className={classNames(
className,
!colored && "text-text-subtle",
colored && m === "GRAPHQL" && "text-info",
colored && m === "WEBSOCKET" && "text-info",
colored && m === "GRPC" && "text-info",
colored && m === "QUERY" && "text-text-subtle",
colored && m === "OPTIONS" && "text-info",
colored && m === "HEAD" && "text-text-subtle",
colored && m === "GET" && "text-primary",
colored && m === "PUT" && "text-warning",
colored && m === "PATCH" && "text-notice",
colored && m === "POST" && "text-success",
colored && m === "DELETE" && "text-danger",
"font-mono shrink-0 whitespace-pre",
"pt-[0.15em]", // Fix for monospace font not vertically centering
)}
>
{label}
</span>
);
}
@@ -1,4 +1,4 @@
import { Icon, type IconProps } from "./Icon";
import { Icon, type IconProps } from "@yaakapp-internal/ui";
import type { TooltipProps } from "./Tooltip";
import { Tooltip } from "./Tooltip";
+342 -68
View File
@@ -1,24 +1,19 @@
import {
HStack,
Icon,
type IconProps,
IconTooltip,
Input as BaseUiInput,
type InputHandle,
type InputProps as BaseUiInputProps,
useStateWithDeps,
} from "@yaakapp-internal/ui";
import type { CodeEditorProps } from "@yaakapp-internal/ui/editor";
import type { EditorView } from "@codemirror/view";
import type { Color } from "@yaakapp-internal/plugins";
import { HStack, Icon, type IconProps } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useCallback, useEffect, useMemo, useRef } from "react";
import type { ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createFastMutation } from "../../hooks/useFastMutation";
import { useIsEncryptionEnabled } from "../../hooks/useIsEncryptionEnabled";
import { useStateWithDeps } from "../../hooks/useStateWithDeps";
import { copyToClipboard } from "../../lib/copy";
import {
analyzeTemplate,
convertTemplateToInsecure,
convertTemplateToSecure,
} from "../../lib/encryption";
import { generateId } from "../../lib/generateId";
import {
setupOrConfigureEncryption,
withEncryptionEnabled,
@@ -30,30 +25,69 @@ import type { EditorProps } from "./Editor/Editor";
import type { GenericCompletion } from "./Editor/genericCompletion";
import { Editor } from "./Editor/LazyEditor";
import { IconButton } from "./IconButton";
import { IconTooltip } from "./IconTooltip";
import { Label } from "./Label";
export type { InputHandle };
export type InputProps = Pick<
EditorProps,
| "language"
| "autocomplete"
| "forcedEnvironmentId"
| "forceUpdateKey"
| "disabled"
| "autoFocus"
| "autoSelect"
| "autocompleteVariables"
| "autocompleteFunctions"
| "onKeyDown"
| "readOnly"
> & {
className?: string;
containerClassName?: string;
inputWrapperClassName?: string;
defaultValue?: string | null;
disableObscureToggle?: boolean;
fullHeight?: boolean;
hideLabel?: boolean;
help?: ReactNode;
label: ReactNode;
labelClassName?: string;
labelPosition?: "top" | "left";
leftSlot?: ReactNode;
multiLine?: boolean;
name?: string;
onBlur?: () => void;
onChange?: (value: string) => void;
onFocus?: () => void;
onPaste?: (value: string) => void;
onPasteOverwrite?: EditorProps["onPasteOverwrite"];
placeholder?: string;
required?: boolean;
rightSlot?: ReactNode;
/**
* Show a chevron that opens the `autocomplete` options as a menu, so a value can be picked
* without knowing what to type. Opt-in, because plenty of inputs pass `autocomplete` without
* wanting the affordance (the URL bar completes from history, and a chevron there would be
* noise). Renders nothing when there are no options to show.
*/
showOptionsPicker?: boolean;
size?: "2xs" | "xs" | "sm" | "md" | "auto";
stateKey: EditorProps["stateKey"];
extraExtensions?: EditorProps["extraExtensions"];
tint?: Color;
type?: "text" | "password";
validate?: boolean | ((v: string) => boolean);
wrapLines?: boolean;
setRef?: (h: InputHandle | null) => void;
};
export type InputProps = Omit<
BaseUiInputProps,
"language" | "extensions" | "renderEditor" | "renderOptionsPicker"
> &
Pick<
EditorProps,
| "language"
| "autocomplete"
| "forcedEnvironmentId"
| "autocompleteVariables"
| "autocompleteFunctions"
| "extraExtensions"
> & {
/**
* Show a chevron that opens the `autocomplete` options as a menu, so a value can be picked
* without knowing what to type. Opt-in, because plenty of inputs pass `autocomplete` without
* wanting the affordance (the URL bar completes from history, and a chevron there would be
* noise). Renders nothing when there are no options to show.
*/
showOptionsPicker?: boolean;
};
export interface InputHandle {
focus: () => void;
isFocused: () => boolean;
value: () => string;
selectAll: () => void;
dispatch: EditorView["dispatch"];
}
export function Input({ type, ...props }: InputProps) {
// If it's a password and template functions are supported (ie. secure(...)) then
@@ -65,47 +99,283 @@ export function Input({ type, ...props }: InputProps) {
}
function BaseInput({
autocomplete,
autocompleteFunctions,
autocompleteVariables,
extraExtensions,
forcedEnvironmentId,
language,
className,
containerClassName,
defaultValue,
disableObscureToggle,
disabled,
forceUpdateKey,
fullHeight,
help,
hideLabel,
inputWrapperClassName,
label,
labelClassName,
labelPosition = "top",
leftSlot,
multiLine,
onBlur,
onChange,
onFocus,
onPaste,
onPasteOverwrite,
placeholder,
readOnly,
required,
rightSlot,
showOptionsPicker,
size = "md",
stateKey,
tint,
type = "text",
validate,
wrapLines,
setRef,
...props
}: InputProps) {
const renderEditor = useCallback(
(editorProps: CodeEditorProps) => (
<Editor
{...editorProps}
language={language}
autocomplete={autocomplete}
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
extraExtensions={extraExtensions}
forcedEnvironmentId={forcedEnvironmentId}
/>
),
[
language,
autocomplete,
autocompleteFunctions,
autocompleteVariables,
extraExtensions,
forcedEnvironmentId,
],
const [focused, setFocused] = useState(false);
const [obscured, setObscured] = useStateWithDeps(type === "password", [type]);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
const editorRef = useRef<EditorView | null>(null);
const skipNextFocus = useRef<boolean>(false);
const handle = useMemo<InputHandle>(
() => ({
focus: () => {
if (editorRef.current == null) return;
const anchor = editorRef.current.state.doc.length;
skipNextFocus.current = true;
editorRef.current.focus();
editorRef.current.dispatch({ selection: { anchor, head: anchor }, scrollIntoView: true });
},
isFocused: () => editorRef.current?.hasFocus ?? false,
value: () => editorRef.current?.state.doc.toString() ?? "",
dispatch: (...args) => {
// oxlint-disable-next-line no-explicit-any
editorRef.current?.dispatch(...(args as any));
},
selectAll() {
if (editorRef.current == null) return;
editorRef.current.focus();
editorRef.current.dispatch({
selection: { anchor: 0, head: editorRef.current.state.doc.length },
});
},
}),
[],
);
const pickerOptions = showOptionsPicker && !props.disabled ? (autocomplete?.options ?? []) : [];
const renderOptionsPicker =
pickerOptions.length > 0
? (insert: (value: string) => void) => (
<OptionsPickerDropdown options={pickerOptions} onSelect={insert} />
)
: undefined;
// Replace the whole value with a picked option. Dispatching runs it through the editor's
// onChange, same as typing, so callers see one consistent change path.
const insertOption = useCallback((value: string) => {
const view = editorRef.current;
if (view == null) return;
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } });
view.focus();
}, []);
const pickerOptions = showOptionsPicker && !disabled ? (props.autocomplete?.options ?? []) : [];
const setEditorRef = useCallback(
(h: EditorView | null) => {
editorRef.current = h;
setRef?.(handle);
},
[handle, setRef],
);
useEffect(() => {
const fn = () => {
skipNextFocus.current = true;
};
window.addEventListener("focus", fn);
return () => {
window.removeEventListener("focus", fn);
};
}, []);
const handleFocus = useCallback(() => {
if (readOnly) return;
if (!skipNextFocus.current) {
editorRef.current?.dispatch({
selection: { anchor: 0, head: editorRef.current.state.doc.length },
});
}
setFocused(true);
onFocus?.();
skipNextFocus.current = false;
}, [onFocus, readOnly]);
const handleBlur = useCallback(async () => {
setFocused(false);
// Move selection to the end on blur
const anchor = editorRef.current?.state.doc.length ?? 0;
editorRef.current?.dispatch({
selection: { anchor, head: anchor },
});
onBlur?.();
}, [onBlur]);
const id = useRef(`input-${generateId()}`);
const editorClassName = classNames(
className,
"bg-transparent! min-w-0 h-auto w-full focus:outline-hidden placeholder:text-placeholder",
);
const isValid = useMemo(() => {
if (required && !validateRequire(defaultValue ?? "")) return false;
if (typeof validate === "boolean") return validate;
if (typeof validate === "function" && !validate(defaultValue ?? "")) return false;
return true;
}, [required, defaultValue, validate]);
const handleChange = useCallback(
(value: string) => {
onChange?.(value);
setHasChanged(true);
},
[onChange, setHasChanged],
);
const wrapperRef = useRef<HTMLDivElement>(null);
// Submit the nearest form on Enter key press
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key !== "Enter") return;
const form = wrapperRef.current?.closest("form");
if (!isValid || form == null) return;
form?.dispatchEvent(new Event("submit", { cancelable: true, bubbles: true }));
},
[isValid],
);
return (
<BaseUiInput {...props} renderEditor={renderEditor} renderOptionsPicker={renderOptionsPicker} />
<div
ref={wrapperRef}
className={classNames(
"pointer-events-auto", // Just in case we're placing in disabled parent
"w-full",
fullHeight && "h-full",
labelPosition === "left" && "flex items-center gap-2",
labelPosition === "top" && "flex-row gap-0.5",
)}
>
<Label
htmlFor={id.current}
help={help}
required={required}
visuallyHidden={hideLabel}
className={classNames(labelClassName)}
>
{label}
</Label>
<HStack
alignItems="stretch"
className={classNames(
containerClassName,
fullHeight && "h-full",
"x-theme-input",
"relative w-full rounded-md text overflow-hidden",
"border",
focused && !disabled ? "border-border-focus" : "border-border",
disabled && "border-dotted",
!isValid && hasChanged && "border-danger!",
size === "md" && "min-h-md",
size === "sm" && "min-h-sm",
size === "xs" && "min-h-xs",
size === "2xs" && "min-h-2xs",
)}
>
{tint != null && (
<div
aria-hidden
className={classNames(
"absolute inset-0 opacity-5 pointer-events-none",
tint === "primary" && "bg-primary",
tint === "secondary" && "bg-secondary",
tint === "info" && "bg-info",
tint === "success" && "bg-success",
tint === "notice" && "bg-notice",
tint === "warning" && "bg-warning",
tint === "danger" && "bg-danger",
)}
/>
)}
{leftSlot}
<HStack
className={classNames(
inputWrapperClassName,
"flex-1 min-w-0 px-2",
fullHeight && "h-full",
leftSlot ? "pl-0" : null,
rightSlot ? "pr-0" : null,
)}
>
<Editor
setRef={setEditorRef}
id={id.current}
hideGutter
singleLine={!multiLine}
containerOnly
stateKey={stateKey}
wrapLines={wrapLines}
heightMode="auto"
onKeyDown={handleKeyDown}
type={type === "password" && !obscured ? "text" : type}
defaultValue={defaultValue}
forceUpdateKey={forceUpdateKey}
placeholder={placeholder}
onChange={handleChange}
onPaste={onPaste}
onPasteOverwrite={onPasteOverwrite}
disabled={disabled}
className={classNames(
editorClassName,
multiLine && size === "md" && "py-1.5",
multiLine && size === "sm" && "py-1",
multiLine && (size === "xs" || size === "2xs") && "py-0.5",
)}
onFocus={handleFocus}
onBlur={handleBlur}
readOnly={readOnly}
{...props}
/>
</HStack>
{type === "password" && !disableObscureToggle && (
<IconButton
title={
obscured
? `Show ${typeof label === "string" ? label : "field"}`
: `Obscure ${typeof label === "string" ? label : "field"}`
}
size="xs"
className={classNames("mr-0.5 h-auto! my-0.5", disabled && "opacity-disabled")}
color={tint}
// iconClassName={classNames(
// tint === 'primary' && 'text-primary',
// tint === 'secondary' && 'text-secondary',
// tint === 'info' && 'text-info',
// tint === 'success' && 'text-success',
// tint === 'notice' && 'text-notice',
// tint === 'warning' && 'text-warning',
// tint === 'danger' && 'text-danger',
// )}
iconSize="sm"
icon={obscured ? "eye" : "eye_closed"}
onClick={() => setObscured((o) => !o)}
/>
)}
{pickerOptions.length > 0 && (
<OptionsPickerDropdown options={pickerOptions} onSelect={insertOption} />
)}
{rightSlot}
</HStack>
</div>
);
}
@@ -149,6 +419,10 @@ function OptionsPickerDropdown({
);
}
function validateRequire(v: string) {
return v.length > 0;
}
type PasswordFieldType = "text" | "encrypted";
function EncryptionInput({
@@ -1,7 +1,7 @@
import classNames from "classnames";
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import { Icon } from "../Icon";
import { Icon } from "@yaakapp-internal/ui";
interface Props {
depth?: number;
@@ -1,10 +1,106 @@
import { KeyValueRow as BaseKeyValueRow, type KeyValueRowProps } from "@yaakapp-internal/ui";
import { copyToClipboard } from "../../lib/copy";
import classNames from "classnames";
import type { HTMLAttributes, ReactElement, ReactNode } from "react";
import { CopyIconButton } from "../CopyIconButton";
export function KeyValueRow(props: Omit<KeyValueRowProps, "copy">) {
return <BaseKeyValueRow {...props} copy={copy} />;
interface Props {
children:
| ReactElement<HTMLAttributes<HTMLTableColElement>>
| (ReactElement<HTMLAttributes<HTMLTableColElement>> | null)[];
selectable?: boolean;
}
function copy(text: string) {
copyToClipboard(text, { disableToast: true });
export function KeyValueRows({ children, selectable }: Props) {
const childArray = Array.isArray(children) ? children.filter(Boolean) : [children];
return (
<table
className={classNames(
"text-editor font-mono min-w-0 w-full mb-auto",
selectable &&
"[&_td]:select-auto [&_td]:cursor-auto [&_td_*]:select-auto [&_td_*]:cursor-auto",
)}
>
<tbody className="divide-y divide-surface-highlight">
{childArray.map((child, i) => (
// oxlint-disable-next-line react/no-array-index-key
<tr key={i}>{child}</tr>
))}
</tbody>
</table>
);
}
interface KeyValueRowProps {
label: ReactNode;
children: ReactNode;
rightSlot?: ReactNode;
leftSlot?: ReactNode;
align?: "top" | "middle";
labelClassName?: string;
labelColor?: "secondary" | "primary" | "info";
enableCopy?: boolean;
copyText?: string;
}
export function KeyValueRow({
label,
children,
rightSlot,
leftSlot,
align = "top",
labelColor = "secondary",
labelClassName,
enableCopy,
copyText,
}: KeyValueRowProps) {
const textToCopy =
copyText ??
(typeof children === "string" || typeof children === "number" ? `${children}` : null);
const copyTitle =
typeof label === "string" || typeof label === "number" ? `Copy ${label}` : "Copy value";
const resolvedRightSlot =
rightSlot ??
(enableCopy && textToCopy != null ? (
<CopyIconButton
text={textToCopy}
className="text-text-subtle"
size="2xs"
title={copyTitle}
iconSize="sm"
/>
) : null);
return (
<>
<td
className={classNames(
"select-none py-0.5 pr-2 h-full max-w-40",
align === "top" && "align-top",
align === "middle" && "align-middle",
labelClassName,
labelColor === "primary" && "text-primary",
labelColor === "secondary" && "text-text-subtle",
labelColor === "info" && "text-info",
)}
>
<span className="select-text cursor-text">{label}</span>
</td>
<td
className={classNames(
"select-none py-0.5 break-all max-w-60",
align === "top" && "align-top",
align === "middle" && "align-middle",
)}
>
<div className="select-text cursor-text max-h-48 overflow-y-auto grid grid-cols-[auto_minmax(0,1fr)_auto]">
{leftSlot ?? <span aria-hidden />}
{children}
{resolvedRightSlot ? (
<div className="ml-1.5">{resolvedRightSlot}</div>
) : (
<span aria-hidden />
)}
</div>
</td>
</>
);
}
@@ -14,8 +14,9 @@ import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "rea
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
import { useRandomKey } from "../../hooks/useRandomKey";
import { useToggle } from "../../hooks/useToggle";
import { languageFromContentType } from "../../lib/contentType";
import { showDialog } from "../../lib/dialog";
import { computeSideForDragMove, DropMarker, languageFromContentType } from "@yaakapp-internal/ui";
import { computeSideForDragMove, DropMarker } from "@yaakapp-internal/ui";
import { showPrompt } from "../../lib/prompt";
import { SelectFile } from "../SelectFile";
import { Button } from "./Button";
@@ -1,5 +1,5 @@
import { generateId } from "../../lib/generateId";
import type { Pair, PairWithId } from "./PairEditor";
import { generateId } from "@yaakapp-internal/lib/generateId";
// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
export function ensurePairId<T extends Pair>(p: T): T & PairWithId {
Loaded 100 of 383 files, more files were not shown because too many files have changed in this diff. Show more