Compare commits

...
Author SHA1 Message Date
Gregory SchierandClaude Fable 5 99a318224f Add the Yaak Bridge so a browser tab can run the real engine
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:17:26 -07:00
23e7229e63 Route all app commands through a single RPC envelope (#542)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 14:16:14 -07:00
Gregory SchierandGitHub 1f91cddab9 Restore HTTP/2 and client certificate parity on the native TLS path (#543) 2026-08-14 14:08:14 -07:00
be004425fa fix: Add username to the tokenStoreKey so the saved token is invalidated when the user changes in the environment (#426)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-08-14 13:50:48 -07:00
Gregory SchierandGitHub 4f03c5c390 Fix sync watcher panic when the sync directory path traverses a symlink (#541) 2026-08-14 13:34:39 -07:00
Gregory SchierandGitHub cb43f0b9b9 Follow the OS appearance when set to System without a new window (#540) 2026-08-14 13:14:01 -07:00
68671377fd fix: AWS4 authentication method didn't sign request body (#430)
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
2026-08-14 13:07:05 -07:00
2383f06e71 Add a typed platform package to decouple the frontend from Tauri (#539)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 12:44:01 -07:00
Gregory Schier 0068be9ffc Run the CLI update check in non-interactive shells so agents see it 2026-08-14 12:27:38 -07:00
Gregory Schier def7d752db Add the MIT license field to the CLI npm packages 2026-08-14 12:27:38 -07:00
Gregory SchierandGitHub 85aed740a8 Import Curl from a paste in the browser build (#538) 2026-08-14 11:54:56 -07:00
Gregory SchierandGitHub d3055c64b3 Honor workspace connection settings in CLI sends (#537) 2026-08-14 10:38:47 -07:00
Gregory Schier 912aaeb7a7 Raise release build timeout to 60 minutes 2026-08-14 10:33:39 -07:00
Gregory SchierandGitHub d0af512a0c Fix UI freezes with large workspaces (#536) 2026-08-14 10:15:32 -07:00
Gregory SchierandGitHub e032c4c8d6 Add a Yaak CLI skill for coding agents (#535) 2026-08-14 06:16:30 -07:00
c3fc3fb68a Fix Wayland crash on Nvidia by disabling explicit sync (#474)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 18:41:50 -07:00
Gregory SchierandGitHub 7e2db7799b Collapse encoded media in editable fields too (#534) 2026-08-13 12:56:32 -07:00
Gregory SchierandGitHub c596e25e5e Identify collapsed values and open them in a viewer (#533) 2026-08-13 12:26:35 -07:00
Gregory SchierandGitHub 74d1b5d6ce HUGE sidebar and typing performance improvements (#516) 2026-08-13 12:23:56 -07:00
WkdXeqtrandGitHub b9d4f76193 Add dropdown to pick header names and values
Adds a chevron on pair editor name and value inputs that opens the same options as inline autocomplete, so suggestions can be found by clicking instead of only by typing. Long values like User-Agent show a short label but insert the full string.

Row action menus move to a vertical ellipsis so the chevron only ever means "pick a value into this field".

Also restores the per-worktree Tauri config handling in run-dev.mjs, which a merge on the branch had reverted.
2026-08-13 10:58:20 -07:00
Gregory Schier d4a963e216 Pass per-worktree Tauri config to dev runs 2026-08-13 09:35:27 -07:00
be765d7bcc Fix nested template function depth tracking (#523)
Co-authored-by: Su <26924933+674019130@users.noreply.github.com>
2026-08-13 09:25:10 -07:00
Gregory Schier d43de3d95a Keep nested worktrees out of test runs
Test runs from the repo root were picking up every git worktree under .claude,
running 648 tests across 102 files instead of 322 across 34. That made results
misleading rather than just noisy: a failure in an unrelated worktree showed up
as a failure here.

Ignores the directory too. Only settings.local.json was listed, while rules.md
stays tracked.
2026-08-13 09:04:02 -07:00
Gregory Schier bc0494fe0e Keep template tag text colored in light themes
Tag text was black in light themes and correctly tinted in dark. Both came from
liftMax(), which pushes lightness to an extreme, and the two extremes are not
symmetric: lightening past the sRGB gamut clips to a still-tinted color, so dark
kept its hue by accident, while darkening to zero lightness collapses every hue
to black.

Stopping short of the extreme keeps the hue in both appearances with no branch
on appearance, and still clears 6:1 against the tag's own surface across every
accent. Dark shifts from washed out toward the accent, so a purple tag now reads
purple rather than near-white pink.
2026-08-13 08:57:25 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c57a64d455 Bump @hono/node-server from 1.19.14 to 2.1.0 (#531)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 08:42:24 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5bebd74b09 Bump nanoid from 5.1.6 to 5.1.16 (#526)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 08:42:09 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a6dd058a3d Bump ip-address and express-rate-limit (#525)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 08:41:27 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1fe8261fbd Bump hono from 4.12.27 to 4.12.34 (#524)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-13 08:40:50 -07:00
Gregory SchierandGitHub 0e4c355e8b Collapse very long lines in response viewers (#532) 2026-08-13 08:40:04 -07:00
Gregory SchierandGitHub f6d926f4b9 Ignore bundled plugin directories left behind by past renames (#530) 2026-08-12 11:22:08 -07:00
Gregory SchierandGitHub a6be9dbaee Allow renaming URL path parameters from the Params tab (#528) 2026-08-12 10:17:02 -07:00
Gregory SchierandGitHub 67a628d67a Link date-fns format docs from timestamp.format (#529) 2026-08-12 09:50:36 -07:00
Gregory SchierandGitHub 0bf7eaed81 Correct timestamp.format help text to reference date-fns, not dayjs (#527) 2026-08-12 08:12:19 -07:00
784a3d3a32 fix(ci): align workflow Node.js version with package engines (#520)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 08:18:22 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
077bfa87b8 Bump body-parser from 2.2.2 to 2.3.0 (#512)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:31:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
733f2e5929 Bump @hono/node-server from 1.19.14 to 2.0.10 (#513)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:20:04 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0f994f89ac Bump hono from 4.12.25 to 4.12.27 (#514)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:20:02 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
24f76398f9 Bump postcss from 8.5.16 to 8.5.25 (#522)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:59 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
14475915df Bump shell-quote from 1.8.4 to 1.10.0 (#517)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:57 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
a50e04f565 Bump seroval from 1.4.2 to 1.5.6 (#518)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:54 -07:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
32fbd66912 Bump fast-uri from 3.1.2 to 3.1.5 (#521)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-01 08:19:52 -07:00
SuandGitHub 3503a9da8e Fix inline rename during IME composition (#519) 2026-08-01 07:45:36 -07:00
Gregory Schier cef6abf5d0 Handle OAuth callback connections concurrently so browser preconnects can't block login 2026-07-29 07:54:59 -07:00
Gregory Schier 3f098f95fe Increase import timeout from 5s to 60s 2026-07-24 07:36:44 -07:00
254 changed files with 13313 additions and 1562 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
node-version: "24"
- name: Install source generators
run: |
+1 -1
View File
@@ -70,7 +70,7 @@ jobs:
runtime: "wry"
targets: "aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }}
timeout-minutes: 40
timeout-minutes: 60
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
+1
View File
@@ -58,3 +58,4 @@ flatpak/node-sources.json
# Claude Code local settings
.claude/settings.local.json
.claude/worktrees/
Generated
+114 -4
View File
@@ -613,11 +613,15 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [
"async-trait",
"axum-core",
"axum-macros",
"base64 0.22.1",
"bytes",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
@@ -626,10 +630,17 @@ dependencies = [
"pin-project-lite",
"rustversion",
"serde",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1",
"sync_wrapper",
"tokio",
"tokio-tungstenite 0.24.0",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -650,6 +661,18 @@ dependencies = [
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]]
@@ -3233,6 +3256,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573"
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -9080,6 +9109,18 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9"
dependencies = [
"futures-util",
"log 0.4.29",
"tokio",
"tungstenite 0.24.0",
]
[[package]]
name = "tokio-tungstenite"
version = "0.26.2"
@@ -9093,7 +9134,7 @@ dependencies = [
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite",
"tungstenite 0.26.2",
]
[[package]]
@@ -9287,6 +9328,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -9297,13 +9339,23 @@ checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
dependencies = [
"bitflags 2.11.0",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower 0.5.2",
"tower-layer",
"tower-service",
"tracing",
"url",
]
@@ -9325,6 +9377,7 @@ version = "0.1.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
dependencies = [
"log 0.4.29",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -9471,6 +9524,24 @@ dependencies = [
"termcolor",
]
[[package]]
name = "tungstenite"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a"
dependencies = [
"byteorder",
"bytes",
"data-encoding",
"http",
"httparse",
"log 0.4.29",
"rand 0.8.5",
"sha1",
"thiserror 1.0.69",
"utf-8",
]
[[package]]
name = "tungstenite"
version = "0.26.2"
@@ -10982,6 +11053,7 @@ dependencies = [
name = "yaak-app-client"
version = "0.0.0"
dependencies = [
"base64 0.22.1",
"charset",
"chrono",
"cookie",
@@ -11015,7 +11087,7 @@ dependencies = [
"thiserror 2.0.17",
"tokio",
"tokio-stream",
"tokio-tungstenite",
"tokio-tungstenite 0.26.2",
"tokio-util",
"ts-rs",
"url",
@@ -11033,6 +11105,7 @@ dependencies = [
"yaak-mac-window",
"yaak-models",
"yaak-plugins",
"yaak-rpc",
"yaak-sse",
"yaak-sync",
"yaak-system-appearance",
@@ -11200,6 +11273,7 @@ dependencies = [
"tokio-stream",
"tonic",
"tonic-reflection",
"ts-rs",
"uuid",
"yaak-common",
"yaak-tls",
@@ -11225,6 +11299,7 @@ dependencies = [
"reqwest 0.12.20",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.17",
"tokio",
"tokio-util",
@@ -11313,7 +11388,7 @@ dependencies = [
"sha2",
"thiserror 2.0.17",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.26.2",
"ts-rs",
"yaak-common",
"yaak-crypto",
@@ -11368,6 +11443,40 @@ dependencies = [
"ts-rs",
]
[[package]]
name = "yaak-server"
version = "0.1.0"
dependencies = [
"axum",
"charset",
"chrono",
"clap",
"dirs",
"env_logger",
"eventsource-client",
"futures",
"include_dir",
"log 0.4.29",
"mime_guess",
"pretty_graphql",
"rand 0.8.5",
"serde",
"serde_json",
"serde_urlencoded",
"tokio",
"tower-http",
"yaak",
"yaak-common",
"yaak-core",
"yaak-crypto",
"yaak-http",
"yaak-models",
"yaak-plugins",
"yaak-rpc",
"yaak-sse",
"yaak-templates",
]
[[package]]
name = "yaak-sse"
version = "0.1.0"
@@ -11440,6 +11549,7 @@ dependencies = [
"thiserror 2.0.17",
"url",
"yaak-models",
"yasna",
]
[[package]]
@@ -11467,7 +11577,7 @@ dependencies = [
"serde_json",
"thiserror 2.0.17",
"tokio",
"tokio-tungstenite",
"tokio-tungstenite 0.26.2",
"url",
"yaak-http",
"yaak-models",
+2
View File
@@ -25,6 +25,8 @@ members = [
"crates-proxy/yaak-proxy-lib",
# CLI crates
"crates-cli/yaak-cli",
# Headless server crates
"crates-server/yaak-server",
# Tauri-specific crates
"crates-tauri/yaak-app-client",
"crates-tauri/yaak-app-proxy",
+52
View File
@@ -0,0 +1,52 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModelById } from "@yaakapp-internal/models";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
import { jotaiStore } from "../lib/jotai";
import { rpc } from "../lib/rpc";
import { showToast } from "../lib/toast";
export function looksLikeCurl(text: string) {
return text.trim().startsWith("curl ");
}
export const importCurl = createFastMutation<
void,
string,
{ overwriteRequestId?: string; command: string }
>({
mutationKey: ["import_curl"],
mutationFn: async ({ overwriteRequestId, command }) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const importedRequest: HttpRequest = await rpc("cmd_curl_to_request", {
command,
workspaceId,
});
let verb: string;
if (overwriteRequestId == null) {
verb = "Created";
await createRequestAndNavigate(importedRequest);
} else {
verb = "Updated";
await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({
...importedRequest,
id: r.id,
createdAt: r.createdAt,
workspaceId: r.workspaceId,
folderId: r.folderId,
name: r.name,
sortPriority: r.sortPriority,
}));
setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);
}
showToast({
color: "success",
message: `${verb} request from Curl`,
});
},
});
+2 -2
View File
@@ -3,7 +3,7 @@ import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { jotaiStore } from "../lib/jotai";
import { router } from "../lib/router";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
// Allow tab with optional subtab (e.g., "plugins:installed")
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
@@ -20,7 +20,7 @@ export const openSettings = createFastMutation<void, string, SettingsTabWithSubt
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
});
await invokeCmd("cmd_new_child_window", {
await rpc("cmd_new_child_window", {
url: location.href,
label: "settings",
title: "Yaak Settings",
@@ -3,7 +3,7 @@ import { getRecentCookieJars } from "../hooks/useRecentCookieJars";
import { getRecentEnvironments } from "../hooks/useRecentEnvironments";
import { getRecentRequests } from "../hooks/useRecentRequests";
import { router } from "../lib/router";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
export const switchWorkspace = createFastMutation<
void,
@@ -30,7 +30,7 @@ export const switchWorkspace = createFastMutation<
params: { workspaceId },
search,
});
await invokeCmd<void>("cmd_new_main_window", { url: location.href });
await rpc<void>("cmd_new_main_window", { url: location.href });
return;
}
@@ -1,4 +1,3 @@
import { open } from "@tauri-apps/plugin-dialog";
import { gitClone } from "@yaakapp-internal/git";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
@@ -11,6 +10,7 @@ import { Checkbox } from "./core/Checkbox";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { promptCredentials } from "./git/credentials";
import { platform } from "@yaakapp-internal/platform";
interface Props {
hide: () => void;
@@ -38,7 +38,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
const handleSelectDirectory = async () => {
const dir = await open({
const dir = await platform.dialog.open({
title: "Select Directory",
directory: true,
multiple: false,
@@ -1,11 +1,10 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import { checkLicense } from "@yaakapp-internal/license";
import { useCallback, useEffect, useRef, useState } from "react";
import { useKeyValue } from "../hooks/useKeyValue";
import { appInfo } from "../lib/appInfo";
import { pricingUrl } from "../lib/pricingUrl";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { platform } from "@yaakapp-internal/platform";
const COMMERCIAL_USE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000;
const COMMERCIAL_USE_BANNER_MESSAGE =
@@ -95,7 +94,7 @@ async function shouldShowCommercialUsePrompt(): Promise<boolean> {
}
try {
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
const license = await checkLicense();
return license.status === "personal_use";
} catch (err) {
console.log("Failed to check license before commercial-use prompt", err);
@@ -104,7 +103,7 @@ async function shouldShowCommercialUsePrompt(): Promise<boolean> {
}
async function openCommercialUsePricing(source: string): Promise<void> {
await openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
await platform.openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
}
function isSnoozed(value: string | null, ms: number): boolean {
@@ -0,0 +1,26 @@
import { useAtomValue } from "jotai";
import { contextMenusAtom, hideContextMenu } from "../lib/contextMenu";
import { ContextMenu } from "./core/Dropdown";
import { ErrorBoundary } from "./ErrorBoundary";
/** Renders menus opened by {@link showContextMenu}, the way {@link Dialogs} renders dialogs. */
export function ContextMenus() {
const menus = useAtomValue(contextMenusAtom);
return (
<>
{menus.map(({ id, items, triggerPosition, triggerRect, triggerEl }) => (
<ErrorBoundary key={id} name={`ContextMenu ${id}`}>
<ContextMenu
items={items}
triggerPosition={triggerPosition}
triggerRect={triggerRect}
// The trigger isn't a React component here, so it arrives as an element and gets
// wrapped to look like the ref the menu expects
triggerRef={{ current: triggerEl ?? null }}
onClose={() => hideContextMenu(id)}
/>
</ErrorBoundary>
))}
</>
);
}
@@ -5,7 +5,7 @@ import { VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { router } from "../lib/router";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
@@ -39,7 +39,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
// Do getWorkspaceMeta instead of naively creating one because it might have
// been created already when the store refreshes the workspace meta after
const workspaceMeta = await invokeCmd<WorkspaceMeta>("cmd_get_workspace_meta", {
const workspaceMeta = await rpc<WorkspaceMeta>("cmd_get_workspace_meta", {
workspaceId,
});
await updateModel({
@@ -1,4 +1,3 @@
import { save } from "@tauri-apps/plugin-dialog";
import type { Workspace } from "@yaakapp-internal/models";
import { workspacesAtom } from "@yaakapp-internal/models";
import { HStack, VStack } from "@yaakapp-internal/ui";
@@ -7,12 +6,13 @@ import { useCallback, useMemo, useState } from "react";
import slugify from "slugify";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
interface Props {
onHide: () => void;
@@ -65,7 +65,7 @@ function ExportDataDialogContent({
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
const slug = workspace ? slugify(workspace.name, { lower: true }) : "workspaces";
const exportPath = await save({
const exportPath = await platform.dialog.save({
title: "Export Data",
defaultPath: `yaak.${slug}.json`,
});
@@ -73,7 +73,7 @@ function ExportDataDialogContent({
return;
}
await invokeCmd("cmd_export_data", {
await rpc("cmd_export_data", {
workspaceIds: ids,
exportPath,
includePrivateEnvironments: includePrivateEnvironments,
@@ -2,7 +2,7 @@ import { HStack, VStack } from "@yaakapp-internal/ui";
import { useRef, useState } from "react";
import type { FeedbackFeature } from "../lib/featureFeedbackConstants";
import { FEEDBACK_FEATURES } from "../lib/featureFeedbackConstants";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { hideToastById, showToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Input } from "./core/Input";
@@ -31,7 +31,7 @@ export function FeedbackToast({ feature, onDone }: Props) {
onDone();
// Fire-and-forget; failures are intentionally ignored
invokeCmd("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
rpc("cmd_send_feedback", { feature, text: trimmedText }).catch(() => {});
showToast({
id: `feature-feedback-${feature}`,
timeout: 3000,
@@ -1,4 +1,3 @@
import { open } from "@tauri-apps/plugin-dialog";
import type { GrpcRequest } from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useActiveRequest } from "../hooks/useActiveRequest";
@@ -8,6 +7,7 @@ import { pluralizeCount } from "../lib/pluralize";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
interface Props {
onDone: () => void;
@@ -45,7 +45,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
color="primary"
variant="border"
onClick={async () => {
const selected = await open({
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
@@ -63,7 +63,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
variant="border"
color="primary"
onClick={async () => {
const selected = await open({
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
@@ -1,4 +1,9 @@
import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models";
import {
type GrpcRequest,
type HttpRequestHeader,
patchModel,
patchModelDebounced,
} from "@yaakapp-internal/models";
import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties } from "react";
@@ -75,11 +80,13 @@ export function GrpcRequestPane({
const { width: paneWidth } = useContainerSize(urlContainerEl);
const handleChangeUrl = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
const handleChangeMessage = useCallback(
// Not debounced: handleSend reads message from the store, so a pending
// debounced patch would send stale text
(message: string) => patchModel(activeRequest, { message }),
[activeRequest],
);
@@ -146,12 +153,12 @@ export function GrpcRequestPane({
);
const handleMetadataChange = useCallback(
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
(metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }),
[activeRequest],
);
const handleDescriptionChange = useCallback(
(description: string) => patchModel(activeRequest, { description }),
(description: string) => patchModelDebounced(activeRequest, { description }),
[activeRequest],
);
@@ -299,7 +306,7 @@ export function GrpcRequestPane({
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
+31 -9
View File
@@ -1,14 +1,17 @@
import type { HttpRequestHeader } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import { acceptLanguages } from "../lib/data/acceptLanguages";
import { cacheControlDirectives } from "../lib/data/cacheControl";
import { charsets } from "../lib/data/charsets";
import { connections } from "../lib/data/connections";
import { encodings } from "../lib/data/encodings";
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 { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { InputProps } from "./core/Input";
import type { Pair, PairEditorProps } from "./core/PairEditor";
import { PairEditorRow } from "./core/PairEditor";
@@ -105,12 +108,21 @@ export function HeadersEditor({
const MIN_MATCH = 3;
const headerOptionsMap: Record<string, string[]> = {
const headerOptionsMap: Record<string, HeaderValuePreset[]> = {
"content-type": mimeTypes,
accept: ["*/*", ...mimeTypes],
"accept-encoding": encodings,
"content-encoding": encodings,
connection: connections,
"accept-charset": charsets,
"accept-language": acceptLanguages,
"cache-control": cacheControlDirectives,
"user-agent": userAgents,
pragma: ["no-cache"],
te: ["trailers", "compress", "deflate", "gzip"],
dnt: ["1", "0"],
"upgrade-insecure-requests": ["1"],
"x-requested-with": ["XMLHttpRequest"],
};
const valueType = (pair: Pair): InputProps["type"] => {
@@ -132,12 +144,22 @@ const valueType = (pair: Pair): InputProps["type"] => {
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletionOption[] =
headerOptionsMap[name]?.map((o) => ({
label: o,
type: "constant",
boost: 1, // Put above other completions
})) ?? [];
const options: GenericCompletion[] =
headerOptionsMap[name]?.map((o) =>
typeof o === "string"
? {
label: o,
type: "constant",
boost: 1, // Put above other completions
}
: {
// Show the short label but insert the full value (e.g. User-Agent)
label: o.label,
apply: o.value,
type: "constant",
boost: 1,
},
) ?? [];
return { minMatch: MIN_MATCH, options };
};
+47 -41
View File
@@ -1,25 +1,23 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { getModel, patchModel, patchModelDebounced } from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import { atom, useAtomValue } from "jotai";
import type { CSSProperties } from "react";
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from "react";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { importCurl, looksLikeCurl } from "../commands/importCurl";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
import { useImportCurl } from "../hooks/useImportCurl";
import { useInheritedHeaders } from "../hooks/useInheritedHeaders";
import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { convertRequestBody } from "../lib/requestBodyConversion";
import {
BODY_TYPE_BINARY,
@@ -42,7 +40,6 @@ import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { InlineCode } from "@yaakapp-internal/ui";
import type { Pair } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
@@ -77,15 +74,12 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "http_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = get(allRequestsAtom);
return requests
.filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
// Derived from the identity-stable URL list so this only recomputes when a URL
// actually changes. The active request's own URL is included, but exact matches
// are filtered out at completion time by genericCompletion.
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
);
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
@@ -133,20 +127,33 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
[activeRequest],
);
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = extractPathPlaceholders(activeRequest.url);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
const item = items.find((p) => p.name === name);
if (item) {
item.readOnlyName = true;
} else {
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
}
}
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
}, [activeRequest.url, activeRequest.urlParameters]);
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
// value detaches from the placeholder.
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
// holds onto it until the URL's placeholders change, so a captured request would go stale and
// patch its parameter list back over newer edits.
const handleRenamePathPlaceholder = useCallback(
(oldName: string, newName: string) => {
const request = getModel("http_request", activeRequestId);
if (request == null) return false;
const patch = renamePathPlaceholder(request, oldName, newName);
if (patch == null) return false; // Unusable name, so the editor reverts the field
void patchModel(request, patch);
return true;
},
[activeRequestId],
);
const { urlParameterPairs, urlParametersKey } = useMemo(
() =>
derivePathPlaceholderPairs(
activeRequest.url,
activeRequest.urlParameters,
handleRenamePathPlaceholder,
),
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
);
let numParams = 0;
if (
@@ -271,19 +278,18 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const updateKey = useRequestUpdateKey(activeRequestId);
const { mutate: importCurl } = useImportCurl();
const handleBodyChange = useCallback(
(body: HttpRequest["body"]) => patchModel(activeRequest, { body }),
(body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }),
[activeRequest],
);
const handleBodyTextChange = useCallback(
(text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }),
(text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }),
[activeRequest],
);
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => getUrlCompletionConfig(autocompleteUrls),
@@ -292,8 +298,8 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
const handlePaste = useCallback(
async (e: ClipboardEvent, text: string) => {
if (text.startsWith("curl ")) {
importCurl({ overwriteRequestId: activeRequestId, command: text });
if (looksLikeCurl(text)) {
importCurl.mutate({ overwriteRequestId: activeRequestId, command: text });
} else {
const patch = prepareImportQuerystring(text);
if (patch != null) {
@@ -315,7 +321,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
}
}
},
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh],
);
const handleSend = useCallback(
() => sendRequest(activeRequest.id ?? null),
@@ -323,7 +329,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
);
const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
@@ -369,7 +375,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModel(activeRequest, { headers })}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
@@ -377,7 +383,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_SETTINGS}>
@@ -429,7 +435,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
requestId={activeRequest.id}
contentType={contentType}
body={activeRequest.body}
onChange={(body) => patchModel(activeRequest, { body })}
onChange={(body) => patchModelDebounced(activeRequest, { body })}
onChangeContentType={handleContentTypeChange}
/>
) : typeof activeRequest.bodyType === "string" ? (
+112
View File
@@ -0,0 +1,112 @@
import { platform, useCapability } from "@yaakapp-internal/platform";
import { Icon } from "@yaakapp-internal/ui";
import * as m from "motion/react-m";
import { useEffect, useState } from "react";
import { importCurl, looksLikeCurl } from "../commands/importCurl";
import { useWindowFocus } from "../hooks/useWindowFocus";
import { showToast } from "../lib/toast";
import { Button } from "./core/Button";
/**
* Offers to create a request from a Curl command on the clipboard. A host that can read
* the clipboard on its own offers it whenever the window is focused; one that would have
* to prompt for permission first waits for the user to paste instead.
*/
export function ImportCurl() {
return useCapability("clipboardRead") ? <ImportCurlButton /> : <ImportCurlOnPaste />;
}
function ImportCurlButton() {
const focused = useWindowFocus();
const [clipboardText, setClipboardText] = useState("");
const [isLoading, setIsLoading] = useState(false);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useEffect(() => {
void platform.clipboard.readText().then(setClipboardText);
}, [focused]);
if (!looksLikeCurl(clipboardText)) {
return null;
}
return (
<m.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5 }}
>
<Button
size="2xs"
variant="border"
color="success"
className="rounded-full"
rightSlot={<Icon icon="import" size="sm" />}
isLoading={isLoading}
title="Import Curl command from clipboard"
onClick={async () => {
setIsLoading(true);
try {
await importCurl.mutateAsync({ command: clipboardText });
setClipboardText(""); // Hide the button until the clipboard changes
} catch (e) {
console.log("Failed to import curl", e);
} finally {
setIsLoading(false);
}
}}
>
Import Curl
</Button>
</m.div>
);
}
function ImportCurlOnPaste() {
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
const command = e.clipboardData?.getData("text/plain") ?? "";
if (!looksLikeCurl(command)) return;
// Editable targets keep the text as text. The URL editor imports it itself.
if (isEditable(e.target)) return;
showToast({
id: "curl-paste",
color: "success",
message: (
<div>
<h2 className="font-semibold">Curl command detected</h2>
<p className="text-text-subtle text-sm">Create a request from the pasted command?</p>
</div>
),
action: ({ hide }) => (
<Button
size="xs"
color="success"
className="mr-auto min-w-20"
rightSlot={<Icon icon="import" size="sm" />}
onClick={() => {
hide();
importCurl.mutate({ command });
}}
>
Create Request
</Button>
),
});
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, []);
return null;
}
function isEditable(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
if (target.closest(".cm-editor") != null) return true;
return ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
}
@@ -1,56 +0,0 @@
import { clear, readText } from "@tauri-apps/plugin-clipboard-manager";
import * as m from "motion/react-m";
import { useEffect, useState } from "react";
import { useImportCurl } from "../hooks/useImportCurl";
import { useWindowFocus } from "../hooks/useWindowFocus";
import { Button } from "./core/Button";
import { Icon } from "@yaakapp-internal/ui";
export function ImportCurlButton() {
const focused = useWindowFocus();
const [clipboardText, setClipboardText] = useState("");
const importCurl = useImportCurl();
const [isLoading, setIsLoading] = useState(false);
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useEffect(() => {
void readText().then(setClipboardText);
}, [focused]);
if (!clipboardText?.trim().startsWith("curl ")) {
return null;
}
return (
<m.div
initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.5 }}
>
<Button
size="2xs"
variant="border"
color="success"
className="rounded-full"
rightSlot={<Icon icon="import" size="sm" />}
isLoading={isLoading}
title="Import Curl command from clipboard"
onClick={async () => {
setIsLoading(true);
try {
await importCurl.mutateAsync({ command: clipboardText });
await clear(); // Clear the clipboard so the button goes away
setClipboardText("");
} catch (e) {
console.log("Failed to import curl", e);
} finally {
setIsLoading(false);
}
}}
>
Import Curl
</Button>
</m.div>
);
}
@@ -0,0 +1,274 @@
import { formatSize } from "@yaakapp-internal/lib/formatSize";
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 })),
);
interface Props {
/** The whole value, exactly as it reads in the document */
text: string;
sniffed: SniffedValue | null;
}
/**
* Shows a value the editor collapsed, in whatever viewer its type calls for.
*
* The editor only ever read the value's head, so this is where it is decoded in full — on an
* explicit click, behind a spinner, rather than during layout.
*/
export function LargeValueDialog({ text, sniffed }: Props) {
const viewer = sniffed == null ? null : viewerFor(sniffed.mime);
const decoded = useDecoded(text, viewer == null ? null : sniffed);
// Nothing recognised it, so there is nothing to decode it into
if (sniffed == null || viewer == null) {
return (
<PagedText text={text}>
{sniffed != null && (
<Banner color="info" className="mb-3">
{sniffed.label} content cannot be previewed, so it is shown as it appears in the
response.
</Banner>
)}
</PagedText>
);
}
if (decoded == null) {
return (
<HStack className="h-full" alignItems="center" justifyContent="center">
<LoadingIcon />
</HStack>
);
}
if ("error" in decoded) {
return (
<PagedText text={text}>
<Banner color="danger" className="mb-3">
Failed to decode this {sniffed.label} value: {decoded.error}
</Banner>
</PagedText>
);
}
const { bytes } = decoded;
switch (viewer) {
case "svg":
return <DecodedSvg bytes={bytes} />;
case "image":
return (
<div className="h-full overflow-auto flex items-center justify-center">
<ImageViewer data={toArrayBuffer(bytes)} mimeType={sniffed.mime} />
</div>
);
case "audio":
return <AudioViewer data={bytes} mimeType={sniffed.mime} />;
case "video":
return <VideoViewer data={bytes} mimeType={sniffed.mime} />;
case "pdf":
return (
<Suspense fallback={<LoadingIcon />}>
<PdfViewer data={bytes} />
</Suspense>
);
case "text":
return <DecodedText bytes={bytes} />;
}
}
/**
* Decoding megabytes is worth doing once, not on every render — and the viewers below key their
* blob URLs off the string, so a fresh one each time would rebuild them for nothing.
*/
function DecodedSvg({ bytes }: { bytes: Uint8Array }) {
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
return <SvgViewer text={text} />;
}
function DecodedText({ bytes }: { bytes: Uint8Array }) {
const text = useMemo(() => new TextDecoder().decode(bytes), [bytes]);
return <PagedText text={text} />;
}
/**
* The same menu the tag in the editor carries, minus the one entry that would only reopen this.
*
* It lives in the dialog's title rather than above the content, which would cost a band of
* whitespace the width of the dialog to hold one small button.
*/
function LargeValueActions({ text, sniffed }: Props) {
const items = useMemo(
() =>
largeValueActions({
value: () => text,
sniffed,
// The tag copies only what it hides; in here, what you see is the whole value
copyText: () => text,
}),
[text, sniffed],
);
return (
<Dropdown items={items}>
<Button size="xs" variant="border" forDropdown>
Actions
</Button>
</Dropdown>
);
}
type Viewer = "image" | "svg" | "audio" | "video" | "pdf" | "text";
/** Which viewer a media type calls for, or null if none of them can show it. */
function viewerFor(mime: string): Viewer | null {
if (mime.startsWith("image/svg")) return "svg";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("audio/")) return "audio";
if (mime.startsWith("video/")) return "video";
if (mime === "application/pdf") return "pdf";
if (mime.startsWith("text/") || /\b(json|xml|javascript|csv)\b/.test(mime)) return "text";
return null;
}
type Decoded = { bytes: Uint8Array } | { error: string };
/**
* The value's bytes, once they exist.
*
* Decoding a few megabytes takes long enough to be seen, so it happens in an effect rather than
* during render — the dialog paints with a spinner first, instead of opening late.
*/
function useDecoded(text: string, sniffed: SniffedValue | null): Decoded | null {
const [decoded, setDecoded] = useState<Decoded | null>(null);
useEffect(() => {
if (sniffed == null) return;
setDecoded(null);
let cancelled = false;
const timer = setTimeout(() => {
let result: Decoded;
try {
result = { bytes: decodeValue(text, sniffed) };
} catch (err) {
result = { error: err instanceof Error ? err.message : String(err) };
}
if (!cancelled) setDecoded(result);
});
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [text, sniffed]);
return decoded;
}
/** A copy, since a `Uint8Array` may be a view onto a larger buffer */
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
return bytes.slice().buffer;
}
/** Characters shown at once. A page this size lays out instantly once its lines are short. */
const PAGE_CHARS = 50_000;
/** Where a line is broken. The long-line cost this whole feature avoids is per line. */
const WRAP_CHARS = 120;
/**
* The raw text, in pages of short lines.
*
* What is left when nothing can render the value. Handing it to an editor whole would walk
* straight back into the layout stall that collapsed it in the first place, so it is paged and
* hard-wrapped instead: no line is ever long, and no page is ever big.
*/
function PagedText({ text, children }: { text: string; children?: ReactNode }) {
const [page, setPage] = useState(0);
const pages = Math.max(1, Math.ceil(text.length / PAGE_CHARS));
const from = page * PAGE_CHARS;
const to = Math.min(from + PAGE_CHARS, text.length);
const body = useMemo(() => wrap(text.slice(from, to)), [text, from, to]);
return (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3">
<div>{children}</div>
<pre className="overflow-auto font-mono text-sm text-text-subtle select-text">{body}</pre>
<HStack space={2} alignItems="center" className="text-sm text-text-subtle">
<span>
{(to - from).toLocaleString()} of {text.length.toLocaleString()} characters
</span>
{pages > 1 && (
<HStack space={2} alignItems="center" className="ml-auto">
<Button
size="xs"
variant="border"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Previous
</Button>
<span>
Page {page + 1} of {pages.toLocaleString()}
</span>
<Button
size="xs"
variant="border"
disabled={page >= pages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</HStack>
)}
</HStack>
</div>
);
}
/** Breaks every line at {@link WRAP_CHARS}, leaving the ones already shorter alone. */
function wrap(text: string): string {
const lines: string[] = [];
for (const line of text.split("\n")) {
if (line.length <= WRAP_CHARS) {
lines.push(line);
continue;
}
for (let i = 0; i < line.length; i += WRAP_CHARS) {
lines.push(line.slice(i, i + WRAP_CHARS));
}
}
return lines.join("\n");
}
export function showLargeValueDialog({ text, sniffed }: Props) {
showDialog({
id: "large-value",
size: "lg",
className: "h-[calc(100vh-10rem)]",
// Everything in one line: the same words the tag uses, and the same menu it carries. A
// separate description and a row of its own for the button cost three bands to say this.
title: (
<HStack space={3} alignItems="center">
<span>
{sniffed == null ? "Hidden Value" : sniffed.label} · {formatSize(text.length)}
</span>
<LargeValueActions text={text} sniffed={sniffed} />
</HStack>
),
render: () => <LargeValueDialog text={text} sniffed={sniffed} />,
});
}
+3 -3
View File
@@ -1,4 +1,3 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
import { useLicense } from "@yaakapp-internal/license";
import { settingsAtom } from "@yaakapp-internal/models";
@@ -14,6 +13,7 @@ import type { ButtonProps } from "./core/Button";
import { Dropdown, type DropdownItem } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import { PillButton } from "./core/PillButton";
import { platform } from "@yaakapp-internal/platform";
const dismissedAtom = atomWithKVStorage<string | null>("dismissed_license_expired", null);
@@ -52,7 +52,7 @@ function getDetail(
leftSlot: <Icon icon="gift" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changes === 0 || data.data.changesUrl == null,
onSelect: () => openUrl(data.data.changesUrl ?? ""),
onSelect: () => platform.openUrl(data.data.changesUrl ?? ""),
},
{
type: "separator",
@@ -63,7 +63,7 @@ function getDetail(
leftSlot: <Icon icon="refresh" />,
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
hidden: data.data.changesUrl == null,
onSelect: () => openUrl(data.data.billingUrl),
onSelect: () => platform.openUrl(data.data.billingUrl),
},
{
label: "Enter License Key",
+3 -4
View File
@@ -1,7 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import { convertFileSrc } from "@tauri-apps/api/core";
import { resolveResource } from "@tauri-apps/api/path";
import classNames from "classnames";
import { platform } from "@yaakapp-internal/platform";
interface Props {
src: string;
@@ -12,8 +11,8 @@ export function LocalImage({ src: srcPath, className }: Props) {
const src = useQuery({
queryKey: ["local-image", srcPath],
queryFn: async () => {
const p = await resolveResource(srcPath);
return convertFileSrc(p);
const p = await platform.files.resolveResource(srcPath);
return platform.files.url(p);
},
});
@@ -1,4 +1,3 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { HttpResponse } from "@yaakapp-internal/models";
import { format, formatDistanceToNowStrict } from "date-fns";
import { useMemo } from "react";
@@ -6,6 +5,7 @@ import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform";
interface Props {
response: HttpResponse;
@@ -45,7 +45,7 @@ export function ResponseHeaders({ response }: Props) {
iconSize="sm"
className="inline-block w-auto h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => openUrl(response.url)}
onClick={() => platform.openUrl(response.url)}
title="Open in browser"
/>
</div>
+2 -2
View File
@@ -1,7 +1,7 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import type { HttpResponse } from "@yaakapp-internal/models";
import { IconButton } from "./core/IconButton";
import { KeyValueRow, KeyValueRows } from "./core/KeyValueRow";
import { platform } from "@yaakapp-internal/platform";
interface Props {
response: HttpResponse;
@@ -26,7 +26,7 @@ export function ResponseInfo({ response }: Props) {
iconSize="sm"
className="inline-block w-auto ml-1 h-auto! opacity-50 hover:opacity-100"
icon="external_link"
onClick={() => openUrl(response.url)}
onClick={() => platform.openUrl(response.url)}
title="Open in browser"
/>
</div>
+17 -29
View File
@@ -1,5 +1,4 @@
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { open } from "@tauri-apps/plugin-dialog";
import { platform } from "@yaakapp-internal/platform";
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import mime from "mime";
@@ -41,7 +40,7 @@ export function SelectFile({
...props
}: Props) {
const handleClick = async () => {
const filePath = await open({
const filePath = await platform.dialog.open({
title: directory ? "Select Folder" : "Select File",
multiple: false,
directory,
@@ -64,32 +63,21 @@ export function SelectFile({
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
// as browser drag-n-drop.
useEffect(() => {
let unlisten: (() => void) | undefined;
const setup = async () => {
const webview = getCurrentWebviewWindow();
unlisten = await webview.onDragDropEvent((event) => {
if (event.payload.type === "over") {
const p = event.payload.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
console.log("IS OVER", isOver);
setIsHovering(isOver);
} else if (event.payload.type === "drop" && isHovering) {
console.log("User dropped", event.payload.paths);
const p = event.payload.paths[0];
if (p) onChange({ filePath: p, contentType: null });
setIsHovering(false);
} else {
console.log("File drop cancelled");
setIsHovering(false);
}
});
};
setup().catch(console.error);
return () => {
if (unlisten) unlisten();
};
return platform.window.onDragDrop((event) => {
if (event.type === "over") {
const p = event.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
setIsHovering(isOver);
} else if (event.type === "drop" && isHovering) {
const p = event.paths[0];
if (p) onChange({ filePath: p, contentType: null });
setIsHovering(false);
} else {
setIsHovering(false);
}
});
}, [isHovering, onChange]);
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
@@ -1,6 +1,5 @@
import { useSearch } from "@tanstack/react-router";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { type } from "@tauri-apps/plugin-os";
import { platform } from "@yaakapp-internal/platform";
import { useLicense } from "@yaakapp-internal/license";
import { pluginsAtom, settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, HStack, Icon } from "@yaakapp-internal/ui";
@@ -60,7 +59,7 @@ export default function Settings({ hide }: Props) {
hide();
} else {
// It's being shown in a window, so close the window
await getCurrentWebviewWindow().close();
await platform.window.close();
}
});
@@ -75,7 +74,7 @@ export default function Settings({ hide }: Props) {
onlyXWindowControl
size="md"
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
osType={type()}
osType={platform.osType()}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
@@ -85,7 +84,7 @@ export default function Settings({ hide }: Props) {
justifyContent="center"
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
>
<div className={classNames(type() === "macos" ? "text-center" : "pl-2")}>Settings</div>
<div className={classNames(platform.osType() === "macos" ? "text-center" : "pl-2")}>Settings</div>
</HStack>
</HeaderSize>
)}
@@ -1,4 +1,3 @@
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Heading, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
@@ -9,6 +8,7 @@ import { CargoFeature } from "../CargoFeature";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { DismissibleBanner } from "../core/DismissibleBanner";
import { IconButton } from "../core/IconButton";
import { platform } from "@yaakapp-internal/platform";
import {
ModelSettingRowBoolean,
ModelSettingSelectControl,
@@ -152,7 +152,7 @@ export function SettingsGeneral() {
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appDataDir),
onClick: () => platform.revealItemInDir(appInfo.appDataDir),
},
]}
/>
@@ -168,7 +168,7 @@ export function SettingsGeneral() {
{
title: revealInFinderText,
icon: "folder_open",
onClick: () => revealItemInDir(appInfo.appLogDir),
onClick: () => platform.revealItemInDir(appInfo.appLogDir),
},
]}
/>
@@ -1,4 +1,4 @@
import { type } from "@tauri-apps/plugin-os";
import { platform } from "@yaakapp-internal/platform";
import { useFonts } from "@yaakapp-internal/fonts";
import { useLicense } from "@yaakapp-internal/license";
import type { EditorKeymap, Settings } from "@yaakapp-internal/models";
@@ -9,7 +9,7 @@ import { useState } from "react";
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
import { showConfirm } from "../../lib/confirm";
import { pricingUrl } from "../../lib/pricingUrl";
import { invokeCmd } from "../../lib/tauri";
import { rpc } from "../../lib/rpc";
import { CargoFeature } from "../CargoFeature";
import { Button } from "../core/Button";
import { Checkbox } from "../core/Checkbox";
@@ -176,7 +176,7 @@ export function SettingsInterface() {
<SettingsSection title="Window">
<NativeTitlebarSetting settings={settings} />
{type() !== "macos" && (
{platform.osType() !== "macos" && (
<ModelSettingRowBoolean
model={settings}
modelKey="hideWindowControls"
@@ -216,7 +216,7 @@ function NativeTitlebarSetting({ settings }: { settings: Settings }) {
size="xs"
onClick={async () => {
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
await invokeCmd("cmd_restart");
await rpc("cmd_restart");
}}
>
Apply and Restart
@@ -1,4 +1,3 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { useLicense } from "@yaakapp-internal/license";
import { Banner, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { differenceInDays } from "date-fns";
@@ -12,6 +11,7 @@ import { Button } from "../core/Button";
import { Link } from "../core/Link";
import { PlainInput } from "../core/PlainInput";
import { Separator } from "../core/Separator";
import { platform } from "@yaakapp-internal/platform";
export function SettingsLicense() {
return (
@@ -135,7 +135,7 @@ function SettingsLicenseCmp() {
<Button
color="secondary"
size="sm"
onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")}
onClick={() => platform.openUrl("https://yaak.app/dashboard?intent=app.license.support")}
rightSlot={<Icon icon="external_link" />}
>
Direct Support
@@ -151,7 +151,7 @@ function SettingsLicenseCmp() {
color="primary"
rightSlot={<Icon icon="external_link" />}
onClick={() =>
openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
platform.openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
}
>
Purchase License
@@ -1,5 +1,4 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { openUrl } from "@tauri-apps/plugin-opener";
import type { Plugin } from "@yaakapp-internal/models";
import { patchModel, pluginsAtom } from "@yaakapp-internal/models";
import type { PluginVersion } from "@yaakapp-internal/plugins";
@@ -39,6 +38,7 @@ import { PlainInput } from "../core/PlainInput";
import { TabContent, Tabs } from "../core/Tabs/Tabs";
import { EmptyStateText } from "../EmptyStateText";
import { SelectFile } from "../SelectFile";
import { platform } from "@yaakapp-internal/platform";
interface SettingsPluginsProps {
defaultSubtab?: string;
@@ -113,7 +113,7 @@ export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
icon="help"
title="View documentation"
onClick={() =>
openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
platform.openUrl("https://yaak.app/docs/plugin-development/plugins-quick-start")
}
/>
</HStack>
@@ -1,4 +1,3 @@
import { openUrl } from "@tauri-apps/plugin-opener";
import { useLicense } from "@yaakapp-internal/license";
import { useRef } from "react";
import { openSettings } from "../commands/openSettings";
@@ -13,6 +12,7 @@ import { Dropdown } from "./core/Dropdown";
import { Icon } from "@yaakapp-internal/ui";
import { IconButton } from "./core/IconButton";
import { KeyboardShortcutsDialog } from "./KeyboardShortcutsDialog";
import { platform } from "@yaakapp-internal/platform";
export function SettingsDropdown() {
const exportData = useExportData();
@@ -62,7 +62,7 @@ export function SettingsDropdown() {
{
label: "Create Run Button",
leftSlot: <Icon icon="rocket" />,
onSelect: () => openUrl("https://yaak.app/button/new"),
onSelect: () => platform.openUrl("https://yaak.app/button/new"),
},
{ type: "separator", label: `Yaak v${appInfo.version}` },
{
@@ -78,26 +78,26 @@ export function SettingsDropdown() {
leftSlot: <Icon icon="circle_dollar_sign" />,
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
onSelect: () =>
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
platform.openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
},
{
label: "Install CLI",
hidden: appInfo.cliVersion != null,
leftSlot: <Icon icon="square_terminal" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl("https://yaak.app/docs/cli"),
onSelect: () => platform.openUrl("https://yaak.app/docs/cli"),
},
{
label: "Feedback",
leftSlot: <Icon icon="chat" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl("https://yaak.app/feedback"),
onSelect: () => platform.openUrl("https://yaak.app/feedback"),
},
{
label: "Changelog",
leftSlot: <Icon icon="cake" />,
rightSlot: <Icon icon="external_link" color="secondary" />,
onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),
onSelect: () => platform.openUrl(`https://yaak.app/changelog/${appInfo.version}`),
},
]}
>
+19 -7
View File
@@ -43,7 +43,8 @@ import { getFolderActions } from "../hooks/useFolderActions";
import { getGrpcRequestActions } from "../hooks/useGrpcRequestActions";
import { useHotKey } from "../hooks/useHotKey";
import { getHttpRequestActions } from "../hooks/useHttpRequestActions";
import { useListenToTauriEvent } from "../hooks/useListenToTauriEvent";
import { platform } from "@yaakapp-internal/platform";
import { usePlatformEvent } from "../hooks/usePlatformEvent";
import { getModelAncestors } from "../hooks/useModelAncestors";
import { sendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest";
import { useSidebarHidden } from "../hooks/useSidebarHidden";
@@ -112,6 +113,7 @@ function Sidebar({ className }: { className?: string }) {
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
const filterText = useAtomValue(sidebarFilterAtom);
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
const wrapperRef = useRef<HTMLElement>(null);
const treeRef = useRef<TreeHandle>(null);
const filterRef = useRef<InputHandle>(null);
@@ -130,11 +132,17 @@ function Sidebar({ className }: { className?: string }) {
if (!didFocus) filterRef.current?.focus();
}, []);
// Focus any new sidebar models when created
useListenToTauriEvent<ModelPayload>("model_write", ({ payload }) => {
if (!isSidebarLeafModel(payload.model)) return;
if (!(payload.change.type === "upsert" && payload.change.created)) return;
treeRef.current?.selectItem(payload.model.id, true);
// Focus new sidebar models created by the user in this window. Writes from other
// sources (import, sync, CLI) can carry thousands of models and shouldn't move
// the selection.
usePlatformEvent<ModelPayload[]>("model_writes", (payloads) => {
for (const payload of payloads) {
if (payload.updateSource.type !== "window") continue;
if (payload.updateSource.label !== platform.window.label) continue;
if (!isSidebarLeafModel(payload.model)) continue;
if (!(payload.change.type === "upsert" && payload.change.created)) continue;
treeRef.current?.selectItem(payload.model.id, true);
}
});
useEffect(() => {
@@ -724,7 +732,11 @@ function Sidebar({ className }: { className?: string }) {
);
}
export default Sidebar;
// Memoized so route navigations (which re-render the workspace layout) don't
// re-render the sidebar subtree. In large workspaces a sidebar re-render is
// very expensive: it re-renders DndContext, whose context churn re-renders
// every visible TreeItem regardless of their memo comparators.
export default memo(Sidebar);
function getGitContextMenuItems({
items,
@@ -1,4 +1,3 @@
import { readDir } from "@tauri-apps/plugin-fs";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
@@ -6,6 +5,7 @@ import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { SettingRowBoolean, SettingRowDirectory } from "./core/SettingRow";
import { SelectFile } from "./SelectFile";
import { platform } from "@yaakapp-internal/platform";
export interface SyncToFilesystemSettingProps {
layout?: "form" | "settings";
@@ -24,7 +24,7 @@ export function SyncToFilesystemSetting({
const handleFilePathChange = async (filePath: string | null) => {
if (filePath != null) {
const files = await readDir(filePath);
const files = await platform.files.readDir(filePath);
if (files.length > 0) {
setSyncDir(filePath);
return;
@@ -1,15 +1,14 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { useCallback, useRef } from "react";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import type { PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import type { EditablePair, PairEditorHandle, PairEditorProps } from "./core/PairEditor";
import { PairOrBulkEditor } from "./core/PairOrBulkEditor";
type Props = {
forceUpdateKey: string;
pairs: HttpRequest["headers"];
pairs: EditablePair[];
stateKey: PairEditorProps["stateKey"];
onChange: (headers: HttpRequest["urlParameters"]) => void;
onChange: PairEditorProps["onChange"];
};
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
@@ -1,5 +1,10 @@
import type { WebsocketRequest } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import {
flushAllModelWrites,
getModel,
patchModel,
patchModelDebounced,
} from "@yaakapp-internal/models";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws";
import classNames from "classnames";
@@ -8,8 +13,7 @@ import type { CSSProperties } from "react";
import { useCallback, useMemo, useRef } from "react";
import { getActiveCookieJar } from "../hooks/useActiveCookieJar";
import { getActiveEnvironment } from "../hooks/useActiveEnvironment";
import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
import { allRequestsAtom } from "../hooks/useAllRequests";
import { allRequestUrlsAtom } from "../hooks/useAllRequests";
import { useAuthTab } from "../hooks/useAuthTab";
import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse";
import { useHeadersTab } from "../hooks/useHeadersTab";
@@ -18,10 +22,8 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse";
import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection";
import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor";
import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey";
import { deepEqualAtom } from "../lib/atoms";
import { languageFromContentType } from "../lib/contentType";
import { generateId } from "../lib/generateId";
import { extractPathPlaceholders } from "../lib/pathPlaceholders";
import { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders";
import { prepareImportQuerystring } from "../lib/prepareImportQuerystring";
import { resolvedModelName } from "../lib/resolvedModelName";
import { CountBadge } from "./core/CountBadge";
@@ -29,7 +31,6 @@ import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import { getUrlCompletionConfig } from "./core/Editor/url/completion";
import { Editor } from "./core/Editor/LazyEditor";
import { IconButton } from "./core/IconButton";
import type { Pair } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
import type { TabItem, TabsRef } from "./core/Tabs/Tabs";
import { setActiveTab, TabContent, Tabs } from "./core/Tabs/Tabs";
@@ -55,15 +56,12 @@ const TAB_SETTINGS = "settings";
const TAB_DESCRIPTION = "description";
const TABS_STORAGE_KEY = "websocket_request_tabs";
const nonActiveRequestUrlsAtom = atom((get) => {
const activeRequestId = get(activeRequestIdAtom);
const requests = get(allRequestsAtom);
return requests
.filter((r) => r.id !== activeRequestId)
.map((r): GenericCompletionOption => ({ type: "constant", label: r.url }));
});
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
// Derived from the identity-stable URL list so this only recomputes when a URL
// actually changes. The active request's own URL is included, but exact matches
// are filtered out at completion time by genericCompletion.
const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] =>
get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })),
);
export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) {
const activeRequestId = activeRequest.id;
@@ -84,20 +82,33 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
[],
);
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
const placeholderNames = extractPathPlaceholders(activeRequest.url);
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
const items: Pair[] = [...nonEmptyParameters];
for (const name of placeholderNames) {
const item = items.find((p) => p.name === name);
if (item) {
item.readOnlyName = true;
} else {
items.push({ name, value: "", enabled: true, readOnlyName: true, id: generateId() });
}
}
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(",") };
}, [activeRequest.url, activeRequest.urlParameters]);
// Renaming a path placeholder has to rewrite the URL and rename the parameter together, or the
// value detaches from the placeholder.
// NOTE: Reads the request fresh rather than closing over `activeRequest`. The row that calls this
// holds onto it until the URL's placeholders change, so a captured request would go stale and
// patch its parameter list back over newer edits.
const handleRenamePathPlaceholder = useCallback(
(oldName: string, newName: string) => {
const request = getModel("websocket_request", activeRequestId);
if (request == null) return false;
const patch = renamePathPlaceholder(request, oldName, newName);
if (patch == null) return false; // Unusable name, so the editor reverts the field
void patchModel(request, patch);
return true;
},
[activeRequestId],
);
const { urlParameterPairs, urlParametersKey } = useMemo(
() =>
derivePathPlaceholderPairs(
activeRequest.url,
activeRequest.urlParameters,
handleRenamePathPlaceholder,
),
[activeRequest.url, activeRequest.urlParameters, handleRenamePathPlaceholder],
);
const tabs = useMemo<TabItem[]>(() => {
return [
@@ -128,7 +139,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
const connection = useAtomValue(activeWebsocketConnectionAtom);
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
const autocompleteUrls = useAtomValue(requestUrlOptionsAtom);
const autocomplete: GenericCompletionConfig = useMemo(
() => getUrlCompletionConfig(autocompleteUrls),
@@ -136,6 +147,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
);
const handleConnect = useCallback(async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
await connectWebsocket({
requestId: activeRequest.id,
environmentId: getActiveEnvironment()?.id ?? null,
@@ -145,6 +157,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
const handleSend = useCallback(async () => {
if (connection == null) return;
await flushAllModelWrites(); // The backend reads the message from the DB
await sendWebsocket({
connectionId: connection?.id,
environmentId: getActiveEnvironment()?.id ?? null,
@@ -157,7 +170,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
}, [connection]);
const handleUrlChange = useCallback(
(url: string) => patchModel(activeRequest, { url }),
(url: string) => patchModelDebounced(activeRequest, { url }),
[activeRequest],
);
@@ -241,7 +254,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
forceUpdateKey={forceUpdateKey}
headers={activeRequest.headers}
stateKey={`headers.${activeRequest.id}`}
onChange={(headers) => patchModel(activeRequest, { headers })}
onChange={(headers) => patchModelDebounced(activeRequest, { headers })}
/>
</TabContent>
<TabContent value={TAB_PARAMS}>
@@ -249,7 +262,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
stateKey={`params.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey + urlParametersKey}
pairs={urlParameterPairs}
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })}
/>
</TabContent>
<TabContent value={TAB_MESSAGE}>
@@ -261,7 +274,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
heightMode={fullHeight ? "full" : "auto"}
defaultValue={activeRequest.message}
language={messageLanguage}
onChange={(message) => patchModel(activeRequest, { message })}
onChange={(message) => patchModelDebounced(activeRequest, { message })}
stateKey={`json.${activeRequest.id}`}
/>
</TabContent>
@@ -278,7 +291,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
className="font-sans text-xl! px-0!"
containerClassName="border-0"
placeholder={resolvedModelName(activeRequest)}
onChange={(name) => patchModel(activeRequest, { name })}
onChange={(name) => patchModelDebounced(activeRequest, { name })}
/>
<MarkdownEditor
name="request-description"
@@ -286,7 +299,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque
defaultValue={activeRequest.description}
stateKey={`description.${activeRequest.id}`}
forceUpdateKey={forceUpdateKey}
onChange={(description) => patchModel(activeRequest, { description })}
onChange={(description) => patchModelDebounced(activeRequest, { description })}
/>
</div>
</TabContent>
+2 -2
View File
@@ -1,4 +1,4 @@
import { type } from "@tauri-apps/plugin-os";
import { platform } from "@yaakapp-internal/platform";
import { settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
import { Banner, HeaderSize, HStack, SidebarLayout } from "@yaakapp-internal/ui";
import classNames from "classnames";
@@ -53,7 +53,7 @@ export function Workspace() {
const workspaces = useAtomValue(workspacesAtom);
const settings = useAtomValue(settingsAtom);
const osType = type();
const osType = platform.osType();
const [width, setWidth] = useSidebarWidth();
const [sidebarHidden, setSidebarHidden] = useSidebarHidden();
const [floatingSidebarHidden, setFloatingSidebarHidden] = useFloatingSidebarHidden();
@@ -1,5 +1,3 @@
import { open } from "@tauri-apps/plugin-dialog";
import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { getModel, settingsAtom, workspacesAtom } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
@@ -16,6 +14,7 @@ import { useCreateWorkspace } from "../hooks/useCreateWorkspace";
import { useDeleteSendHistory } from "../hooks/useDeleteSendHistory";
import { useWorkspaceActions } from "../hooks/useWorkspaceActions";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import { jotaiStore } from "../lib/jotai";
import { revealInFinderText } from "../lib/reveal";
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
@@ -26,6 +25,7 @@ import { Icon } from "@yaakapp-internal/ui";
import type { RadioDropdownItem } from "./core/RadioDropdown";
import { RadioDropdown } from "./core/RadioDropdown";
import { SwitchWorkspaceDialog } from "./SwitchWorkspaceDialog";
import { platform } from "@yaakapp-internal/platform";
type Props = Pick<ButtonProps, "className" | "justify" | "forDropdown" | "leftSlot">;
@@ -75,7 +75,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
label: "Open Folder",
leftSlot: <Icon icon="folder_open" />,
onSelect: async () => {
const dir = await open({
const dir = await platform.dialog.open({
title: "Select Workspace Directory",
directory: true,
multiple: false,
@@ -90,6 +90,11 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: <Icon icon="hard_drive_download" />,
onSelect: openCloneGitRepositoryDialog,
},
{
label: "Import Data",
leftSlot: <Icon icon="folder_input" />,
onSelect: () => importData.mutate(),
},
],
},
];
@@ -114,7 +119,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: <Icon icon="folder_symlink" />,
onSelect: async () => {
if (workspaceMeta?.settingSyncDir == null) return;
await revealItemInDir(workspaceMeta.settingSyncDir);
await platform.revealItemInDir(workspaceMeta.settingSyncDir);
},
},
{
@@ -10,7 +10,7 @@ import { CookieDropdown } from "./CookieDropdown";
import { IconButton } from "./core/IconButton";
import { PillButton } from "./core/PillButton";
import { EnvironmentActionsDropdown } from "./EnvironmentActionsDropdown";
import { ImportCurlButton } from "./ImportCurlButton";
import { ImportCurl } from "./ImportCurl";
import { LicenseBadge } from "./LicenseBadge";
import { RecentRequestsDropdown } from "./RecentRequestsDropdown";
import { SettingsDropdown } from "./SettingsDropdown";
@@ -56,7 +56,7 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({
<RecentRequestsDropdown />
</div>
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
<ImportCurlButton />
<ImportCurl />
{showEncryptionSetup ? (
<PillButton color="danger" onClick={setupOrConfigureEncryption}>
Enter Encryption Key
+32 -9
View File
@@ -233,23 +233,40 @@ export const Dropdown = forwardRef<DropdownRef, DropdownProps>(function Dropdown
export interface ContextMenuProps {
triggerPosition: { x: number; y: number } | null;
/**
* The box the menu belongs to, when the trigger has one.
*
* Placement aligns to the trigger's left or right edge depending on the room beside it, so a
* menu opened from an element wants its real rect. Without one the position is treated as a
* zero-width point, which is right for a menu opened at the cursor and wrong for one opened
* from a button.
*/
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
/**
* The element the menu belongs to, so a click on it doesn't count as a click outside.
*
* Without this the trigger gets both: the outside-click handler closes the menu on mousedown,
* then the trigger's own click opens it again, and pressing it looks like it does nothing.
*/
triggerRef?: RefObject<HTMLElement | null>;
className?: string;
items: DropdownProps["items"];
onClose: () => void;
}
export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function ContextMenu(
{ triggerPosition, className, items, onClose },
{ triggerPosition, triggerRect, triggerRef, className, items, onClose },
ref,
) {
const triggerShape = useMemo(
() => ({
top: triggerPosition?.y ?? 0,
bottom: triggerPosition?.y ?? 0,
left: triggerPosition?.x ?? 0,
right: triggerPosition?.x ?? 0,
}),
[triggerPosition],
() =>
triggerRect ?? {
top: triggerPosition?.y ?? 0,
bottom: triggerPosition?.y ?? 0,
left: triggerPosition?.x ?? 0,
right: triggerPosition?.x ?? 0,
},
[triggerPosition, triggerRect],
);
if (triggerPosition == null) return null;
@@ -259,9 +276,13 @@ export const ContextMenu = forwardRef<DropdownRef, ContextMenuProps>(function Co
isOpen={true} // Always open because we return null if not
className={className}
defaultSelectedIndex={null}
// A menu opened from an element points back at it. One opened at the cursor has nothing
// to point at, so it goes without.
showTriangle={triggerRect != null}
ref={ref}
items={items}
onClose={onClose}
triggerRef={triggerRef}
triggerShape={triggerShape}
/>
);
@@ -277,7 +298,9 @@ interface MenuProps {
fullWidth?: boolean;
isOpen: boolean;
items: DropdownItem[];
triggerRef?: RefObject<HTMLButtonElement | null>;
// Any element, not just a button: a menu can be opened from anything, and this is only ever
// used to ask whether a click landed on the trigger
triggerRef?: RefObject<HTMLElement | null>;
isSubmenu?: boolean;
}
@@ -121,6 +121,24 @@
@apply cursor-default;
}
/* The chevron on a tag that opens a menu. The tag itself is the button, so this is
only the affordance that says so. */
.tag-action {
@apply inline-flex items-center align-middle ml-1;
@apply text-text-subtlest;
svg {
@apply w-[0.9em] h-[0.9em];
}
}
/* A preview of the value a tag stands for. Sized before the image loads, so arriving
never changes the line's height. */
.tag-thumbnail {
@apply inline-block align-middle mr-1 rounded-xs;
@apply w-[1.1em] h-[1.1em] object-cover bg-surface-highlight;
}
.fn {
@apply inline-block;
.fn-inner {
@@ -8,6 +8,7 @@ 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";
@@ -15,7 +16,6 @@ import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { GraphQLSchema } from "graphql";
import { useAtomValue } from "jotai";
import { md5 } from "js-md5";
import type { ReactNode, RefObject } from "react";
import {
Children,
@@ -33,6 +33,7 @@ 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";
@@ -42,6 +43,7 @@ import { IconButton } from "../IconButton";
import "./Editor.css";
import {
baseExtensions,
editableExtensions,
getLanguageExtension,
multiLineExtensions,
readonlyExtensions,
@@ -288,7 +290,7 @@ function EditorInner({
function configureReadOnly() {
if (cm.current === null) return;
const current = readOnlyCompartment.current.get(cm.current.view.state) ?? emptyExtension;
const next = readOnly ? readonlyExtensions : 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;
@@ -381,6 +383,7 @@ function EditorInner({
const initEditorRef = useCallback(
function initEditorRef(container: HTMLDivElement | null) {
if (container === null) {
flushCachedEditorState(stateKey);
cm.current?.view.destroy();
cm.current = null;
return;
@@ -410,7 +413,7 @@ function EditorInner({
keymapCompartment.current.of(
keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default,
),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : emptyExtension),
readOnlyCompartment.current.of(readOnly ? readonlyExtensions : editableExtensions),
...getExtensions({
container,
singleLine,
@@ -430,7 +433,7 @@ function EditorInner({
: []),
];
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey);
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey, !!readOnly);
const doc = `${defaultValue ?? ""}`;
const config: EditorStateConfig = { extensions, doc };
@@ -639,7 +642,7 @@ function getExtensions({
onChange.current?.(update.state.doc.toString());
}
saveCachedEditorState(stateKey, update.state);
saveCachedEditorStateDebounced(stateKey, update.state);
}),
];
}
@@ -652,13 +655,53 @@ const placeholderElFromText = (text: string | undefined) => {
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 the hash of it instead.
// 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.
stateObj.docHash = md5(stateObj.doc);
// 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 {
@@ -668,7 +711,7 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
}
}
function getCachedEditorState(doc: string, stateKey: string | null) {
function getCachedEditorState(doc: string, stateKey: string | null, readOnly: boolean) {
if (stateKey == null) return;
try {
@@ -678,7 +721,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) {
const { docHash, ...state } = JSON.parse(stateStr);
// Ensure the doc matches the one that was used to save the state
if (docHash !== md5(doc)) {
if (docHash !== docFingerprint(doc, { exact: !readOnly })) {
return null;
}
@@ -59,6 +59,7 @@ import { pluralizeCount } from "../../../lib/pluralize";
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
import type { EditorProps } from "./Editor";
import { jsonParseLinter } from "./json-lint";
import { largeValues, mediaValues } from "./largeValues";
import { pairs } from "./pairs/extension";
import { searchMatchCount } from "./searchMatchCount";
import { text } from "./text/extension";
@@ -258,8 +259,19 @@ export const baseExtensions = [
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(),
@@ -0,0 +1,500 @@
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 {
COLLAPSE_MEDIA_CHARS,
COLLAPSE_TOKEN_CHARS,
collapseDecorations,
largeValues,
MAX_VISIBLE_LINE_CHARS,
} from "./largeValues";
import type { SniffedValue } from "./sniffValue";
const BIG = "A".repeat(1_000_000);
/** With a grammar, so tokens can be collapsed individually */
const jsonState = (doc: string) => EditorState.create({ doc, extensions: [jsonc(), largeValues] });
/** Without a grammar, so only the column rule applies */
const plainState = (doc: string) => EditorState.create({ doc, extensions: largeValues });
/**
* The decorations as if the whole document were on screen.
*
* In the editor the plugin passes the viewport instead, which is the same call with narrower
* ranges — the rules themselves don't know the difference.
*/
function decorationsFor(state: EditorState) {
return collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
}
function collapsedRanges(state: EditorState) {
const ranges: { from: number; to: number }[] = [];
const iter = decorationsFor(state).iter();
while (iter.value != null) {
ranges.push({ from: iter.from, to: iter.to });
iter.next();
}
return ranges;
}
/** How much of each line is still rendered */
function visibleLineLengths(state: EditorState) {
const hidden = collapsedRanges(state);
const lengths: number[] = [];
for (let n = 1; n <= state.doc.lines; n++) {
const line = state.doc.line(n);
const covered = hidden
.filter((h) => h.from >= line.from && h.to <= line.to)
.reduce((sum, h) => sum + (h.to - h.from), 0);
lengths.push(line.length - covered);
}
return lengths;
}
describe("collapsing", () => {
test("leaves an ordinary body alone", () => {
expect(collapsedRanges(jsonState('{"hello":"world"}'))).toEqual([]);
});
test("leaves a large body of short lines alone", () => {
const doc = Array.from({ length: 20_000 }, (_, i) => ` { "id": ${i} },`).join("\n");
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
expect(collapsedRanges(jsonState(doc))).toEqual([]);
});
test("leaves a line just under the column limit alone", () => {
expect(collapsedRanges(plainState("x".repeat(MAX_VISIBLE_LINE_CHARS)))).toEqual([]);
});
test("never renders more than the column limit per line", () => {
for (const state of [jsonState(`{"image":"${BIG}"}`), plainState(BIG)]) {
for (const length of visibleLineLengths(state)) {
expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
}
}
});
test("keeps the document text intact", () => {
const doc = `{"image":"${BIG}"}`;
expect(jsonState(doc).sliceDoc()).toBe(doc);
expect(jsonState(doc).doc.length).toBe(doc.length);
});
});
describe("token collapsing, with a grammar", () => {
test("hides the whole value, leaving its quotes visible", () => {
const doc = `{"name":"a.png","image":"${BIG}","size":12}`;
const state = jsonState(doc);
const ranges = collapsedRanges(state);
expect(ranges).toHaveLength(1);
// Exactly the text between the quotes, so the line reads as `"image":"<tag>"`
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
expect(doc[ranges[0]!.from - 1]).toBe('"');
expect(doc[ranges[0]!.to]).toBe('"');
// Everything after the value is still rendered, unlike a plain column cut
expect(doc.slice(ranges[0]!.to)).toContain('"size":12}');
});
// Small enough that the parse always finishes inside PARSE_TIMEOUT_MS, even on a slow
// machine. With a bigger body this falls back to the column cut, which is by design but
// makes the assertion depend on how fast the runner is.
test("keeps every key visible in a minified body with several large values", () => {
const chunk = "B".repeat(20_000);
const doc = `{${["a", "b", "c", "d", "e"].map((k) => `"${k}":"${chunk}"`).join(",")}}`;
const state = jsonState(doc);
const ranges = collapsedRanges(state);
expect(ranges).toHaveLength(5);
for (const key of ["a", "b", "c", "d", "e"]) {
// No collapse swallows the key
const at = doc.indexOf(`"${key}":`);
expect(ranges.some((r) => r.from <= at && r.to > at)).toBe(false);
}
expect(visibleLineLengths(state)[0]).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
});
test("collapses a value on a pretty-printed line", () => {
const doc = `{\n "name": "a.png",\n "image": "${BIG}"\n}`;
const state = jsonState(doc);
const ranges = collapsedRanges(state);
expect(ranges).toHaveLength(1);
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(BIG);
// Only the long line is touched, and all that is left of it is ` "image": ""`
expect(visibleLineLengths(state)).toEqual([1, 18, 13, 1]);
expect(state.doc.line(2).text).toBe(' "name": "a.png",');
});
test("ignores tokens under the collapse threshold", () => {
// Under the threshold once the surrounding quotes are counted
const short = "C".repeat(COLLAPSE_TOKEN_CHARS - 10);
const doc = `{${Array.from({ length: 4 }, (_, i) => `"k${i}":"${short}"`).join(",")}}`;
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
// Nothing is big enough to collapse on its own, so the column rule takes over
const ranges = collapsedRanges(jsonState(doc));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.to).toBe(doc.length);
});
});
describe("column collapsing, without a grammar", () => {
test("collapses everything past the limit", () => {
const ranges = collapsedRanges(plainState(BIG));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
expect(ranges[0]!.to).toBe(BIG.length);
});
test("handles a long line of many short tokens", () => {
// A single-line CSV row: no token is long enough to collapse on its own
const row = Array.from({ length: 40_000 }, (_, i) => `value ${i}`).join(", ");
const ranges = collapsedRanges(plainState(row));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
});
test("collapses each long line independently", () => {
const doc = `${BIG}\nshort\n${BIG}`;
const ranges = collapsedRanges(plainState(doc));
expect(ranges).toHaveLength(2);
for (const length of visibleLineLengths(plainState(doc))) {
expect(length).toBeLessThanOrEqual(MAX_VISIBLE_LINE_CHARS);
}
});
test("never hides a line break", () => {
const doc = `${BIG}\nshort`;
const state = plainState(doc);
for (const { from, to } of collapsedRanges(state)) {
expect(state.sliceDoc(from, to)).not.toContain("\n");
}
expect(state.doc.lines).toBe(2);
});
});
describe("undelimited tokens", () => {
// The text grammar parses a whole line as one token. Collapsing it whole would leave the
// line with nothing on it but a tag, so the column cut handles it instead.
const textState = (doc: string) => EditorState.create({ doc, extensions: [text(), largeValues] });
test("falls back to the column cut for a long line of plain text", () => {
const doc = Array.from({ length: 90_000 }, (_, i) => `word${i}`).join(" ");
const ranges = collapsedRanges(textState(doc));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
expect(ranges[0]!.to).toBe(doc.length);
});
test("leaves the start of the line readable", () => {
const doc = `IMPORTANT-PREFIX ${"z".repeat(500_000)}`;
const state = textState(doc);
const ranges = collapsedRanges(state);
expect(ranges[0]!.from).toBe(MAX_VISIBLE_LINE_CHARS);
expect(state.sliceDoc(0, 16)).toBe("IMPORTANT-PREFIX");
});
});
describe("sniffing the collapsed value", () => {
/** The widget standing in for each collapse, in document order */
function widgets(state: EditorState) {
const found: { valueFrom: number; sniffed: SniffedValue | null }[] = [];
const iter = decorationsFor(state).iter();
while (iter.value != null) {
found.push(iter.value.spec.widget);
iter.next();
}
return found;
}
/** A base64 value that starts with a PNG signature and runs on well past the limits */
function pngBase64(length = 1_000_000) {
const bytes = new Uint8Array(length);
bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
test("names the format of a base64 value inside JSON", () => {
const state = jsonState(`{"image":"${pngBase64()}"}`);
expect(widgets(state)[0]?.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
});
test("names the format of a data URI", () => {
const doc = `{"image":"data:image/jpeg;base64,${BIG}"}`;
expect(widgets(jsonState(doc))[0]?.sniffed).toMatchObject({ label: "JPEG" });
});
test("names nothing when the value is just text", () => {
expect(widgets(jsonState(`{"image":"${BIG}"}`))[0]?.sniffed).toBeNull();
expect(widgets(plainState(BIG))[0]?.sniffed).toBeNull();
});
test("reads a column cut from the start of its line, not from the cut", () => {
// A body that is nothing but one base64 blob: the tail is hidden, but the value it
// belongs to begins at the start of the line, which is where the signature is
const state = plainState(pngBase64());
const [widget] = widgets(state);
expect(widget?.valueFrom).toBe(0);
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
});
test("reads a token collapse from the value itself", () => {
const doc = `{"image":"${pngBase64()}"}`;
const [widget] = widgets(jsonState(doc));
// Just past the opening quote, so the signature is the first thing it sees
expect(widget?.valueFrom).toBe(doc.indexOf('"', doc.indexOf("image") + 6) + 1);
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
});
test("finds a run that starts partway through a line, and names it", () => {
// No grammar here, and the value is not the whole line. The run is found by its alphabet,
// so the prefix stays on screen and the blob is still named.
const prefix = "some prefix text ";
const state = plainState(`${prefix}${pngBase64()}`);
const [widget] = widgets(state);
expect(widget?.valueFrom).toBe(prefix.length);
expect(widget?.sniffed).toMatchObject({ label: "PNG" });
});
});
describe("recomputing", () => {
test("updates when the document changes", () => {
const state = plainState('{"image":"short"}');
expect(collapsedRanges(state)).toEqual([]);
const next = state.update({
changes: { from: 0, to: state.doc.length, insert: BIG },
}).state;
expect(collapsedRanges(next)).toHaveLength(1);
});
});
describe("media collapsing, below the length rules", () => {
/** A real PNG signature, at a size that no length rule would touch */
function png(bytes: number) {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let binary = "";
for (const byte of b) binary += String.fromCharCode(byte);
return btoa(binary);
}
/** Well under every length threshold, so only the media rule can collapse it */
const SMALL = png(3_000);
test("collapses a small image on a short line", () => {
expect(SMALL.length).toBeLessThan(MAX_VISIBLE_LINE_CHARS);
expect(SMALL.length).toBeLessThan(COLLAPSE_TOKEN_CHARS);
const state = jsonState(`{\n "avatar": "${SMALL}"\n}`);
const ranges = collapsedRanges(state);
expect(ranges).toHaveLength(1);
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(SMALL);
});
test("names it, so the tag can say what it is", () => {
const state = jsonState(`{"avatar":"${SMALL}"}`);
const iter = decorationsFor(state).iter();
expect(iter.value?.spec.widget.sniffed).toMatchObject({ mime: "image/png", label: "PNG" });
});
test("leaves a value of the same size alone when nothing recognises it", () => {
// The only difference from the case above is what the bytes turn out to be
const prose = "x".repeat(SMALL.length);
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
});
test("leaves anything under the media threshold alone, recognised or not", () => {
const tiny = png(400);
expect(tiny.length).toBeLessThan(COLLAPSE_MEDIA_CHARS);
expect(collapsedRanges(jsonState(`{"icon":"${tiny}"}`))).toEqual([]);
});
test("leaves text alone even when its first bytes match a signature", () => {
// `Qk0` decodes to `BM`, the whole of the BMP signature. Two bytes come up by chance often
// enough that the sniff alone must not be allowed to hide something.
const prose = `Qk0 ${"the quick brown fox jumps over the lazy dog. ".repeat(40)}`;
expect(prose.length).toBeGreaterThan(COLLAPSE_MEDIA_CHARS);
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toEqual([]);
});
test("still collapses it once it is long enough to be a rendering problem", () => {
// Past the length rule the sniff no longer decides anything, so this is hidden for its size
const prose = "Qk0 " + "words and more words ".repeat(COLLAPSE_TOKEN_CHARS);
expect(collapsedRanges(jsonState(`{"note":"${prose}"}`))).toHaveLength(1);
});
test("collapses a data URI on a short line", () => {
const state = jsonState(`{"avatar":"data:image/jpeg;base64,${SMALL}"}`);
expect(collapsedRanges(state)).toHaveLength(1);
});
test("still leaves an ordinary document completely untouched", () => {
const doc = Array.from({ length: 5_000 }, (_, i) => ` { "id": ${i}, "name": "item" },`).join(
"\n",
);
expect(collapsedRanges(jsonState(doc))).toEqual([]);
});
});
describe("media rule only, for a document being edited", () => {
/** What an editable editor gets: rule 2 alone */
function editableRanges(state: EditorState) {
const ranges: { from: number; to: number }[] = [];
const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
while (iter.value != null) {
ranges.push({ from: iter.from, to: iter.to });
iter.next();
}
return ranges;
}
function png(bytes: number) {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let binary = "";
for (const byte of b) binary += String.fromCharCode(byte);
return btoa(binary);
}
test("collapses a pasted image, the one thing it is for", () => {
const value = png(3_000);
const state = jsonState(`{\n "avatar": "${value}"\n}`);
expect(editableRanges(state)).toHaveLength(1);
expect(state.sliceDoc(editableRanges(state)[0]!.from, editableRanges(state)[0]!.to)).toBe(
value,
);
});
test("leaves a long value alone when nothing can name it", () => {
// Rule 1 would take this on a stalling line. Editing it is plausible, so it stays.
const doc = `{"blob":"${"Z".repeat(COLLAPSE_TOKEN_CHARS * 3)}"}`;
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
expect(editableRanges(jsonState(doc))).toEqual([]);
});
test("never cuts at the column limit, which would strand text out of reach", () => {
// Rule 3 territory: a minified body with no value big enough to collapse on its own
const doc = `[${Array.from({ length: 4_000 }, (_, i) => `"word-${i}"`).join(",")}]`;
expect(doc.length).toBeGreaterThan(MAX_VISIBLE_LINE_CHARS);
expect(editableRanges(jsonState(doc))).toEqual([]);
// The read-only rules still cut it
expect(collapsedRanges(jsonState(doc))).toHaveLength(1);
});
test("never swallows a template tag, collapsing only the run beside it", () => {
// A brace is not in the base64 alphabet, so the run starts after the tag and the tag stays
// on screen where it can still be read and edited
const tag = "{{ image }}";
const doc = `{"avatar":"${tag}${png(3_000)}"}`;
const ranges = editableRanges(jsonState(doc));
expect(ranges).toHaveLength(1);
expect(ranges[0]!.from).toBe(doc.indexOf(tag) + tag.length);
expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).not.toContain("{");
});
test("finds a data URI whole, header and all", () => {
const value = `data:image/jpeg;base64,${png(3_000)}`;
const doc = `{"avatar":"${value}"}`;
const ranges = editableRanges(jsonState(doc));
expect(ranges).toHaveLength(1);
expect(doc.slice(ranges[0]!.from, ranges[0]!.to)).toBe(value);
});
test("hides no line break, so the plugin may provide it from the viewport", () => {
const state = jsonState(`{\n "a": "${png(3_000)}",\n "b": 1\n}`);
for (const { from, to } of editableRanges(state)) {
expect(state.sliceDoc(from, to)).not.toContain("\n");
}
});
});
describe("templated fields, where the grammar is an overlay", () => {
// Every editable field mixes its language with twig, which mounts the base language as an
// overlay. Overlays are not traversed by `Tree.iterate`, so a rule that walks the tree sees
// one enormous Text node and finds nothing. Rule 2 reads the text instead, and this is the
// case that has to keep working: an image pasted into a JSON request body.
const twigState = (doc: string) =>
EditorState.create({
doc,
extensions: [
twig({
base: jsonc(),
environmentVariables: [],
completionOptions: [],
onClickVariable: () => {},
onClickMissingVariable: () => {},
onClickPathParameter: () => {},
extraExtensions: [],
}),
largeValues,
],
});
function png(bytes: number) {
const b = new Uint8Array(bytes);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let binary = "";
for (const byte of b) binary += String.fromCharCode(byte);
return btoa(binary);
}
test("collapses a pasted image in a templated body", () => {
const value = png(3_000);
const state = twigState(`{\n "avatar": "${value}"\n}`);
const ranges: { from: number; to: number }[] = [];
const iter = collapseDecorations(state, [{ from: 0, to: state.doc.length }], "media").iter();
while (iter.value != null) {
ranges.push({ from: iter.from, to: iter.to });
iter.next();
}
expect(ranges).toHaveLength(1);
expect(state.sliceDoc(ranges[0]!.from, ranges[0]!.to)).toBe(value);
});
});
describe("viewport scoping", () => {
const doc = () => {
const value = "A".repeat(COLLAPSE_TOKEN_CHARS * 2);
return `{\n${["a", "b", "c"].map((k) => ` "${k}": "${value}"`).join(",\n")}\n}`;
};
test("decorates only the ranges it is given", () => {
const state = jsonState(doc());
const secondLine = state.doc.line(3);
const all = collapseDecorations(state, [{ from: 0, to: state.doc.length }]);
const one = collapseDecorations(state, [{ from: secondLine.from, to: secondLine.to }]);
expect(all.size).toBe(3);
expect(one.size).toBe(1);
});
test("covers a line the range only partly overlaps", () => {
// The viewport can start mid-line; the collapse still has to span the whole value
const state = jsonState(doc());
const line = state.doc.line(2);
const partial = collapseDecorations(state, [{ from: line.from + 5, to: line.from + 6 }]);
expect(partial.size).toBe(1);
const iter = partial.iter();
expect(iter.to).toBeGreaterThan(line.from + 6);
});
});
@@ -0,0 +1,584 @@
import { ensureSyntaxTree, syntaxTree } from "@codemirror/language";
import { formatSize } from "@yaakapp-internal/lib/formatSize";
import type { EditorState, Extension, Range } from "@codemirror/state";
import type { Tree as SyntaxTree } from "@lezer/common";
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
import { fireAndForget } from "../../../lib/fireAndForget";
import type { SniffedValue } from "./sniffValue";
import { isEncodedRun, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
/**
* How much of a line may be rendered before the rest is collapsed.
*
* VS Code draws nothing past column 10,000 (`editor.stopRenderingLineAfter`) for the same
* reason. It can afford to be blunt about it because it doesn't soft wrap by default; we
* collapse to a placeholder that can be opened instead.
*/
export const MAX_VISIBLE_LINE_CHARS = 10_000;
/**
* A quoted value longer than this is collapsed whole, whatever it turns out to hold. Long
* enough that nothing a person might actually read is ever hidden on length alone.
*/
export const COLLAPSE_TOKEN_CHARS = 5_000;
/**
* A quoted value longer than this is collapsed too, but only when {@link sniffValue} can say
* what it is. Encoded media is never worth reading, so once we can name it and offer a viewer,
* a tag beats a wall of base64 well below the length that would make it a rendering problem —
* this covers the avatars and icons that make up most base64 in real payloads. Anything we
* can't identify stays visible until it's big enough to be a problem on its own.
*/
export const COLLAPSE_MEDIA_CHARS = 1_000;
/**
* Collapses what can't be read: over-long lines, which stall layout, and encoded media, which
* is only noise on screen.
*
* Layout cost tracks the length of the longest line, not the size of the document. A 1 MB
* response of ordinary multi-line JSON renders fine, while the same 1 MB on a single line stalls
* the UI, because soft wrap has to measure the whole line end to end to find its break points.
* Measured in WKWebView, the engine macOS ships: 221 ms per render pass at 1 MB and 968 ms at
* 3 MB, against 51 ms and 138 ms once collapsed, with soft wrap left on.
*
* Three rules:
*
* 1. Collapse quoted values over {@link COLLAPSE_TOKEN_CHARS} whole, so a base64 string reads
* as `"image": "PNG · 999.8 KB"` with its key intact. A minified body with several large
* values keeps every one of its keys. Needs a grammar to find the value.
* 2. Collapse quoted values over {@link COLLAPSE_MEDIA_CHARS} that sniff as a known format,
* which is what catches an ordinary embedded image long before it is a rendering problem.
* 3. On a line still over {@link MAX_VISIBLE_LINE_CHARS}, collapse whatever is left past the
* column limit. This needs no grammar, so it covers plain text, undelimited tokens, and any
* line that is simply long.
*
* Only what the viewport covers is examined, and only lines long enough to hold a collapse are
* looked at, so a document of ordinary short lines costs a length check per visible line and
* nothing else — no grammar is consulted and no tree is walked.
*
* Nothing leaves the document. Copy, filter and save all still see the full text, and the tag
* itself is a button, opening a menu that views, copies or saves exactly what it stands for.
*
* The value's own head says what it is — a `data:` URI names its media type, and raw base64
* gives its format up in the first dozen bytes — so the tag can name the format and open the
* value in the matching viewer without any of it being read. See {@link sniffValue}.
*
* An editable document gets rule 2 alone, as {@link mediaValues}. Encoded media is never typed
* by hand: it arrives pasted, and the only edit anyone makes to it is replacing it wholesale,
* which the tag already supports — {@link EditorView.atomicRanges} makes it delete as one unit.
* So collapsing it hides nothing anyone was going to read. The other two rules stay out, since
* they hide text by length alone, without being able to say what it is: under rule 3 a minified
* body would become uneditable past the column limit, which really would be editing text you
* can't see.
*/
/**
* Which of the three rules to apply.
*
* `media` is the subset safe for a document being edited: collapse only what we can name.
*/
export type CollapseRules = "all" | "media";
/**
* A hidden range, the value it belongs to, and what that value turned out to be.
*
* The hidden range and the value differ under the column rule, where the value starts at the
* beginning of the line but only what runs past the limit is hidden. The tag stands in for
* [from, to) and copies exactly that; the viewer needs the whole value, [valueFrom, to).
*/
interface Collapse {
from: number;
to: number;
valueFrom: number;
sniffed: SniffedValue | null;
}
/**
* Lucide's `chevron-down`, inlined because the widget builds its DOM synchronously and
* rendering React here would leave it empty while CodeMirror measures line heights.
*/
const CHEVRON_DOWN =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" ' +
'stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ' +
'aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';
/**
* Marks the tag as the thing that opens the menu.
*
* The whole tag is the target rather than the chevron alone: there is only one action, and a
* label you can't click next to a hit area a few pixels wide is a worse button than the tag
* itself. The chevron stays as the affordance that says so.
*
* A span with a role rather than a real button, because a button's box model makes the line
* taller — the one thing this extension exists to keep from happening.
*/
function makeTagButton(el: HTMLElement, title: string, onOpen: () => void) {
el.role = "button";
el.ariaHasPopup = "menu";
el.tabIndex = 0;
el.title = title;
el.ariaLabel = title;
// Keep the editor from moving the cursor when the tag is pressed
el.addEventListener("mousedown", (e) => {
e.preventDefault();
e.stopPropagation();
});
el.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
onOpen();
});
el.addEventListener("keydown", (e) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
e.stopPropagation();
onOpen();
});
}
class LargeValueWidget extends WidgetType {
constructor(
private readonly from: number,
private readonly to: number,
private readonly valueFrom: number,
private readonly sniffed: SniffedValue | null,
) {
super();
}
eq(other: LargeValueWidget) {
return (
other.from === this.from &&
other.to === this.to &&
other.valueFrom === this.valueFrom &&
other.sniffed?.mime === this.sniffed?.mime
);
}
toDOM(view: EditorView) {
const length = this.to - this.from;
const el = document.createElement("span");
// The same neutral tag styling a path parameter uses. The theme scope matters: these
// tokens resolve against the tag palette, not the editor's ambient one, where a
// tag-sized border is meant to be near invisible.
el.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
const thumbnail = buildThumbnail(view, this.valueFrom, this.to, this.sniffed);
if (thumbnail != null) {
el.appendChild(thumbnail);
}
const label = document.createElement("span");
// A named type reads as a stand-in for the value, so it only needs to say what and how big.
// Without a name there is nothing to show but the fact that something is missing.
label.textContent =
this.sniffed == null
? `${formatSize(length)} hidden…`
: `${this.sniffed.label} · ${formatSize(length)}`;
el.appendChild(label);
const chevron = document.createElement("span");
chevron.className = "tag-action";
chevron.ariaHidden = "true";
chevron.innerHTML = CHEVRON_DOWN;
el.appendChild(chevron);
makeTagButton(
el,
this.sniffed == null ? "Value actions" : `${this.sniffed.label} actions`,
() => fireAndForget(this.openMenu(view, el)),
);
return el;
}
/**
* Copy, view and save, in a menu opened against the button.
*
* Everything the menu needs is pulled in on click. The editor loads on every response, and
* neither the React menu nor the viewers behind the dialog are worth carrying until someone
* asks for them.
*/
private async openMenu(view: EditorView, tag: HTMLElement) {
const [{ toggleContextMenu }, { showLargeValueDialog }, { encodingLabel, largeValueActions }] =
await Promise.all([
import("../../../lib/contextMenu"),
import("../../LargeValueDialog"),
import("../../../lib/largeValue"),
]);
const { sniffed } = this;
// Sliced when an action runs rather than now, so opening the menu never touches the value
const value = () => view.state.sliceDoc(this.valueFrom, this.to);
const head = view.state.sliceDoc(
this.valueFrom,
Math.min(this.valueFrom + SNIFF_HEAD_CHARS, this.to),
);
// The whole tag, so the menu can align to whichever edge has room beside it
const rect = tag.getBoundingClientRect();
toggleContextMenu({
id: "large-value",
triggerPosition: { x: rect.left, y: rect.bottom },
triggerRect: rect,
triggerEl: tag,
items: [
{ type: "separator", label: encodingLabel(head, sniffed, this.to - this.from) },
...largeValueActions({
value,
sniffed,
// Exactly what the tag stands in for, which under the column rule is only the tail
copyText: () => view.state.sliceDoc(this.from, this.to),
onView: () => showLargeValueDialog({ text: value(), sniffed }),
}),
],
});
}
ignoreEvent() {
return false;
}
}
/**
* How much encoded image a tag will preview. Roughly 3 MB of file, base64 being 4 bytes to 3.
*
* The decode runs on WebKit's image thread, but the bitmap it produces is sized by the image's
* own dimensions, not by the box we draw it in — a 4000×3000 photo costs 48 MB however small the
* thumbnail. So there has to be a limit, even though encoded size is only a proxy for the one
* that matters: a well-compressed photo can decode larger than a lossless screenshot twice its
* file size. This is set high enough to cover ordinary screenshots and wallpapers, since a tag
* that previews some images and not others is worse than one that previews none. Only widgets in
* the viewport are ever built, so a screenful is the most that decode at once.
*/
const THUMBNAIL_MAX_CHARS = 4_000_000;
/**
* A preview of the value, at a size fixed before it loads.
*
* The box is a fixed square from the moment it's inserted, so the image arriving never changes
* the line's height or the tag's width. A growing line box is the layout cost this whole
* extension exists to avoid, and an image of unknown dimensions is the classic way to cause one.
*
* The src is the value's own text handed straight to the decoder as a data URI. Decoding the
* base64 ourselves first would mean walking megabytes on the main thread, which is the one
* thing that must not happen here.
*/
function buildThumbnail(
view: EditorView,
valueFrom: number,
to: number,
sniffed: SniffedValue | null,
): HTMLElement | null {
if (sniffed == null || !sniffed.mime.startsWith("image/")) return null;
if (sniffed.encoding !== "base64") return null;
if (to - valueFrom > THUMBNAIL_MAX_CHARS) return null;
const img = document.createElement("img");
img.className = "tag-thumbnail";
img.alt = "";
img.ariaHidden = "true";
img.decoding = "async";
// A magic number can be wrong, and a data URI can lie. Drop the box rather than leave a
// broken-image glyph sitting in the tag.
img.addEventListener("error", () => img.remove());
// Read the document and build the data URI after the widget is measured and on screen
requestAnimationFrame(() => {
if (!img.isConnected) return;
const payload = view.state.sliceDoc(valueFrom + sniffed.offset, to);
img.src = `data:${sniffed.mime};base64,${payload}`;
});
return img;
}
interface Line {
from: number;
to: number;
}
/**
* The lines the given ranges touch, in document order and each listed once.
*
* Only lines long enough to hold a collapse are returned. That check is what keeps this free on
* ordinary documents: no grammar is consulted and no tree is walked for a screen of short lines.
*/
function collapsibleLines(state: EditorState, ranges: readonly { from: number; to: number }[]) {
const lines: Line[] = [];
let lastFrom = -1;
for (const range of ranges) {
let pos = range.from;
for (;;) {
const line = state.doc.lineAt(pos);
if (line.from > lastFrom) {
lastFrom = line.from;
if (line.to - line.from >= COLLAPSE_MEDIA_CHARS) {
lines.push({ from: line.from, to: line.to });
}
}
if (line.to >= range.to) break;
pos = line.to + 1;
}
}
return lines;
}
/**
* How long to spend parsing before falling back to the column rule.
*
* The initial parse is budgeted by time, so it stops partway through a document with several
* large values, and we'd only find the first one. Parsing the rest of a 1 MB body costs about
* 12 ms, against the 171 ms of layout it saves.
*/
const PARSE_TIMEOUT_MS = 100;
/**
* The parsed tree covering these lines.
*
* Parsing is only ever forced for a line long enough to stall layout, where finding the values
* inside it is what saves the frame. For everything else we take whatever the background parse
* has reached and decorate again when it advances — a forced parse runs from the top of the
* document, so making one happen on every scroll would cost far more than the tags are worth.
*/
function treeForLines(state: EditorState, lines: Line[]): SyntaxTree {
const last = lines[lines.length - 1];
const stalls = lines.some((l) => l.to - l.from > MAX_VISIBLE_LINE_CHARS);
if (last == null || !stalls) {
return syntaxTree(state);
}
return ensureSyntaxTree(state, last.to, PARSE_TIMEOUT_MS) ?? syntaxTree(state);
}
const QUOTES = ['"', "'", "`"];
/** A run of base64, long enough to be worth looking at. Nothing else can be encoded media. */
const ENCODED_RUN = new RegExp(`[A-Za-z0-9+/=_-]{${COLLAPSE_MEDIA_CHARS},}`, "g");
/** The header that turns a bare run into a data URI, when one sits right before it. */
const DATA_URI_HEAD = /data:[^,;\s"']*(?:;[^,;\s"']*)*;base64,$/;
/**
* Encoded media on this line, found by reading the text rather than the grammar.
*
* Deliberately grammar-free. Every editable field in the app mixes its language with the twig
* parser, which mounts the base language as an *overlay*, and overlays are not traversed by
* `Tree.iterate` — so a rule that walks the tree finds one enormous `Text` node and nothing
* inside it. Rule 2 has to work in those fields above all, since that is where images get
* pasted, and a run of base64 is a text pattern anyway: no grammar can describe it better than
* the alphabet does.
*
* A `data:` header is picked up by looking backwards from the run, so the whole URI collapses
* as one thing and its declared type is what names it.
*/
function findMediaRuns(state: EditorState, line: Line): Collapse[] {
const text = state.sliceDoc(line.from, line.to);
const collapses: Collapse[] = [];
ENCODED_RUN.lastIndex = 0;
for (let m = ENCODED_RUN.exec(text); m != null; m = ENCODED_RUN.exec(text)) {
let start = m.index;
// A data URI's own header breaks the alphabet, so the run starts after it
const header = DATA_URI_HEAD.exec(text.slice(0, start));
if (header != null) {
start = header.index;
}
const valueFrom = line.from + start;
const valueTo = line.from + m.index + m[0].length;
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
const sniffed = sniffValue(head);
// Only hide what we can name. A short magic number matches by chance now and then, so the
// run itself has to look encoded too.
if (sniffed != null && sniffed.encoding === "base64" && isEncodedRun(head, sniffed.offset)) {
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed });
}
}
return collapses;
}
/**
* Quoted values big enough to be a rendering problem on their own, whatever they hold.
*
* Needs the grammar, to find where the value starts and ends, and so only runs for a document
* nobody is editing — which is also the only place this rule applies.
*/
function findLargeTokens(state: EditorState, tree: SyntaxTree, line: Line): Collapse[] {
const collapses: Collapse[] = [];
tree.iterate({
from: line.from,
to: line.to,
enter: (node) => {
// A node this small can't contain anything worth collapsing, and neither can its children
if (node.to - node.from < COLLAPSE_TOKEN_CHARS) return false;
// Only leaves, so we collapse the string itself rather than the object holding it
if (node.node.firstChild != null) return true;
const from = Math.max(node.from, line.from);
const to = Math.min(node.to, line.to);
const open = state.sliceDoc(from, from + 1);
const quoted = to - from >= 2 && QUOTES.includes(open) && state.sliceDoc(to - 1, to) === open;
if (!quoted) return false;
// Everything inside the quotes. The whole value is hidden, so it is its own value range
const valueFrom = from + 1;
const valueTo = to - 1;
if (valueTo - valueFrom < COLLAPSE_TOKEN_CHARS) return false;
const head = state.sliceDoc(valueFrom, Math.min(valueFrom + SNIFF_HEAD_CHARS, valueTo));
collapses.push({ from: valueFrom, to: valueTo, valueFrom, sniffed: sniffValue(head) });
return false;
},
});
return collapses;
}
/**
* Both rules' findings as one list, in document order, with nothing overlapping.
*
* A big quoted base64 value satisfies both rules, and two decorations over the same text is an
* error. The token wins where they collide, because its range stops at the quotes and so leaves
* the structure around it readable.
*/
function mergeCollapses(tokens: Collapse[], runs: Collapse[]): Collapse[] {
const merged = [...tokens];
for (const run of runs) {
if (!tokens.some((t) => run.from < t.to && t.from < run.to)) {
merged.push(run);
}
}
return merged.sort((a, b) => a.from - b.from);
}
/**
* Where the line runs past the column limit, counting only what is still visible after the
* token collapses, or -1 if it fits.
*/
function findColumnCut(line: Line, tokens: Collapse[]): number {
let visible = 0;
let pos = line.from;
for (const token of [...tokens, null]) {
const segmentEnd = token == null ? line.to : token.from;
if (segmentEnd > pos) {
if (visible + (segmentEnd - pos) > MAX_VISIBLE_LINE_CHARS) {
return pos + (MAX_VISIBLE_LINE_CHARS - visible);
}
visible += segmentEnd - pos;
}
if (token != null) {
pos = token.to;
}
}
return -1;
}
function collapsesForLine(
state: EditorState,
tree: SyntaxTree,
line: Line,
rules: CollapseRules,
): Collapse[] {
const runs = findMediaRuns(state, line);
// Length alone only hides things on a line that would stall without it, and never in a
// document being edited, where hiding text by size would put it out of reach
const stalls = rules === "all" && line.to - line.from > MAX_VISIBLE_LINE_CHARS;
const tokens = mergeCollapses(stalls ? findLargeTokens(state, tree, line) : [], runs);
// Short enough to render, so there is nothing left to cut
if (!stalls) {
return tokens;
}
const cut = findColumnCut(line, tokens);
if (cut < 0) {
return tokens;
}
// The cut always lands in a visible stretch, so it never splits a token collapse
const kept = tokens.filter((t) => t.to <= cut);
// Only the tail is hidden, but the value it belongs to runs from the start of the line — a
// body that is nothing but one base64 blob is still recognisable from there
const head = state.sliceDoc(line.from, Math.min(line.from + SNIFF_HEAD_CHARS, line.to));
kept.push({ from: cut, to: line.to, valueFrom: line.from, sniffed: sniffValue(head) });
return kept;
}
/**
* The collapses covering the given document ranges.
*
* Kept separate from the plugin so the rules can be exercised against a state directly, with no
* view and no DOM.
*/
export function collapseDecorations(
state: EditorState,
ranges: readonly { from: number; to: number }[],
rules: CollapseRules = "all",
): DecorationSet {
const lines = collapsibleLines(state, ranges);
if (lines.length === 0) {
return Decoration.none;
}
const tree = treeForLines(state, lines);
const decorations: Range<Decoration>[] = [];
for (const line of lines) {
for (const { from, to, valueFrom, sniffed } of collapsesForLine(state, tree, line, rules)) {
const widget = new LargeValueWidget(from, to, valueFrom, sniffed);
decorations.push(Decoration.replace({ widget }).range(from, to));
}
}
return Decoration.set(decorations);
}
/**
* Scoped to the viewport, so the cost is a screenful however big the document is.
*
* Recomputed when the document changes, when the viewport moves, and when background parsing
* advances — a value can only be found once the grammar has reached it.
*/
function collapsePlugin(rules: CollapseRules) {
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = collapseDecorations(view.state, view.visibleRanges, rules);
}
update(update: ViewUpdate) {
if (
update.docChanged ||
update.viewportChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)
) {
this.decorations = collapseDecorations(
update.view.state,
update.view.visibleRanges,
rules,
);
}
}
},
{
decorations: (v) => v.decorations,
// Step the cursor over a placeholder instead of stranding it inside, and delete it whole
provide: (plugin) =>
EditorView.atomicRanges.of((view) => view.plugin(plugin)?.decorations ?? Decoration.none),
},
);
}
/** All three rules. For a document nobody is editing. */
export const largeValues: Extension = [collapsePlugin("all")];
/** Only what we can name, which is the part that is safe to hide while someone is editing. */
export const mediaValues: Extension = [collapsePlugin("media")];
@@ -0,0 +1,170 @@
import { describe, expect, test } from "vite-plus/test";
import { decodeBase64Prefix, labelForMime, SNIFF_HEAD_CHARS, sniffValue } from "./sniffValue";
/** A base64 value that starts with `magic` and runs on for a while, as a real one would */
function base64Of(magic: number[], length = 2_000): string {
const bytes = new Uint8Array(length);
bytes.set(magic);
for (let i = magic.length; i < length; i++) {
bytes[i] = i % 251;
}
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
/** One character, one byte. These are all ASCII signatures. */
function ascii(s: string): number[] {
const bytes: number[] = [];
for (let i = 0; i < s.length; i++) {
bytes.push(s.charCodeAt(i));
}
return bytes;
}
/** Four bytes no signature looks at: a RIFF chunk length, or an ISO-BMFF box size */
const IGNORED = [0x00, 0x00, 0x00, 0x20];
const PNG = [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a];
const JPEG = [0xff, 0xd8, 0xff, 0xe0];
const PDF = ascii("%PDF-1.7");
describe("magic numbers", () => {
const cases: [string, number[], string, string][] = [
["PNG", PNG, "image/png", "PNG"],
["JPEG", JPEG, "image/jpeg", "JPEG"],
["GIF", ascii("GIF89a"), "image/gif", "GIF"],
["PDF", PDF, "application/pdf", "PDF"],
["WEBP", [...ascii("RIFF"), ...IGNORED, ...ascii("WEBP")], "image/webp", "WEBP"],
["WAV", [...ascii("RIFF"), ...IGNORED, ...ascii("WAVE")], "audio/wav", "WAV"],
["ZIP", [...ascii("PK"), 0x03, 0x04], "application/zip", "ZIP"],
["GZIP", [0x1f, 0x8b, 0x08], "application/gzip", "GZIP"],
["MP3", [...ascii("ID3"), 0x04], "audio/mpeg", "MP3"],
["OGG", ascii("OggS"), "audio/ogg", "OGG"],
["FLAC", ascii("fLaC"), "audio/flac", "FLAC"],
["WEBM", [0x1a, 0x45, 0xdf, 0xa3], "video/webm", "WEBM"],
["MP4", [...IGNORED, ...ascii("ftypisom")], "video/mp4", "MP4"],
["AVIF", [...IGNORED, ...ascii("ftypavif")], "image/avif", "AVIF"],
["HEIC", [...IGNORED, ...ascii("ftypheic")], "image/heic", "HEIC"],
["M4A", [...IGNORED, ...ascii("ftypM4A ")], "audio/mp4", "M4A"],
["MOV", [...IGNORED, ...ascii("ftypqt ")], "video/quicktime", "MOV"],
["BMP", ascii("BMxx"), "image/bmp", "BMP"],
["TIFF", [...ascii("II"), 0x2a, 0x00], "image/tiff", "TIFF"],
];
for (const [name, magic, mime, label] of cases) {
test(`recognises ${name}`, () => {
expect(sniffValue(base64Of(magic))).toEqual({ mime, label, offset: 0, encoding: "base64" });
});
}
test("recognises nothing in arbitrary base64", () => {
expect(sniffValue(btoa("just some text that happens to be encoded"))).toBeNull();
});
test("recognises nothing in text that isn't base64", () => {
expect(sniffValue("the quick brown fox jumps over the lazy dog".repeat(10))).toBeNull();
});
test("recognises nothing in a JWT-shaped value", () => {
expect(sniffValue(`${btoa('{"alg":"HS256"}')}.${btoa('{"sub":"1"}')}.c2ln`)).toBeNull();
});
test("reads the url-safe alphabet", () => {
const urlSafe = base64Of(PNG).replaceAll("+", "-").replaceAll("/", "_");
expect(sniffValue(urlSafe)?.label).toBe("PNG");
});
test("ignores leading whitespace, and counts it in the offset", () => {
expect(sniffValue(` ${base64Of(PNG)}`)).toMatchObject({ label: "PNG", offset: 3 });
});
});
describe("data URIs", () => {
test("takes the declared type over the bytes", () => {
const uri = `data:image/svg+xml;base64,${btoa("<svg/>")}`;
expect(sniffValue(uri)).toEqual({
mime: "image/svg+xml",
label: "SVG",
offset: "data:image/svg+xml;base64,".length,
encoding: "base64",
});
});
test("points past the header, so the payload can be decoded from there", () => {
const payload = base64Of(PNG);
const uri = `data:image/png;base64,${payload}`;
const sniffed = sniffValue(uri)!;
expect(uri.slice(sniffed.offset, sniffed.offset + 8)).toBe(payload.slice(0, 8));
});
test("falls back to the bytes when the declared type says nothing", () => {
const uri = `data:application/octet-stream;base64,${base64Of(PDF)}`;
expect(sniffValue(uri)).toMatchObject({ mime: "application/pdf", label: "PDF" });
});
test("handles a header with no type at all", () => {
expect(sniffValue(`data:;base64,${base64Of(JPEG)}`)).toMatchObject({ mime: "image/jpeg" });
});
test("handles extra parameters", () => {
const uri = `data:text/plain;charset=utf-8;base64,${btoa("hello")}`;
expect(sniffValue(uri)).toMatchObject({ mime: "text/plain", encoding: "base64" });
});
test("handles a percent-encoded payload", () => {
expect(sniffValue("data:text/html,%3Ch1%3Ehi%3C/h1%3E")).toEqual({
mime: "text/html",
label: "HTML",
offset: "data:text/html,".length,
encoding: "percent",
});
});
test("defaults a bare percent-encoded payload to text", () => {
expect(sniffValue("data:,hello%20there")).toMatchObject({
mime: "text/plain",
encoding: "percent",
});
});
});
describe("decoding only the head", () => {
test("reads no more of the value than the bytes asked for", () => {
const big = base64Of(PNG, 3_000_000);
// Truncating to the head must not change what is found, which is the whole point:
// callers only ever hand it a slice
expect(sniffValue(big.slice(0, SNIFF_HEAD_CHARS))).toEqual(sniffValue(big));
});
test("decodes whole 4-character groups only", () => {
// 12 bytes needs 16 characters; a 15-character slice yields the 12 bytes of 3 whole groups
expect(decodeBase64Prefix(base64Of(PNG), 0, 12)).toHaveLength(12);
expect(decodeBase64Prefix(base64Of(PNG).slice(0, 15), 0, 12)).toHaveLength(9);
});
test("declines a value too short to hold a group", () => {
expect(decodeBase64Prefix("abc", 0, 12)).toBeNull();
});
test("declines text outside the alphabet", () => {
expect(decodeBase64Prefix("hello world, not base64!", 0, 12)).toBeNull();
});
});
describe("labels", () => {
test.each([
["image/png", "PNG"],
["image/svg+xml", "SVG"],
["text/plain", "TEXT"],
["application/vnd.ms-excel", "MS-EXCEL"],
["image/x-icon", "ICON"],
["application/octet-stream", "APPLICATION"],
["font/woff2", "WOFF2"],
["application/vnd.openxmlformats-officedocument.wordprocessingml.document", "DOCUMENT"],
])("%s reads as %s", (mime, label) => {
expect(labelForMime(mime)).toBe(label);
});
});
@@ -0,0 +1,219 @@
/**
* What a collapsed value turns out to be.
*
* Sniffing reads the head of the value only. A `data:` URI states its own media type, and raw
* base64 gives up its type in the first few bytes, so neither needs the rest — which is the
* point, since the whole reason these values are collapsed is that touching all of one costs
* enough to stall the UI.
*/
export interface SniffedValue {
/** Full media type, e.g. `image/png`. What the dialog routes on. */
mime: string;
/** A word for the tag, e.g. `PNG`. */
label: string;
/** Where the encoded payload starts in the value, past any `data:` header. */
offset: number;
encoding: "base64" | "percent";
}
/**
* How much of a value {@link sniffValue} needs. Enough for a `data:` header of realistic
* length, and far more than the 24 base64 characters the magic numbers are read from.
*/
export const SNIFF_HEAD_CHARS = 256;
/** Bytes the longest signature needs: `RIFF????WEBP`, and an ISO-BMFF brand at offset 8. */
const SNIFF_BYTES = 12;
/** `data:[<mime>][;<param>…],` — the payload follows the comma. */
const DATA_URI = /^data:([^,;]*)((?:;[^,;]*)*),/;
/** Media types that name no format, so the bytes are worth a look even when one is declared. */
const UNINFORMATIVE = ["", "application/octet-stream", "binary/octet-stream"];
interface Signature {
label: string;
mime: string;
offset?: number;
/** Byte values, where `null` matches anything */
magic: readonly (number | null)[];
}
/**
* A magic number written as the ASCII it reads as, with `?` for a byte that varies.
*
* Indexed rather than iterated, because a signature is a sequence of bytes: one character here
* is one byte, and splitting into code points would be the wrong unit for that.
*/
function ascii(pattern: string): (number | null)[] {
const bytes: (number | null)[] = [];
for (let i = 0; i < pattern.length; i++) {
bytes.push(pattern[i] === "?" ? null : pattern.charCodeAt(i));
}
return bytes;
}
const SIGNATURES: readonly Signature[] = [
{ label: "PNG", mime: "image/png", magic: [0x89, ...ascii("PNG"), 0x0d, 0x0a, 0x1a, 0x0a] },
{ label: "JPEG", mime: "image/jpeg", magic: [0xff, 0xd8, 0xff] },
{ label: "GIF", mime: "image/gif", magic: ascii("GIF8") },
{ label: "BMP", mime: "image/bmp", magic: ascii("BM") },
{ label: "WEBP", mime: "image/webp", magic: ascii("RIFF????WEBP") },
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("II"), 0x2a, 0x00] },
{ label: "TIFF", mime: "image/tiff", magic: [...ascii("MM"), 0x00, 0x2a] },
{ label: "ICO", mime: "image/x-icon", magic: [0x00, 0x00, 0x01, 0x00] },
{ label: "PDF", mime: "application/pdf", magic: ascii("%PDF-") },
{ label: "WAV", mime: "audio/wav", magic: ascii("RIFF????WAVE") },
{ label: "AVI", mime: "video/x-msvideo", magic: ascii("RIFF????AVI ") },
{ label: "MP3", mime: "audio/mpeg", magic: ascii("ID3") },
// A bare MPEG frame header: 11 sync bits, then a layer III / II / I version pair
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xfb] },
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf3] },
{ label: "MP3", mime: "audio/mpeg", magic: [0xff, 0xf2] },
{ label: "OGG", mime: "audio/ogg", magic: ascii("OggS") },
{ label: "FLAC", mime: "audio/flac", magic: ascii("fLaC") },
{ label: "WEBM", mime: "video/webm", magic: [0x1a, 0x45, 0xdf, 0xa3] },
{ label: "ZIP", mime: "application/zip", magic: [...ascii("PK"), 0x03, 0x04] },
{ label: "GZIP", mime: "application/gzip", magic: [0x1f, 0x8b] },
{ label: "7Z", mime: "application/x-7z-compressed", magic: [...ascii("7z"), 0xbc, 0xaf, 0x27] },
{ label: "RAR", mime: "application/vnd.rar", magic: ascii("Rar!") },
{ label: "GLTF", mime: "model/gltf-binary", magic: ascii("glTF") },
];
/**
* ISO base media files all start `????ftyp`, so the format is in the brand that follows rather
* than in the signature itself. Anything unlisted is some flavour of MP4.
*/
const ISO_BRANDS: Record<string, { mime: string; label: string }> = {
avif: { mime: "image/avif", label: "AVIF" },
avis: { mime: "image/avif", label: "AVIF" },
heic: { mime: "image/heic", label: "HEIC" },
heix: { mime: "image/heic", label: "HEIC" },
hevc: { mime: "image/heic", label: "HEIC" },
mif1: { mime: "image/heif", label: "HEIF" },
msf1: { mime: "image/heif", label: "HEIF" },
"M4A ": { mime: "audio/mp4", label: "M4A" },
"qt ": { mime: "video/quicktime", label: "MOV" },
};
function matches(bytes: Uint8Array, { magic, offset = 0 }: Signature): boolean {
if (bytes.length < offset + magic.length) return false;
return magic.every((b, i) => b == null || bytes[offset + i] === b);
}
function readAscii(bytes: Uint8Array, from: number, length: number): string {
return String.fromCharCode(...bytes.subarray(from, from + length));
}
/** The format the first bytes of a file identify it as, or null if they identify nothing. */
export function sniffBytes(bytes: Uint8Array): { mime: string; label: string } | null {
if (readAscii(bytes, 4, 4) === "ftyp") {
return ISO_BRANDS[readAscii(bytes, 8, 4)] ?? { mime: "video/mp4", label: "MP4" };
}
const sig = SIGNATURES.find((s) => matches(bytes, s));
return sig == null ? null : { mime: sig.mime, label: sig.label };
}
/**
* The first `bytes` bytes of a base64 payload, or null if it isn't base64 after all.
*
* Only the characters those bytes need are decoded. The trailing partial group is dropped
* rather than padded, since `atob` rejects a group of one or two characters outright and we
* have no use for the byte a three-character group would add.
*/
export function decodeBase64Prefix(text: string, offset: number, bytes: number): Uint8Array | null {
const wanted = Math.ceil(bytes / 3) * 4;
let b64 = text.slice(offset, offset + wanted);
b64 = b64.slice(0, b64.length - (b64.length % 4));
if (b64.length === 0) return null;
// The URL-safe alphabet stands for the same bytes
b64 = b64.replaceAll("-", "+").replaceAll("_", "/");
if (!/^[A-Za-z0-9+/]+$/.test(b64)) return null;
try {
const binary = atob(b64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
out[i] = binary.charCodeAt(i);
}
return out;
} catch {
return null;
}
}
/**
* What a value is, from its head alone, or null if nothing recognises it.
*
* `head` need only be the first {@link SNIFF_HEAD_CHARS} characters — pass more and the rest is
* ignored. The offsets in the result are relative to the start of the whole value.
*/
export function sniffValue(head: string): SniffedValue | null {
const lead = head.length - head.trimStart().length;
const value = head.slice(lead);
const dataUri = DATA_URI.exec(value);
if (dataUri != null) {
const declared = dataUri[1]!.toLowerCase();
const offset = lead + dataUri[0].length;
if (!dataUri[2]!.split(";").includes("base64")) {
// A percent-encoded payload states its own type or is text by definition
const mime = declared === "" ? "text/plain" : declared;
return { mime, label: labelForMime(mime), offset, encoding: "percent" };
}
if (!UNINFORMATIVE.includes(declared)) {
return { mime: declared, label: labelForMime(declared), offset, encoding: "base64" };
}
return sniffBase64At(head, offset);
}
return sniffBase64At(head, lead);
}
function sniffBase64At(head: string, offset: number): SniffedValue | null {
const bytes = decodeBase64Prefix(head, offset, SNIFF_BYTES);
const sniffed = bytes == null ? null : sniffBytes(bytes);
return sniffed == null ? null : { ...sniffed, offset, encoding: "base64" };
}
/** The shortest run of base64 that {@link isEncodedRun} will accept as convincing */
const ENCODED_RUN_CHARS = 64;
/**
* Whether a value is one unbroken run of base64, rather than text that merely opens like it.
*
* A magic number is only a few bytes, so short ones match by chance — `BM` is two, which comes
* up about once in every 65,000 values. That was harmless while a value had to be long enough
* to hurt rendering before anything was hidden, and the sniff only chose the label. It is not
* harmless when the sniff itself decides to hide something, so that rule asks for this as well:
* prose leaves the alphabet within a few characters, and an encoded blob never does.
*/
export function isEncodedRun(head: string, offset: number): boolean {
const run = head.slice(offset);
return run.length >= ENCODED_RUN_CHARS && /^[A-Za-z0-9+/\-_]+={0,2}$/.test(run);
}
/** Subtypes whose own name makes a poor label */
const LABEL_OVERRIDES: Record<string, string> = {
"text/plain": "TEXT",
};
/**
* A word short enough for a tag. The distinguishing part of a subtype is its last dotted
* segment before any `+suffix`, so `image/svg+xml` reads as SVG and `application/vnd.ms-excel`
* as MS-EXCEL. Anything still too long falls back to the top-level type.
*/
export function labelForMime(mime: string): string {
const override = LABEL_OVERRIDES[mime];
if (override != null) {
return override;
}
const [type, subtype] = mime.split("/");
const word = (subtype ?? "").split("+")[0]!.split(".").pop()!.replace(/^x-/, "");
if (word.length > 0 && word.length <= 10 && word !== "octet-stream") {
return word.toUpperCase();
}
return (type ?? mime).toUpperCase();
}
@@ -22,6 +22,7 @@ import { Button } from "./Button";
import type { DropdownItem } from "./Dropdown";
import { Dropdown } from "./Dropdown";
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";
@@ -63,6 +64,13 @@ export type InputProps = Pick<
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"];
@@ -115,6 +123,7 @@ function BaseInput({
readOnly,
required,
rightSlot,
showOptionsPicker,
size = "md",
stateKey,
tint,
@@ -156,6 +165,17 @@ function BaseInput({
[],
);
// 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;
@@ -350,12 +370,55 @@ function BaseInput({
onClick={() => setObscured((o) => !o)}
/>
)}
{pickerOptions.length > 0 && (
<OptionsPickerDropdown options={pickerOptions} onSelect={insertOption} />
)}
{rightSlot}
</HStack>
</div>
);
}
/**
* Chevron that opens the input's autocomplete options as a menu, for browsing values rather than
* typing them. Shows `label` but inserts `apply` when set, so a long value can be listed under a
* short name (e.g. a User-Agent).
*
* Skipped in the tab order: once focus is in the input, inline autocomplete offers the same
* options, so this would only add a stop between fields. Hidden when the nearest container is
* narrow and the input needs the width more — written as a `max` query so it stays visible for
* inputs that have no container ancestor at all.
*/
function OptionsPickerDropdown({
options,
onSelect,
}: {
options: GenericCompletion[];
onSelect: (value: string) => void;
}) {
const items = useMemo<DropdownItem[]>(
() =>
options.map((o) => ({
label: o.label,
onSelect: () => onSelect(typeof o.apply === "string" ? o.apply : o.label),
})),
[options, onSelect],
);
return (
<Dropdown items={items}>
<IconButton
tabIndex={-1}
iconSize="sm"
size="xs"
icon="chevron_down"
title="Show suggestions"
className="mr-0.5 h-auto! my-0.5 text-text-subtlest @max-[24rem]:hidden"
/>
</Dropdown>
);
}
function validateRequire(v: string) {
return v.length > 0;
}
+133 -27
View File
@@ -9,7 +9,6 @@ import {
useSensor,
useSensors,
} from "@dnd-kit/core";
import { basename } from "@tauri-apps/api/path";
import classNames from "classnames";
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { WrappedEnvironmentVariable } from "../../hooks/useEnvironmentVariables";
@@ -34,12 +33,22 @@ import { Input } from "./Input";
import { ensurePairId } from "./PairEditor.util";
import type { RadioDropdownItem } from "./RadioDropdown";
import { RadioDropdown } from "./RadioDropdown";
import { platform } from "@yaakapp-internal/platform";
export interface PairEditorHandle {
/**
* Focus a row's name field once it's able to take focus. Focus can't land immediately when the
* row isn't mounted yet or the editor is hidden — eg. sitting in a tab that's still becoming
* active — so this retries for up to ~1s. A newer focus request cancels a pending one.
*/
focusName(id: string): void;
/** Focus a row's value field. See {@link PairEditorHandle.focusName} for timing. */
focusValue(id: string): void;
}
/** ~1s at 60fps, plenty for a tab switch to land without spinning forever if it never does */
const MAX_FOCUS_ATTEMPTS = 60;
export type PairEditorProps = {
allowFileValues?: boolean;
allowMultilineValues?: boolean;
@@ -53,7 +62,7 @@ export type PairEditorProps = {
nameValidate?: InputProps["validate"];
noScroll?: boolean;
onChange: (pairs: PairWithId[]) => void;
pairs: Pair[];
pairs: EditablePair[];
stateKey: InputProps["stateKey"];
setRef?: (n: PairEditorHandle) => void;
valueAutocomplete?: (name: string) => GenericCompletionConfig | undefined;
@@ -72,13 +81,35 @@ export type Pair = {
contentType?: string;
filename?: string;
isFile?: boolean;
readOnlyName?: boolean;
};
export type PairWithId = Pair & {
id: string;
};
/**
* A pair as handed to the editor. Adds behaviour that only the editor cares about, so the plain
* `Pair` stays the shape that gets written to models.
*/
export type EditablePair = Pair & {
/**
* When set, name edits are held until the field blurs and then committed through this, instead
* of calling `onChange` on every keystroke. Return false to reject the new name, which reverts
* the field. For names that can't be written directly, like a URL path placeholder that lives
* in the URL itself.
*/
commitName?: (name: string) => boolean;
};
type EditablePairWithId = EditablePair & {
id: string;
};
/** Strip the editor-only fields, so they can never reach a model write */
function toPairData({ commitName: _commitName, ...pair }: EditablePairWithId): PairWithId {
return pair;
}
/** Max number of pairs to show before prompting the user to reveal the rest */
const MAX_INITIAL_PAIRS = 30;
@@ -106,8 +137,8 @@ export function PairEditor({
setRef,
}: PairEditorProps) {
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
const [isDragging, setIsDragging] = useState<PairWithId | null>(null);
const [pairs, setPairs] = useState<PairWithId[]>([]);
const [isDragging, setIsDragging] = useState<EditablePairWithId | null>(null);
const [pairs, setPairs] = useState<EditablePairWithId[]>([]);
const [showAll, toggleShowAll] = useToggle(false);
// NOTE: Use local force update key because we trigger an effect on forceUpdateKey change. If
// we simply pass forceUpdateKey to the editor, the data set by useEffect will be stale.
@@ -115,16 +146,38 @@ export function PairEditor({
const rowsRef = useRef<Record<string, RowHandle | null>>({});
const pendingFocusFrame = useRef<number | null>(null);
useEffect(
() => () => {
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
},
[],
);
const focusWhenReady = useCallback((id: string, field: "name" | "value") => {
if (pendingFocusFrame.current != null) cancelAnimationFrame(pendingFocusFrame.current);
let attemptsLeft = MAX_FOCUS_ATTEMPTS;
const attempt = () => {
pendingFocusFrame.current = null;
const row = rowsRef.current[id];
const landed = field === "name" ? row?.focusName() : row?.focusValue();
if (landed || --attemptsLeft <= 0) return;
pendingFocusFrame.current = requestAnimationFrame(attempt);
};
attempt();
}, []);
const handle = useMemo<PairEditorHandle>(
() => ({
focusName(id: string) {
rowsRef.current[id]?.focusName();
focusWhenReady(id, "name");
},
focusValue(id: string) {
rowsRef.current[id]?.focusValue();
focusWhenReady(id, "value");
},
}),
[],
[focusWhenReady],
);
const initPairEditorRow = useCallback(
@@ -147,7 +200,7 @@ export function PairEditor({
// oxlint-disable-next-line react-hooks/exhaustive-deps -- Only care about forceUpdateKey
useEffect(() => {
// Remove empty headers on initial render and ensure they all have valid ids (pairs didn't use to have IDs)
const newPairs: PairWithId[] = [];
const newPairs: EditablePairWithId[] = [];
for (let i = 0; i < originalPairs.length; i++) {
const p = originalPairs[i];
if (!p) continue; // Make TS happy
@@ -155,6 +208,21 @@ export function PairEditor({
newPairs.push(ensurePairId(p));
}
// When the reset holds the exact same rows (eg. renaming a URL path placeholder, which keeps
// every row id), swap the data in without rebuilding the row editors. Unfocused inputs re-seed
// themselves when `defaultValue` changes, and a rebuild would drop the user's focus and
// selection — like tabbing from a placeholder's name into its value.
const trailingPair = pairs[pairs.length - 1];
const sameRows =
trailingPair != null &&
isPairEmpty(trailingPair) &&
pairs.length === newPairs.length + 1 &&
newPairs.every((p, i) => p.id === pairs[i]?.id);
if (sameRows) {
setPairs([...newPairs, trailingPair]);
return;
}
// Add empty last pair if there is none
const lastPair = newPairs[newPairs.length - 1];
if (lastPair == null || !isPairEmpty(lastPair)) {
@@ -166,10 +234,10 @@ export function PairEditor({
}, [forceUpdateKey]);
const setPairsAndSave = useCallback(
(fn: (pairs: PairWithId[]) => PairWithId[]) => {
(fn: (pairs: EditablePairWithId[]) => EditablePairWithId[]) => {
setPairs((oldPairs) => {
const pairs = fn(oldPairs);
onChange(pairs);
onChange(pairs.map(toPairData));
return pairs;
});
},
@@ -177,7 +245,7 @@ export function PairEditor({
);
const handleChange = useCallback(
(pair: PairWithId) =>
(pair: EditablePairWithId) =>
setPairsAndSave((pairs) => pairs.map((p) => (pair.id !== p.id ? p : pair))),
[setPairsAndSave],
);
@@ -362,14 +430,14 @@ export function PairEditor({
type PairEditorRowProps = {
className?: string;
pair: PairWithId;
pair: EditablePairWithId;
forceFocusNamePairId?: string | null;
forceFocusValuePairId?: string | null;
onChange?: (pair: PairWithId) => void;
onDelete?: (pair: PairWithId, focusPrevious: boolean) => void;
onFocusName?: (pair: PairWithId) => void;
onFocusValue?: (pair: PairWithId) => void;
onSubmit?: (pair: PairWithId) => void;
onChange?: (pair: EditablePairWithId) => void;
onDelete?: (pair: EditablePairWithId, focusPrevious: boolean) => void;
onFocusName?: (pair: EditablePairWithId) => void;
onFocusValue?: (pair: EditablePairWithId) => void;
onSubmit?: (pair: EditablePairWithId) => void;
isLast?: boolean;
disabled?: boolean;
disableDrag?: boolean;
@@ -397,8 +465,8 @@ type PairEditorRowProps = {
>;
interface RowHandle {
focusName(): void;
focusValue(): void;
focusName(): boolean;
focusValue(): boolean;
}
export function PairEditorRow({
@@ -436,9 +504,11 @@ export function PairEditorRow({
const handle = useRef<RowHandle>({
focusName() {
nameInputRef.current?.focus();
return nameInputRef.current?.isFocused() ?? false;
},
focusValue() {
valueInputRef.current?.focus();
return valueInputRef.current?.isFocused() ?? false;
},
});
@@ -471,11 +541,37 @@ export function PairEditorRow({
[onChange, pair],
);
// The name being typed into a deferred-commit field, before it's committed or reverted
const pendingName = useRef<string | null>(null);
const handleChangeName = useMemo(
() => (name: string) => onChange?.({ ...pair, name }),
() => (name: string) => {
// Keep the edit local until commit. Writing on every keystroke would reset the editor from
// beneath the cursor, since the pairs are derived from the name being edited.
if (pair.commitName != null) pendingName.current = name;
else onChange?.({ ...pair, name });
},
[onChange, pair],
);
const revertName = useCallback(() => {
const nameInput = nameInputRef.current;
if (nameInput != null) {
const changes = { from: 0, to: nameInput.value().length, insert: pair.name };
nameInput.dispatch({ changes });
}
pendingName.current = null;
}, [pair.name]);
const handleBlurName = useCallback(() => {
if (pair.commitName == null) return;
const name = pendingName.current;
pendingName.current = null;
if (name == null || name === pair.name) return;
if (!pair.commitName(name)) revertName();
}, [pair, revertName]);
const handleChangeValueText = useMemo(
() => (value: string) => onChange?.({ ...pair, value, isFile: false }),
[onChange, pair],
@@ -551,6 +647,10 @@ export function PairEditorRow({
[setDraggableRef, setDroppableRef],
);
// Skip the trailing placeholder row. It exists to start a new pair, so a picker there would
// imply it's already a real row. `Input` handles the rest, including having no options to show.
const showOptionsPicker = !isLast;
return (
<div
ref={handleSetRef}
@@ -596,7 +696,7 @@ export function PairEditorRow({
stateKey={`name.${pair.id}.${stateKey}`}
disabled={disabled}
wrapLines={false}
readOnly={pair.readOnlyName || isDraggingGlobal}
readOnly={isDraggingGlobal}
size="sm"
required={!isLast && !!pair.enabled && !!pair.value}
validate={nameValidate}
@@ -606,12 +706,14 @@ export function PairEditorRow({
defaultValue={pair.name}
label="Name"
name={`name[${index}]`}
onBlur={handleBlurName}
onChange={handleChangeName}
onFocus={handleFocusName}
placeholder={namePlaceholder ?? "name"}
autocomplete={nameAutocomplete}
autocompleteVariables={nameAutocompleteVariables}
autocompleteFunctions={nameAutocompleteFunctions}
showOptionsPicker={showOptionsPicker}
/>
<div className="w-full grid grid-cols-[minmax(0,1fr)_auto] gap-1 items-center">
{pair.isFile ? (
@@ -656,6 +758,7 @@ export function PairEditorRow({
autocomplete={valueAutocomplete?.(pair.name)}
autocompleteFunctions={valueAutocompleteFunctions}
autocompleteVariables={valueAutocompleteVariablesFiltered}
showOptionsPicker={showOptionsPicker}
/>
)}
</div>
@@ -675,8 +778,10 @@ export function PairEditorRow({
<IconButton
iconSize="sm"
size="xs"
icon={isLast || disabled ? "empty" : "chevron_down"}
title="Select form data type"
// Ellipsis rather than a chevron: the name and value fields now carry chevrons for
// picking a suggestion, and this menu acts on the row instead of filling in a field.
icon={isLast || disabled ? "empty" : "ellipsis_vertical"}
title="More actions"
className="text-text-subtlest"
/>
</Dropdown>
@@ -746,7 +851,7 @@ function FileActionsDropdown({
leftSlot: <Icon icon="file_code" />,
onSelect: async () => {
console.log("PAIR", pair);
const defaultFilename = await basename(pair.value ?? "");
const defaultFilename = await platform.files.basename(pair.value ?? "");
const filename = await showPrompt({
id: "filename",
title: "Override Filename",
@@ -800,7 +905,8 @@ function FileActionsDropdown({
<IconButton
iconSize="sm"
size="xs"
icon="chevron_down"
// Matches the plain row menu, so the actions column reads the same for file and text rows
icon="ellipsis_vertical"
title="Select form data type"
className="text-text-subtlest"
/>
@@ -808,7 +914,7 @@ function FileActionsDropdown({
);
}
function emptyPair(): PairWithId {
function emptyPair(): EditablePairWithId {
return ensurePairId({ enabled: true, name: "", value: "" });
}
@@ -1,9 +1,10 @@
import { generateId } from "../../lib/generateId";
import type { Pair, PairWithId } from "./PairEditor";
export function ensurePairId(p: Pair): PairWithId {
// NOTE: Generic so callers keep whatever they passed in (eg. an EditablePair stays editable)
export function ensurePairId<T extends Pair>(p: T): T & PairWithId {
if (typeof p.id === "string") {
return p as PairWithId;
return p as T & PairWithId;
}
return { ...p, id: p.id ?? generateId() };
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { type } from "@tauri-apps/plugin-os";
import { platform } from "@yaakapp-internal/platform";
import { HStack } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { CSSProperties, ReactNode } from "react";
@@ -66,7 +66,7 @@ export function Select<T extends string>({
<Label htmlFor={id} visuallyHidden={hideLabel} className={labelClassName} help={help}>
{label}
</Label>
{type() === "macos" && !filterable ? (
{platform.osType() === "macos" && !filterable ? (
<HStack
space={2}
className={classNames(
@@ -1,26 +1,29 @@
import { convertFileSrc } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { platform } from "@yaakapp-internal/platform";
interface Props {
bodyPath?: string;
data?: Uint8Array;
mimeType?: string;
}
export function AudioViewer({ bodyPath, data }: Props) {
export function AudioViewer({ bodyPath, data, mimeType }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
setSrc(platform.files.url(bodyPath));
} else if (data) {
const blob = new Blob([new Uint8Array(data)], { type: "audio/mpeg" });
// The type matters here in a way it doesn't for an image: a media element goes by what
// the blob declares rather than sniffing it, so an Ogg labelled as MP3 won't play
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "audio/mpeg" });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
}, [bodyPath, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
return <audio className="w-full" controls src={src} />;
@@ -1,8 +1,8 @@
import { convertFileSrc } from "@tauri-apps/api/core";
import classNames from "classnames";
import { useEffect, useState } from "react";
import { platform } from "@yaakapp-internal/platform";
type Props = { className?: string } & (
type Props = { className?: string; mimeType?: string } & (
| {
bodyPath: string;
}
@@ -11,23 +11,23 @@ type Props = { className?: string } & (
}
);
export function ImageViewer({ className, ...props }: Props) {
export function ImageViewer({ className, mimeType, ...props }: Props) {
const [src, setSrc] = useState<string>();
const bodyPath = "bodyPath" in props ? props.bodyPath : null;
const data = "data" in props ? props.data : null;
useEffect(() => {
if (bodyPath != null) {
setSrc(convertFileSrc(bodyPath));
setSrc(platform.files.url(bodyPath));
} else if (data != null) {
const blob = new Blob([data], { type: "image/png" });
const blob = new Blob([data], { type: mimeType ?? "image/png" });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
}, [bodyPath, data, mimeType]);
return (
<img
@@ -1,12 +1,12 @@
import "react-pdf/dist/Page/TextLayer.css";
import "react-pdf/dist/Page/AnnotationLayer.css";
import { convertFileSrc } from "@tauri-apps/api/core";
import "./PdfViewer.css";
import type { PDFDocumentProxy } from "pdfjs-dist";
import { useEffect, useRef, useState } from "react";
import { useMemo, useRef, useState } from "react";
import { Document, Page } from "react-pdf";
import { useContainerSize } from "@yaakapp-internal/ui";
import { fireAndForget } from "../../lib/fireAndForget";
import { platform } from "@yaakapp-internal/platform";
fireAndForget(
import("react-pdf").then(({ pdfjs }) => {
@@ -30,26 +30,32 @@ const options = {
export function PdfViewer({ bodyPath, data }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [numPages, setNumPages] = useState<number>();
const [src, setSrc] = useState<string | { data: Uint8Array }>();
const { width: containerWidth } = useContainerSize(containerRef);
useEffect(() => {
// During render, not in an effect: an effect leaves the first paint with no file, and
// `Document` renders its "Failed to load PDF file" state for that frame before recovering
const src = useMemo(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
} else if (data) {
return platform.files.url(bodyPath);
}
if (data) {
// Create a copy to avoid "Buffer is already detached" errors
// This happens when the ArrayBuffer is transferred/detached elsewhere
const dataCopy = new Uint8Array(data);
setSrc({ data: dataCopy });
} else {
setSrc(undefined);
return { data: new Uint8Array(data) };
}
return undefined;
}, [bodyPath, data]);
const onDocumentLoadSuccess = ({ numPages: nextNumPages }: PDFDocumentProxy): void => {
setNumPages(nextNumPages);
};
// Nothing to show yet, rather than the failure state `Document` renders for an empty file
if (src == null) {
return null;
}
return (
<div ref={containerRef} className="w-full h-full overflow-y-auto">
<Document
@@ -1,26 +1,28 @@
import { convertFileSrc } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { platform } from "@yaakapp-internal/platform";
interface Props {
bodyPath?: string;
data?: Uint8Array;
mimeType?: string;
}
export function VideoViewer({ bodyPath, data }: Props) {
export function VideoViewer({ bodyPath, data, mimeType }: Props) {
const [src, setSrc] = useState<string>();
useEffect(() => {
if (bodyPath) {
setSrc(convertFileSrc(bodyPath));
setSrc(platform.files.url(bodyPath));
} else if (data) {
const blob = new Blob([new Uint8Array(data)], { type: "video/mp4" });
// As in AudioViewer: a media element trusts the declared type instead of sniffing
const blob = new Blob([new Uint8Array(data)], { type: mimeType ?? "video/mp4" });
const url = URL.createObjectURL(blob);
setSrc(url);
return () => URL.revokeObjectURL(url);
} else {
setSrc(undefined);
}
}, [bodyPath, data]);
}, [bodyPath, data, mimeType]);
// oxlint-disable-next-line jsx-a11y/media-has-caption
return <video className="w-full" controls src={src} />;
+8 -6
View File
@@ -1,5 +1,5 @@
// Listen for settings changes, the re-compute theme
import { listen } from "@tauri-apps/api/event";
import { platform } from "@yaakapp-internal/platform";
import type { ModelPayload } from "@yaakapp-internal/models";
import { fireAndForget } from "./lib/fireAndForget";
import { getSettings } from "./lib/settings";
@@ -8,10 +8,12 @@ function setFontSizeOnDocument(fontSize: number) {
document.documentElement.style.fontSize = `${fontSize}px`;
}
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
if (event.payload.model.model !== "settings") return;
setFontSizeOnDocument(event.payload.model.interfaceFontSize);
}).catch(console.error);
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
for (const payload of payloads) {
if (payload.change.type !== "upsert") continue;
if (payload.model.model !== "settings") continue;
setFontSizeOnDocument(payload.model.interfaceFontSize);
}
});
fireAndForget(getSettings().then((settings) => setFontSizeOnDocument(settings.interfaceFontSize)));
+8 -6
View File
@@ -1,5 +1,5 @@
// Listen for settings changes, the re-compute theme
import { listen } from "@tauri-apps/api/event";
import { platform } from "@yaakapp-internal/platform";
import type { ModelPayload, Settings } from "@yaakapp-internal/models";
import { fireAndForget } from "./lib/fireAndForget";
import { getSettings } from "./lib/settings";
@@ -12,10 +12,12 @@ function setFonts(settings: Settings) {
);
}
listen<ModelPayload>("model_write", async (event) => {
if (event.payload.change.type !== "upsert") return;
if (event.payload.model.model !== "settings") return;
setFonts(event.payload.model);
}).catch(console.error);
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
for (const payload of payloads) {
if (payload.change.type !== "upsert") continue;
if (payload.model.model !== "settings") continue;
setFonts(payload.model);
}
});
fireAndForget(getSettings().then((settings) => setFonts(settings)));
+24
View File
@@ -4,6 +4,7 @@ import {
websocketRequestsAtom,
} from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { selectAtom } from "jotai/utils";
export const allRequestsAtom = atom((get) => [
...get(httpRequestsAtom),
@@ -14,3 +15,26 @@ export const allRequestsAtom = atom((get) => [
export function useAllRequests() {
return useAtomValue(allRequestsAtom);
}
const stringArrayEqual = (a: string[], b: string[]) =>
a.length === b.length && a.every((v, i) => v === b[i]);
// Identity-stable derivations so subscribers don't recompute or re-render when
// unrelated request fields change (eg. every debounced edit of a request)
export const allRequestIdsAtom = selectAtom(
allRequestsAtom,
(requests) => requests.map((r) => r.id),
stringArrayEqual,
);
export const allRequestUrlsAtom = selectAtom(
allRequestsAtom,
(requests) => {
const urls = new Set<string>();
for (const r of requests) {
if (r.url) urls.add(r.url);
}
return Array.from(urls);
},
stringArrayEqual,
);
@@ -1,9 +1,9 @@
import { event } from "@tauri-apps/api";
import { platform } from "@yaakapp-internal/platform";
import { useFastMutation } from "./useFastMutation";
export function useCancelHttpResponse(id: string | null) {
return useFastMutation<void>({
mutationKey: ["cancel_http_response", id],
mutationFn: () => event.emit(`cancel_http_response_${id}`),
mutationFn: () => platform.emit(`cancel_http_response_${id}`),
});
}
@@ -3,13 +3,13 @@ import { InlineCode } from "@yaakapp-internal/ui";
import { showAlert } from "../lib/alert";
import { appInfo } from "../lib/appInfo";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
export function useCheckForUpdates() {
return useMutation({
mutationKey: ["check_for_updates"],
mutationFn: async () => {
const hasUpdate: boolean = await minPromiseMillis(invokeCmd("cmd_check_for_updates"), 500);
const hasUpdate: boolean = await minPromiseMillis(rpc("cmd_check_for_updates"), 500);
if (!hasUpdate) {
showAlert({
id: "no-updates",
@@ -1,4 +1,4 @@
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { useFastMutation } from "./useFastMutation";
export function useDeleteGrpcConnections(requestId?: string) {
@@ -6,7 +6,7 @@ export function useDeleteGrpcConnections(requestId?: string) {
mutationKey: ["delete_grpc_connections", requestId],
mutationFn: async () => {
if (requestId === undefined) return;
await invokeCmd("cmd_delete_all_grpc_connections", { requestId });
await rpc("cmd_delete_all_grpc_connections", { requestId });
},
});
}
@@ -1,4 +1,4 @@
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { useFastMutation } from "./useFastMutation";
export function useDeleteHttpResponses(requestId?: string) {
@@ -6,7 +6,7 @@ export function useDeleteHttpResponses(requestId?: string) {
mutationKey: ["delete_http_responses", requestId],
mutationFn: async () => {
if (requestId === undefined) return;
await invokeCmd("cmd_delete_all_http_responses", { requestId });
await rpc("cmd_delete_all_http_responses", { requestId });
},
});
}
@@ -8,7 +8,7 @@ import { showAlert } from "../lib/alert";
import { showConfirmDelete } from "../lib/confirm";
import { jotaiStore } from "../lib/jotai";
import { pluralizeCount } from "../lib/pluralize";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useFastMutation } from "./useFastMutation";
@@ -45,7 +45,7 @@ export function useDeleteSendHistory() {
if (!confirmed) return false;
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
await invokeCmd("cmd_delete_send_history", { workspaceId });
await rpc("cmd_delete_send_history", { workspaceId });
return true;
},
});
+3 -3
View File
@@ -6,7 +6,7 @@ import type {
GetFolderActionsResponse,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins";
export type CallableFolderAction = Pick<FolderAction, "label" | "icon"> & {
@@ -30,7 +30,7 @@ export function useFolderActions() {
}
export async function getFolderActions() {
const responses = await invokeCmd<GetFolderActionsResponse[]>("cmd_folder_actions");
const responses = await rpc<GetFolderActionsResponse[]>("cmd_folder_actions");
const actions = responses.flatMap((r) =>
r.actions.map((a, i) => ({
label: a.label,
@@ -41,7 +41,7 @@ export async function getFolderActions() {
pluginRefId: r.pluginRefId,
args: { folder },
};
await invokeCmd("cmd_call_folder_action", { req: payload });
await rpc("cmd_call_folder_action", { req: payload });
},
})),
);
+15 -8
View File
@@ -1,9 +1,10 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { emit } from "@tauri-apps/api/event";
import { platform } from "@yaakapp-internal/platform";
import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { jotaiStore } from "../lib/jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { activeEnvironmentIdAtom, useActiveEnvironment } from "./useActiveEnvironment";
import { useDebouncedValue } from "@yaakapp-internal/ui";
@@ -22,24 +23,30 @@ export function useGrpc(
const go = useMutation<void, string>({
mutationKey: ["grpc_go", conn?.id],
mutationFn: () =>
invokeCmd<void>("cmd_grpc_go", { requestId, environmentId: environment?.id, protoFiles }),
mutationFn: async () => {
await flushAllModelWrites(); // The backend reads the request from the DB
return rpc<void>("cmd_grpc_go", {
requestId,
environmentId: environment?.id,
protoFiles,
});
},
});
const send = useMutation({
mutationKey: ["grpc_send", conn?.id],
mutationFn: ({ message }: { message: string }) =>
emit(`grpc_client_msg_${conn?.id ?? "none"}`, { Message: message }),
platform.emit(`grpc_client_msg_${conn?.id ?? "none"}`, { Message: message }),
});
const cancel = useMutation({
mutationKey: ["grpc_cancel", conn?.id ?? "n/a"],
mutationFn: () => emit(`grpc_client_msg_${conn?.id ?? "none"}`, "Cancel"),
mutationFn: () => platform.emit(`grpc_client_msg_${conn?.id ?? "none"}`, "Cancel"),
});
const commit = useMutation({
mutationKey: ["grpc_commit", conn?.id ?? "n/a"],
mutationFn: () => emit(`grpc_client_msg_${conn?.id ?? "none"}`, "Commit"),
mutationFn: () => platform.emit(`grpc_client_msg_${conn?.id ?? "none"}`, "Commit"),
});
const debouncedUrl = useDebouncedValue<string>(req?.url ?? "", 1000);
@@ -54,7 +61,7 @@ export function useGrpc(
queryFn: () => {
const environmentId = jotaiStore.get(activeEnvironmentIdAtom);
return minPromiseMillis<ReflectResponseService[]>(
invokeCmd("cmd_grpc_reflect", { requestId, protoFiles, environmentId }),
rpc("cmd_grpc_reflect", { requestId, protoFiles, environmentId }),
300,
);
},
@@ -6,7 +6,7 @@ import type {
GrpcRequestAction,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { getGrpcProtoFiles } from "./useGrpcProtoFiles";
import { usePluginsKey } from "./usePlugins";
@@ -33,7 +33,7 @@ export function useGrpcRequestActions() {
}
export async function getGrpcRequestActions() {
const responses = await invokeCmd<GetGrpcRequestActionsResponse[]>("cmd_grpc_request_actions");
const responses = await rpc<GetGrpcRequestActionsResponse[]>("cmd_grpc_request_actions");
return responses.flatMap((r) =>
r.actions.map((a, i) => ({
@@ -46,7 +46,7 @@ export async function getGrpcRequestActions() {
pluginRefId: r.pluginRefId,
args: { grpcRequest, protoFiles },
};
await invokeCmd("cmd_call_grpc_request_action", { req: payload });
await rpc("cmd_call_grpc_request_action", { req: payload });
},
})),
);
+3 -3
View File
@@ -1,4 +1,4 @@
import { type } from "@tauri-apps/plugin-os";
import { platform } from "@yaakapp-internal/platform";
import { debounce } from "@yaakapp-internal/lib";
import { settingsAtom } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
@@ -102,7 +102,7 @@ const defaultHotkeysOther: Record<HotkeyAction, string[]> = {
/** Get the default hotkeys for the current platform */
export const defaultHotkeys: Record<HotkeyAction, string[]> =
type() === "macos" ? defaultHotkeysMac : defaultHotkeysOther;
platform.osType() === "macos" ? defaultHotkeysMac : defaultHotkeysOther;
/** Atom that provides the effective hotkeys by merging defaults with user settings */
export const hotkeysAtom = atom((get) => {
@@ -318,7 +318,7 @@ export function getHotkeyScope(action: HotkeyAction): string {
}
export function formatHotkeyString(trigger: string): string[] {
const os = type();
const os = platform.osType();
const parts = trigger.split("+");
const labelParts: string[] = [];
@@ -3,7 +3,7 @@ import type { GetHttpAuthenticationSummaryResponse } from "@yaakapp-internal/plu
import { atom, useAtomValue } from "jotai";
import { useState } from "react";
import { jotaiStore } from "../lib/jotai";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { usePluginsKey } from "./usePlugins";
@@ -31,7 +31,7 @@ export function useSubscribeHttpAuthentication() {
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => {
try {
const result = await invokeCmd<GetHttpAuthenticationSummaryResponse[]>(
const result = await rpc<GetHttpAuthenticationSummaryResponse[]>(
"cmd_get_http_authentication_summaries",
);
setNumResults(result.length);
@@ -11,7 +11,7 @@ import type { GetHttpAuthenticationConfigResponse, JsonPrimitive } from "@yaakap
import { useAtomValue } from "jotai";
import { md5 } from "js-md5";
import { useState } from "react";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { activeEnvironmentIdAtom } from "./useActiveEnvironment";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
@@ -48,7 +48,7 @@ export function useHttpAuthenticationConfig(
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: async () => {
if (authName == null || authName === "inherit") return null;
const config = await invokeCmd<GetHttpAuthenticationConfigResponse>(
const config = await rpc<GetHttpAuthenticationConfigResponse>(
"cmd_get_http_authentication_config",
{
authName,
@@ -65,7 +65,7 @@ export function useHttpAuthenticationConfig(
call: async (
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
) => {
await invokeCmd("cmd_call_http_authentication_action", {
await rpc("cmd_call_http_authentication_action", {
pluginRefId: config.pluginRefId,
actionIndex: i,
authName,
@@ -6,7 +6,7 @@ import type {
HttpRequestAction,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins";
export type CallableHttpRequestAction = Pick<HttpRequestAction, "label" | "icon"> & {
@@ -30,7 +30,7 @@ export function useHttpRequestActions() {
}
export async function getHttpRequestActions() {
const responses = await invokeCmd<GetHttpRequestActionsResponse[]>("cmd_http_request_actions");
const responses = await rpc<GetHttpRequestActionsResponse[]>("cmd_http_request_actions");
const actions = responses.flatMap((r) =>
r.actions.map((a, i) => ({
label: a.label,
@@ -41,7 +41,7 @@ export async function getHttpRequestActions() {
pluginRefId: r.pluginRefId,
args: { httpRequest },
};
await invokeCmd("cmd_call_http_request_action", { req: payload });
await rpc("cmd_call_http_request_action", { req: payload });
},
})),
);
+2 -2
View File
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import type { HttpResponse } from "@yaakapp-internal/models";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
export function useHttpRequestBody(response: HttpResponse | null) {
return useQuery({
@@ -18,7 +18,7 @@ export async function getRequestBodyText(response: HttpResponse | null) {
return null;
}
const data = await invokeCmd<number[] | null>("cmd_http_request_body", {
const data = await rpc<number[] | null>("cmd_http_request_body", {
responseId: response.id,
});
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import type { HttpResponse, HttpResponseEvent } from "@yaakapp-internal/models";
import {
httpResponseEventsAtom,
@@ -8,6 +7,7 @@ import {
import { useAtomValue } from "jotai";
import { useEffect } from "react";
import { fireAndForget } from "../lib/fireAndForget";
import { rpc } from "../lib/rpc";
export function useHttpResponseEvents(response: HttpResponse | null) {
const allEvents = useAtomValue(httpResponseEventsAtom);
@@ -20,7 +20,7 @@ export function useHttpResponseEvents(response: HttpResponse | null) {
// Fetch events from database, filtering out events from other responses and merging atomically
fireAndForget(
invoke<HttpResponseEvent[]>("cmd_get_http_response_events", { responseId: response.id }).then(
rpc<HttpResponseEvent[]>("cmd_get_http_response_events", { responseId: response.id }).then(
(events) =>
mergeModelsInStore("http_response_event", events, (e) => e.responseId === response.id),
),
-52
View File
@@ -1,52 +0,0 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { patchModelById } from "@yaakapp-internal/models";
import { createRequestAndNavigate } from "../lib/createRequestAndNavigate";
import { jotaiStore } from "../lib/jotai";
import { invokeCmd } from "../lib/tauri";
import { showToast } from "../lib/toast";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useFastMutation } from "./useFastMutation";
import { wasUpdatedExternally } from "./useRequestUpdateKey";
export function useImportCurl() {
return useFastMutation({
mutationKey: ["import_curl"],
mutationFn: async ({
overwriteRequestId,
command,
}: {
overwriteRequestId?: string;
command: string;
}) => {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const importedRequest: HttpRequest = await invokeCmd("cmd_curl_to_request", {
command,
workspaceId,
});
let verb: string;
if (overwriteRequestId == null) {
verb = "Created";
await createRequestAndNavigate(importedRequest);
} else {
verb = "Updated";
await patchModelById(importedRequest.model, overwriteRequestId, (r: HttpRequest) => ({
...importedRequest,
id: r.id,
createdAt: r.createdAt,
workspaceId: r.workspaceId,
folderId: r.folderId,
name: r.name,
sortPriority: r.sortPriority,
}));
setTimeout(() => wasUpdatedExternally(overwriteRequestId), 100);
}
showToast({
color: "success",
message: `${verb} request from Curl`,
});
},
});
}
@@ -1,5 +1,5 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { invoke } from "@tauri-apps/api/core";
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { buildClientSchema, getIntrospectionQuery } from "graphql";
@@ -9,6 +9,7 @@ import { getResponseBodyText } from "../lib/responseBody";
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
import { useActiveEnvironment } from "./useActiveEnvironment";
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { rpc } from "../lib/rpc";
const introspectionRequestBody = JSON.stringify({
query: getIntrospectionQuery(),
@@ -32,7 +33,7 @@ export function useIntrospectGraphQL(
const upsertIntrospection = useCallback(
async (content: string | null) => {
const v = await invoke<GraphQlIntrospection>("models_upsert_graphql_introspection", {
const v = await rpc<GraphQlIntrospection>("models_upsert_graphql_introspection", {
requestId: baseRequest.id,
workspaceId: baseRequest.workspaceId,
content: content ?? "",
@@ -119,7 +120,7 @@ function useIntrospectionResult(request: HttpRequest) {
return useQuery({
queryKey: ["introspection", request.id],
queryFn: async () =>
invoke<GraphQlIntrospection | null>("models_get_graphql_introspection", {
rpc<GraphQlIntrospection | null>("models_get_graphql_introspection", {
requestId: request.id,
}),
});
@@ -1,28 +0,0 @@
import type { EventCallback, EventName } from "@tauri-apps/api/event";
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { useEffect, useRef } from "react";
export function useListenToTauriEvent<T>(event: EventName, fn: EventCallback<T>) {
const handlerRef = useRef(fn);
useEffect(() => {
handlerRef.current = fn;
}, [fn]);
useEffect(() => {
return listenToTauriEvent<T>(event, (p) => handlerRef.current(p));
}, [event]);
}
export function listenToTauriEvent<T>(event: EventName, fn: EventCallback<T>) {
const unsubPromise = listen<T>(
event,
fn,
// Listen to `emit_all()` events or events specific to the current window
{ target: { label: getCurrentWebviewWindow().label, kind: "Window" } },
);
return () => {
unsubPromise.then((unsub) => unsub()).catch(console.error);
};
}
+8 -10
View File
@@ -6,21 +6,19 @@ import { useMemo } from "react";
export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) {
const folders = useAtomValue(foldersAtom);
return useMemo(() => getParentFolders(folders, m), [folders, m]);
// Key on folderId, not the model itself, so edits to the model (eg. every URL
// keystroke replacing the active request) don't produce a new array identity
const folderId = m?.folderId ?? null;
return useMemo(() => getParentFolders(folders, folderId), [folders, folderId]);
}
function getParentFolders(
folders: Folder[],
currentModel: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null,
): Folder[] {
if (currentModel == null) return [];
function getParentFolders(folders: Folder[], folderId: string | null): Folder[] {
if (folderId == null) return [];
const parentFolder = currentModel.folderId
? folders.find((f) => f.id === currentModel.folderId)
: null;
const parentFolder = folders.find((f) => f.id === folderId);
if (parentFolder == null) {
return [];
}
return [parentFolder, ...getParentFolders(folders, parentFolder)];
return [parentFolder, ...getParentFolders(folders, parentFolder.folderId ?? null)];
}
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import type { GrpcConnection, GrpcEvent } from "@yaakapp-internal/models";
import {
grpcConnectionsAtom,
@@ -11,6 +10,7 @@ import { useEffect, useMemo } from "react";
import { fireAndForget } from "../lib/fireAndForget";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { activeRequestIdAtom } from "./useActiveRequestId";
import { rpc } from "../lib/rpc";
const pinnedGrpcConnectionIdsAtom = atomWithKVStorage<Record<string, string | null>>(
"pinned-grpc-connection-ids",
@@ -71,7 +71,7 @@ export function useGrpcEvents(connectionId: string | null) {
// Fetch events from database, filtering out events from other connections and merging atomically
fireAndForget(
invoke<GrpcEvent[]>("models_grpc_events", { connectionId }).then((events) =>
rpc<GrpcEvent[]>("models_grpc_events", { connectionId }).then((events) =>
mergeModelsInStore("grpc_event", events, (e) => e.connectionId === connectionId),
),
);
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import type { WebsocketConnection, WebsocketEvent } from "@yaakapp-internal/models";
import {
mergeModelsInStore,
@@ -12,6 +11,7 @@ import { fireAndForget } from "../lib/fireAndForget";
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
import { jotaiStore } from "../lib/jotai";
import { activeRequestIdAtom } from "./useActiveRequestId";
import { rpc } from "../lib/rpc";
const pinnedWebsocketConnectionIdAtom = atomWithKVStorage<Record<string, string | null>>(
"pinned-websocket-connection-ids",
@@ -58,7 +58,7 @@ export function useWebsocketEvents(connectionId: string | null) {
// Fetch events from database, filtering out events from other connections and merging atomically
fireAndForget(
invoke<WebsocketEvent[]>("models_websocket_events", { connectionId }).then((events) =>
rpc<WebsocketEvent[]>("models_websocket_events", { connectionId }).then((events) =>
mergeModelsInStore("websocket_event", events, (e) => e.connectionId === connectionId),
),
);
@@ -0,0 +1,12 @@
import { platform } from "@yaakapp-internal/platform";
import { useEffect, useRef } from "react";
/** Subscribe to a backend event for the lifetime of the component. */
export function usePlatformEvent<T>(event: string, fn: (payload: T) => void) {
const handlerRef = useRef(fn);
useEffect(() => {
handlerRef.current = fn;
}, [fn]);
useEffect(() => platform.listen<T>(event, (payload) => handlerRef.current(payload)), [event]);
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { pluginsAtom } from "@yaakapp-internal/models";
import type { PluginMetadata } from "@yaakapp-internal/plugins";
import { useAtomValue } from "jotai";
import { queryClient } from "../lib/queryClient";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
function pluginInfoKey(id: string | null, plugin: Plugin | null) {
return ["plugin_info", id ?? "n/a", plugin?.updatedAt ?? "n/a"];
@@ -19,7 +19,7 @@ export function usePluginInfo(id: string | null) {
placeholderData: (prev) => prev, // Keep previous data on refetch
queryFn: () => {
if (id == null) return null;
return invokeCmd<PluginMetadata>("cmd_plugin_info", { id });
return rpc<PluginMetadata>("cmd_plugin_info", { id });
},
});
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { changeModelStoreWorkspace, pluginsAtom } from "@yaakapp-internal/models
import { useAtomValue } from "jotai";
import { jotaiStore } from "../lib/jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
import { useDebouncedValue } from "@yaakapp-internal/ui";
import { invalidateAllPluginInfo } from "./usePluginInfo";
@@ -26,7 +26,7 @@ export function useRefreshPlugins() {
mutationFn: async () => {
await minPromiseMillis(
(async () => {
await invokeCmd("cmd_reload_plugins");
await rpc("cmd_reload_plugins");
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
await changeModelStoreWorkspace(workspaceId); // Force refresh models
invalidateAllPluginInfo();
@@ -1,9 +1,15 @@
import { useEffect, useState } from "react";
import type { Appearance } from "@yaakapp-internal/theme";
import { getCSSAppearance, subscribeToPreferredAppearance } from "@yaakapp-internal/theme";
import {
getCSSAppearance,
getSystemAppearance,
subscribeToPreferredAppearance,
} from "@yaakapp-internal/theme";
export function usePreferredAppearance() {
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(getCSSAppearance());
const [preferredAppearance, setPreferredAppearance] = useState<Appearance>(
getSystemAppearance() ?? getCSSAppearance(),
);
useEffect(() => subscribeToPreferredAppearance(setPreferredAppearance), []);
return preferredAppearance;
}
+3 -3
View File
@@ -2,7 +2,7 @@ import { useQuery } from "@tanstack/react-query";
import type { RenderPurpose } from "@yaakapp-internal/plugins";
import { useAtomValue } from "jotai";
import { minPromiseMillis } from "../lib/minPromiseMillis";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { useActiveEnvironment } from "./useActiveEnvironment";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
@@ -49,7 +49,7 @@ export async function renderTemplate({
purpose: RenderPurpose;
ignoreError?: boolean;
}): Promise<string> {
return invokeCmd("cmd_render_template", {
return rpc("cmd_render_template", {
template,
workspaceId,
environmentId,
@@ -67,5 +67,5 @@ export async function decryptTemplate({
workspaceId: string;
environmentId: string | null;
}): Promise<string> {
return invokeCmd("cmd_decrypt_template", { template, workspaceId, environmentId });
return rpc("cmd_decrypt_template", { template, workspaceId, environmentId });
}
+17 -10
View File
@@ -1,4 +1,4 @@
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { platform } from "@yaakapp-internal/platform";
import type { ModelPayload } from "@yaakapp-internal/models";
import { atom, useAtomValue } from "jotai";
import { generateId } from "../lib/generateId";
@@ -6,25 +6,32 @@ import { jotaiStore } from "../lib/jotai";
const requestUpdateKeyAtom = atom<Record<string, string>>({});
getCurrentWebviewWindow()
.listen<ModelPayload>("model_write", ({ payload }) => {
if (payload.change.type !== "upsert") return;
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
const changedIds: string[] = [];
for (const payload of payloads) {
if (payload.change.type !== "upsert") continue;
if (
(payload.model.model === "http_request" ||
payload.model.model === "grpc_request" ||
payload.model.model === "websocket_request") &&
((payload.updateSource.type === "window" &&
payload.updateSource.label !== getCurrentWebviewWindow().label) ||
payload.updateSource.label !== platform.window.label) ||
payload.updateSource.type !== "window")
) {
wasUpdatedExternally(payload.model.id);
changedIds.push(payload.model.id);
}
})
.catch(console.error);
}
if (changedIds.length > 0) wasUpdatedExternally(changedIds);
});
export function wasUpdatedExternally(changedRequestId: string) {
jotaiStore.set(requestUpdateKeyAtom, (m) => ({ ...m, [changedRequestId]: generateId() }));
export function wasUpdatedExternally(changedRequestIds: string | string[]) {
const ids = Array.isArray(changedRequestIds) ? changedRequestIds : [changedRequestIds];
jotaiStore.set(requestUpdateKeyAtom, (m) => {
const next = { ...m };
for (const id of ids) next[id] = generateId();
return next;
});
}
export function useRequestUpdateKey(requestId: string | null) {
+4 -4
View File
@@ -1,13 +1,13 @@
import { save } from "@tauri-apps/plugin-dialog";
import type { HttpResponse } from "@yaakapp-internal/models";
import { getModel } from "@yaakapp-internal/models";
import mime from "mime";
import slugify from "slugify";
import { InlineCode } from "@yaakapp-internal/ui";
import { getContentTypeFromHeaders } from "../lib/model_util";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { showToast } from "../lib/toast";
import { useFastMutation } from "./useFastMutation";
import { platform } from "@yaakapp-internal/platform";
export function useSaveResponse(response: HttpResponse | null) {
return useFastMutation({
@@ -21,11 +21,11 @@ export function useSaveResponse(response: HttpResponse | null) {
const contentType = getContentTypeFromHeaders(response.headers) ?? "unknown";
const ext = mime.getExtension(contentType);
const slug = slugify(request.name || "response", { lower: true });
const filepath = await save({
const filepath = await platform.dialog.save({
defaultPath: ext ? `${slug}.${ext}` : slug,
title: "Save Response",
});
await invokeCmd("cmd_save_response", { responseId: response.id, filepath });
await rpc("cmd_save_response", { responseId: response.id, filepath });
showToast({
message: (
<>
@@ -1,6 +1,6 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { getActiveCookieJar } from "./useActiveCookieJar";
import { getActiveEnvironment } from "./useActiveEnvironment";
import { createFastMutation, useFastMutation } from "./useFastMutation";
@@ -12,7 +12,7 @@ async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse |
await flushAllModelWrites();
return invokeCmd("cmd_send_http_request", {
return rpc("cmd_send_http_request", {
requestId: id,
environmentId: getActiveEnvironment()?.id,
cookieJarId: getActiveCookieJar()?.id,
@@ -1,8 +1,8 @@
import { type } from "@tauri-apps/plugin-os";
import { platform } from "@yaakapp-internal/platform";
import { useIsFullscreen } from "@yaakapp-internal/ui";
export function useStoplightsVisible() {
const fullscreen = useIsFullscreen();
const stoplightsVisible = type() === "macos" && !fullscreen;
const stoplightsVisible = platform.osType() === "macos" && !fullscreen;
return stoplightsVisible;
}
@@ -1,4 +1,4 @@
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { platform } from "@yaakapp-internal/platform";
import { settingsAtom } from "@yaakapp-internal/models";
import { useAtomValue } from "jotai";
import { useEffect } from "react";
@@ -11,7 +11,7 @@ export function useSyncFontSizeSetting() {
}
const { interfaceScale, editorFontSize } = settings;
getCurrentWebviewWindow().setZoom(interfaceScale).catch(console.error);
platform.window.setZoom(interfaceScale).catch(console.error);
document.documentElement.style.setProperty("--editor-font-size", `${editorFontSize}px`);
}, [settings]);
}
+4 -4
View File
@@ -1,5 +1,5 @@
import { useHotKey } from "./useHotKey";
import { useListenToTauriEvent } from "./useListenToTauriEvent";
import { usePlatformEvent } from "./usePlatformEvent";
import { useZoom } from "./useZoom";
export function useSyncZoomSetting() {
@@ -8,9 +8,9 @@ export function useSyncZoomSetting() {
// shortcuts for Windows/Linux
const zoom = useZoom();
useHotKey("app.zoom_in", zoom.zoomIn);
useListenToTauriEvent("zoom_in", zoom.zoomIn);
usePlatformEvent("zoom_in", zoom.zoomIn);
useHotKey("app.zoom_out", zoom.zoomOut);
useListenToTauriEvent("zoom_out", zoom.zoomOut);
usePlatformEvent("zoom_out", zoom.zoomOut);
useHotKey("app.zoom_reset", zoom.zoomReset);
useListenToTauriEvent("zoom_reset", zoom.zoomReset);
usePlatformEvent("zoom_reset", zoom.zoomReset);
}
@@ -12,7 +12,7 @@ import {
import type { GetTemplateFunctionConfigResponse, JsonPrimitive } from "@yaakapp-internal/plugins";
import { useAtomValue } from "jotai";
import { md5 } from "js-md5";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { activeEnvironmentIdAtom } from "./useActiveEnvironment";
import { activeWorkspaceIdAtom } from "./useActiveWorkspace";
@@ -63,7 +63,7 @@ export async function getTemplateFunctionConfig(
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace,
environmentId: string | undefined,
) {
const config = await invokeCmd<GetTemplateFunctionConfigResponse>(
const config = await rpc<GetTemplateFunctionConfigResponse>(
"cmd_template_function_config",
{
functionName,
@@ -6,7 +6,7 @@ import type {
import { atom, useAtomValue, useSetAtom } from "jotai";
import { useMemo, useState } from "react";
import type { TwigCompletionOption } from "../components/core/Editor/twig/completion";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins";
const templateFunctionsAtom = atom<TemplateFunction[]>([]);
@@ -49,7 +49,7 @@ export function useSubscribeTemplateFunctions() {
refetchInterval: numFns > 0 ? Number.POSITIVE_INFINITY : 1000,
refetchOnMount: true,
queryFn: async () => {
const result = await invokeCmd<GetTemplateFunctionSummaryResponse[]>(
const result = await rpc<GetTemplateFunctionSummaryResponse[]>(
"cmd_template_function_summaries",
);
setNumFns(result.length);
@@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query";
import type { Tokens } from "@yaakapp-internal/templates";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
export function useTemplateTokensToString(tokens: Tokens) {
return useQuery<string>({
@@ -11,5 +11,5 @@ export function useTemplateTokensToString(tokens: Tokens) {
}
export async function templateTokensToString(tokens: Tokens): Promise<string> {
return invokeCmd("cmd_template_tokens_to_string", { tokens });
return rpc("cmd_template_tokens_to_string", { tokens });
}
@@ -6,7 +6,7 @@ import type {
WebsocketRequestAction,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins";
export type CallableWebSocketRequestAction = Pick<WebsocketRequestAction, "label" | "icon"> & {
@@ -30,7 +30,7 @@ export function useWebsocketRequestActions() {
}
export async function getWebsocketRequestActions() {
const responses = await invokeCmd<GetWebsocketRequestActionsResponse[]>(
const responses = await rpc<GetWebsocketRequestActionsResponse[]>(
"cmd_websocket_request_actions",
);
const actions = responses.flatMap((r) =>
@@ -43,7 +43,7 @@ export async function getWebsocketRequestActions() {
pluginRefId: r.pluginRefId,
args: { websocketRequest },
};
await invokeCmd("cmd_call_websocket_request_action", { req: payload });
await rpc("cmd_call_websocket_request_action", { req: payload });
},
})),
);
+2 -9
View File
@@ -1,18 +1,11 @@
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { platform } from "@yaakapp-internal/platform";
import { useEffect, useState } from "react";
import { fireAndForget } from "../lib/fireAndForget";
export function useWindowFocus() {
const [visible, setVisible] = useState(true);
useEffect(() => {
const unlisten = getCurrentWebviewWindow().onFocusChanged((e) => {
setVisible(e.payload);
});
return () => {
fireAndForget(unlisten.then((fn) => fn()));
};
return platform.window.onFocusChanged(setVisible);
}, []);
return visible;
@@ -6,7 +6,7 @@ import type {
WorkspaceAction,
} from "@yaakapp-internal/plugins";
import { useMemo } from "react";
import { invokeCmd } from "../lib/tauri";
import { rpc } from "../lib/rpc";
import { usePluginsKey } from "./usePlugins";
export type CallableWorkspaceAction = Pick<WorkspaceAction, "label" | "icon"> & {
@@ -30,7 +30,7 @@ export function useWorkspaceActions() {
}
export async function getWorkspaceActions() {
const responses = await invokeCmd<GetWorkspaceActionsResponse[]>("cmd_workspace_actions");
const responses = await rpc<GetWorkspaceActionsResponse[]>("cmd_workspace_actions");
const actions = responses.flatMap((r) =>
r.actions.map((a, i) => ({
label: a.label,
@@ -41,7 +41,7 @@ export async function getWorkspaceActions() {
pluginRefId: r.pluginRefId,
args: { workspace },
};
await invokeCmd("cmd_call_workspace_action", { req: payload });
await rpc("cmd_call_workspace_action", { req: payload });
},
})),
);
+3 -3
View File
@@ -3,7 +3,7 @@ import type { AnyModel, ModelPayload } from "@yaakapp-internal/models";
import { watchWorkspaceFiles } from "@yaakapp-internal/sync";
import { syncWorkspace } from "../commands/commands";
import { activeWorkspaceIdAtom, activeWorkspaceMetaAtom } from "../hooks/useActiveWorkspace";
import { listenToTauriEvent } from "../hooks/useListenToTauriEvent";
import { platform } from "@yaakapp-internal/platform";
import { jotaiStore } from "../lib/jotai";
export function initSync() {
@@ -33,8 +33,8 @@ const syncAfterModelWrite = eagerDebounceAsync(sync, 1000);
* simply add long-lived subscribers for the lifetime of the app.
*/
function initModelListeners() {
listenToTauriEvent<ModelPayload>("model_write", (p) => {
if (isModelRelevant(p.payload.model)) syncAfterModelWrite();
platform.listen<ModelPayload[]>("model_writes", (payloads) => {
if (payloads.some((payload) => isModelRelevant(payload.model))) syncAfterModelWrite();
});
}
+4 -4
View File
@@ -1,5 +1,5 @@
import { getIdentifier } from "@tauri-apps/api/app";
import { invokeCmd } from "./tauri";
import { platform } from "@yaakapp-internal/platform";
import { rpc } from "./rpc";
export interface AppInfo {
isDev: boolean;
@@ -16,8 +16,8 @@ export interface AppInfo {
}
export const appInfo = {
...(await invokeCmd("cmd_metadata")),
identifier: await getIdentifier(),
...(await rpc("cmd_metadata")),
identifier: await platform.appIdentifier(),
} as AppInfo;
console.log("App info", appInfo);
+46
View File
@@ -0,0 +1,46 @@
import { atom } from "jotai";
import type { DropdownItem } from "../components/core/Dropdown";
import { jotaiStore } from "./jotai";
/**
* A menu opened from somewhere that can't render React.
*
* Most menus in the app are a `Dropdown` wrapped around their own trigger. This is for the
* cases where the trigger isn't a React component at all — a CodeMirror widget builds its DOM
* synchronously, so it can only hand over a position and a list of items. Same shape as
* {@link showDialog}.
*/
export interface ContextMenuInstance {
id: string;
/** Where to open, in viewport coordinates */
triggerPosition: { x: number; y: number };
/** The trigger's box, when it has one, so placement can align to its edges */
triggerRect?: Pick<DOMRect, "top" | "bottom" | "left" | "right">;
/** The element it belongs to, so clicking that doesn't read as a click outside */
triggerEl?: HTMLElement | null;
items: DropdownItem[];
}
export const contextMenusAtom = atom<ContextMenuInstance[]>([]);
export function showContextMenu({ id, ...props }: ContextMenuInstance) {
jotaiStore.set(contextMenusAtom, (m) => [...m.filter((c) => c.id !== id), { id, ...props }]);
}
/**
* Opens the menu, or closes it if this one is already open.
*
* What a trigger wants: pressing it a second time should put the menu away. Same shape as
* {@link toggleDialog}.
*/
export function toggleContextMenu({ id, ...props }: ContextMenuInstance) {
if (jotaiStore.get(contextMenusAtom).some((c) => c.id === id)) {
hideContextMenu(id);
} else {
showContextMenu({ id, ...props });
}
}
export function hideContextMenu(id: string) {
jotaiStore.set(contextMenusAtom, (m) => m.filter((c) => c.id !== id));
}
+3 -3
View File
@@ -1,14 +1,14 @@
import { clear, writeText } from "@tauri-apps/plugin-clipboard-manager";
import { showToast } from "./toast";
import { platform } from "@yaakapp-internal/platform";
export function copyToClipboard(
text: string | null,
{ disableToast }: { disableToast?: boolean } = {},
) {
if (text == null) {
clear().catch(console.error);
platform.clipboard.clear().catch(console.error);
} else {
writeText(text).catch(console.error);
platform.clipboard.writeText(text).catch(console.error);
}
if (text !== "" && !disableToast) {
@@ -0,0 +1,15 @@
// Common Accept-Language values.
export const acceptLanguages = [
"*",
"en",
"en-US",
"en-US,en;q=0.9",
"en-GB,en;q=0.9",
"de-DE,de;q=0.9,en;q=0.8",
"fr-FR,fr;q=0.9,en;q=0.8",
"es-ES,es;q=0.9,en;q=0.8",
"ru-RU,ru;q=0.9,en;q=0.8",
"uk-UA,uk;q=0.9,en;q=0.8",
"zh-CN,zh;q=0.9,en;q=0.8",
"ja-JP,ja;q=0.9,en;q=0.8",
];

Some files were not shown because too many files have changed in this diff Show More