Compare commits

..
318 changed files with 5757 additions and 16917 deletions

No files matched your search

-21
View File
@@ -7,23 +7,6 @@ updates:
interval: weekly
day: monday
open-pull-requests-limit: 5
ignore:
# Build tooling majors are opted into, not queued. These never reach a
# user, so the cost of being a release behind is low and the cost of a
# surprise is a broken build. Note this also suppresses security PRs for
# these, so Dependabot alerts are the backstop.
- dependency-name: typescript
update-types: ["version-update:semver-major"]
- dependency-name: vitest
update-types: ["version-update:semver-major"]
- dependency-name: "@types/*"
update-types: ["version-update:semver-major"]
- dependency-name: "vite-plugin-*"
update-types: ["version-update:semver-major"]
# 0.8 dropped stateExtensions and updateSchema from the package root and
# exposes no subpath to reach them, which GrpcEditor needs. Unpin once
# that editor moves to the jsonSchema helper.
- dependency-name: codemirror-json-schema
groups:
npm-production:
dependency-type: production
@@ -42,10 +25,6 @@ updates:
interval: weekly
day: monday
open-pull-requests-limit: 5
ignore:
# Held at 11.10.0 to pin what rolldown_resolver ("^11") resolves to; it
# does not build against 11.11. See crates-cli/yaak-cli/Cargo.toml.
- dependency-name: oxc_resolver
groups:
cargo:
patterns:
@@ -1,79 +0,0 @@
// Retain only distributable bundle files, never the target tree or signing material.
import { createHash } from "node:crypto";
import {
copyFileSync,
createReadStream,
existsSync,
mkdirSync,
readdirSync,
writeFileSync,
} from "node:fs";
import { join, relative } from "node:path";
const env = process.env;
const bundle = join("target", env.RELEASE_TARGET || "", "release", "bundle");
const destination = join(env.RUNNER_TEMP, "yaak-release-artifacts");
const extensions =
/\.(dmg|deb|rpm|AppImage|exe|msi|app\.tar\.gz|AppImage\.tar\.gz|nsis\.zip|msi\.zip)(\.sig)?$/;
const files = [];
function snapshot(directory) {
if (!existsSync(directory)) return;
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const source = join(directory, entry.name);
// Only descend through the bundler's immediate output folders. This excludes
// .app trees and the extracted CEF deb contents (which contain executables).
if (entry.isDirectory() && directory === bundle) {
snapshot(source);
} else if (
entry.isFile() &&
(extensions.test(entry.name) || /^yaak-cef_.*_linux_.*\.tar\.gz$/.test(entry.name))
) {
// Match tauri-action's default macOS updater asset naming exactly.
const arch = env.RELEASE_ARCH === "arm64" ? "aarch64" : "x64";
const assetName = entry.name.replace(/\.app\.tar\.gz(\.sig)?$/, `_${arch}.app.tar.gz$1`);
mkdirSync(destination, { recursive: true });
const retained = join(destination, assetName);
// The second snapshot must not overwrite the original user installer/signature.
if (!existsSync(retained)) copyFileSync(source, retained);
files.push({ source: relative(".", source), assetName });
}
}
}
snapshot(bundle);
if (existsSync(destination)) {
const assets = readdirSync(destination)
.filter((name) => name !== "provenance.json")
.sort();
const checksums = [];
for (const assetName of assets) {
const hash = createHash("sha256");
for await (const chunk of createReadStream(join(destination, assetName))) hash.update(chunk);
checksums.push({ assetName, sha256: hash.digest("hex") });
}
writeFileSync(
join(destination, "provenance.json"),
JSON.stringify(
{
repository: env.GITHUB_REPOSITORY,
tag: env.GITHUB_REF_NAME,
sha: env.GITHUB_SHA,
runId: env.GITHUB_RUN_ID,
runAttempt: env.GITHUB_RUN_ATTEMPT,
workflowRef: env.GITHUB_WORKFLOW_REF,
workflowSha: env.GITHUB_WORKFLOW_SHA,
tauriAction: "tauri-apps/tauri-action@v0",
platform: env.RELEASE_PLATFORM,
runtime: env.RELEASE_RUNTIME,
arch: env.RELEASE_ARCH,
target: env.RELEASE_TARGET,
args: env.RELEASE_ARGS,
assets: checksums,
},
null,
2,
) + "\n",
);
}
console.log(`Found ${files.length} distributable bundle files in ${bundle}`);
+5 -51
View File
@@ -70,7 +70,7 @@ jobs:
runtime: "wry"
targets: "aarch64-pc-windows-msvc"
runs-on: ${{ matrix.platform }}
timeout-minutes: 360
timeout-minutes: 60
steps:
- name: Checkout yaakapp/app
uses: actions/checkout@v4
@@ -172,8 +172,7 @@ jobs:
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaakprotoc.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/protoc/yaakprotoc || true
codesign --force --options runtime --entitlements crates-tauri/yaak-app-client/macos/entitlements.yaaknode.plist --sign "$APPLE_SIGNING_IDENTITY" crates-tauri/yaak-app-client/vendored/node/yaaknode || true
- name: Build and upload Tauri bundles
uses: tauri-apps/tauri-action@v0
- uses: tauri-apps/tauri-action@v0
env:
YAAK_TARGET_ARCH: ${{ matrix.yaak_arch }}
@@ -195,8 +194,6 @@ jobs:
AZURE_CLIENT_SECRET: ${{ matrix.os == 'windows' && secrets.AZURE_CLIENT_SECRET }}
AZURE_TENANT_ID: ${{ matrix.os == 'windows' && secrets.AZURE_TENANT_ID }}
with:
# Also retries builds; upload retries alone cannot clean up starter assets.
retryAttempts: 3
tauriScript: "node ../../node_modules/@tauri-apps/cli/tauri.js"
tagName: "v__VERSION__"
releaseName: "Release __VERSION__"
@@ -206,17 +203,6 @@ jobs:
projectPath: ./crates-tauri/yaak-app-client
args: "${{ matrix.args }}"
# Snapshot before the per-machine build overwrites the regular NSIS bundle/signature.
- name: Snapshot Tauri bundles
if: ${{ !cancelled() }}
run: node .github/scripts/preserve-release-artifacts.mjs
env:
RELEASE_TARGET: ${{ matrix.targets }}
RELEASE_PLATFORM: ${{ matrix.platform }}
RELEASE_RUNTIME: ${{ matrix.runtime }}
RELEASE_ARCH: ${{ matrix.yaak_arch }}
RELEASE_ARGS: ${{ matrix.args }}
- name: Build and upload CEF tarball from deb (Linux only)
if: matrix.os == 'ubuntu' && matrix.runtime == 'cef'
env:
@@ -231,13 +217,7 @@ jobs:
mkdir -p "$extract_dir"
dpkg-deb -x "$deb" "$extract_dir"
tar -C "$extract_dir" -czf "$tarball" .
for attempt in 1 2 3 4; do
if gh release upload "$GITHUB_REF_NAME" "$tarball" --clobber; then
break
fi
if [ "$attempt" -eq 4 ]; then exit 1; fi
sleep 10
done
gh release upload "${{ github.ref_name }}" "$tarball" --clobber
# Build a per-machine NSIS installer for enterprise deployment (PDQ, SCCM, Intune)
- name: Build and upload machine-wide installer (Windows only)
@@ -255,7 +235,6 @@ jobs:
Get-ChildItem -Recurse -Path target -File -Filter "*.exe.sig" | Remove-Item -Force
Push-Location crates-tauri/yaak-app-client
npx tauri bundle ${{ matrix.args }} --bundles nsis --config '{"bundle":{"createUpdaterArtifacts":true,"windows":{"nsis":{"installMode":"perMachine"}}}}'
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Pop-Location
$setup = Get-ChildItem -Recurse -Path target -Filter "*setup*.exe" | Select-Object -First 1
$setupSig = "$($setup.FullName).sig"
@@ -263,30 +242,5 @@ jobs:
$destSig = "$dest.sig"
Copy-Item $setup.FullName $dest
Copy-Item $setupSig $destSig
foreach ($asset in @($dest, $destSig)) {
for ($attempt = 1; $attempt -le 4; $attempt++) {
gh release upload "$env:GITHUB_REF_NAME" "$asset" --clobber
if ($LASTEXITCODE -eq 0) { break }
if ($attempt -eq 4) { exit $LASTEXITCODE }
Start-Sleep -Seconds 10
}
}
- name: Snapshot additional release bundles
if: ${{ !cancelled() }}
run: node .github/scripts/preserve-release-artifacts.mjs
env:
RELEASE_TARGET: ${{ matrix.targets }}
RELEASE_PLATFORM: ${{ matrix.platform }}
RELEASE_RUNTIME: ${{ matrix.runtime }}
RELEASE_ARCH: ${{ matrix.yaak_arch }}
RELEASE_ARGS: ${{ matrix.args }}
- name: Retain release bundles for recovery
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: release-${{ github.ref_name }}-${{ matrix.platform }}-${{ matrix.runtime }}-${{ matrix.yaak_arch }}-${{ github.run_id }}-attempt-${{ github.run_attempt }}
path: ${{ runner.temp }}/yaak-release-artifacts/
retention-days: 7
if-no-files-found: warn
gh release upload "${{ github.ref_name }}" "$dest" --clobber
gh release upload "${{ github.ref_name }}" "$destSig" --clobber
+1 -2
View File
@@ -3,7 +3,6 @@
"ignorePatterns": [
"**/bindings/**",
"crates/yaak-templates/pkg/**",
"apps/yaak-client/routeTree.gen.ts",
"plugins/importer-bruno/tests/fixtures/upstream/**"
"apps/yaak-client/routeTree.gen.ts"
]
}
Generated
+1015 -974
View File
File diff suppressed because it is too large. Load diff
+1 -9
View File
@@ -29,7 +29,6 @@ members = [
"crates-proxy/yaak-proxy-lib",
# Server crates (the browser tier's hosted send executor)
"crates-server/yaak-web",
"crates-server/yaak-playground",
# CLI crates
"crates-cli/yaak-cli",
# Tauri-specific crates
@@ -48,14 +47,7 @@ chrono = "0.4.42"
hex = "0.4.3"
keyring = "3.6.3"
log = "0.4.29"
reqwest = { version = "0.13.5", default-features = false, features = [
# reqwest 0.13 switched `default-tls` from native-tls to rustls; keep the
# rest of the old default set and stay on native-tls.
"charset",
"http2",
"system-proxy",
"native-tls",
] }
reqwest = "0.12.20"
rustls = { version = "0.23.34", default-features = false }
rustls-platform-verifier = "0.6.2"
schemars = { version = "0.8.22", features = ["chrono"] }
-15
View File
@@ -1,15 +0,0 @@
# syntax=docker/dockerfile:1
# The playground API behind yaak.run. See crates-server/yaak-playground/README.md.
FROM rust:1-bookworm AS build
WORKDIR /app
COPY . .
RUN cargo build --release -p yaak-playground
FROM debian:bookworm-slim
COPY --from=build /app/target/release/yaak-playground /usr/local/bin/yaak-playground
ENV HOST=0.0.0.0
ENV PORT=8080
EXPOSE 8080
USER nobody
CMD ["yaak-playground"]
+1 -4
View File
@@ -38,10 +38,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates libssl3 && rm -rf /var/lib/apt/lists/*
COPY --from=server /app/target/release/yaak-web /usr/local/bin/yaak-web
COPY --from=web /app/dist/apps/yaak-client /srv
# 0.0.0.0 so the port is reachable from outside the container. PORT is the variable a
# platform-as-a-service assigns, so a deploy that sets it needs nothing else changed.
ENV HOST=0.0.0.0
ENV PORT=8080
ENV YAAK_WEB_BIND=0.0.0.0:8080
EXPOSE 8080
USER nobody
# Overriding the command (dropping --serve) leaves the stateless send executor:
@@ -6,12 +6,11 @@ import { showDialog } from "../lib/dialog";
import { jotaiStore } from "../lib/jotai";
import { setWorkspaceSearchParams } from "../lib/setWorkspaceSearchParams";
/**
* Prompt for and create a sub-environment of the given base environment, resolving with the new
* environment's ID (or null if the dialog was dismissed). This does not change the workspace's
* active environment.
*/
export const createSubEnvironment = createFastMutation<string | null, unknown, Environment | null>({
export const createSubEnvironmentAndActivate = createFastMutation<
string | null,
unknown,
Environment | null
>({
mutationKey: ["create_environment"],
mutationFn: async (baseEnvironment) => {
if (baseEnvironment == null) {
@@ -42,17 +41,6 @@ export const createSubEnvironment = createFastMutation<string | null, unknown, E
});
});
},
});
/** Same as {@link createSubEnvironment}, but also makes the new environment the active one. */
export const createSubEnvironmentAndActivate = createFastMutation<
string | null,
unknown,
Environment | null
>({
mutationKey: ["create_environment_and_activate"],
mutationFn: (baseEnvironment) => createSubEnvironment.mutateAsync(baseEnvironment),
disableToastError: true, // Already shown by createSubEnvironment
onSuccess: async (environmentId) => {
if (environmentId == null) {
return; // Was not created
@@ -1,10 +1,16 @@
import type { WorkspaceSettingsTab } from "../components/WorkspaceSettingsDialog";
import type {
WorkspaceSettingsHighlight,
WorkspaceSettingsTab,
} from "../components/WorkspaceSettingsDialog";
import { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import { jotaiStore } from "../lib/jotai";
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
export function openWorkspaceSettings(
tab?: WorkspaceSettingsTab,
highlight?: WorkspaceSettingsHighlight,
) {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
if (workspaceId == null) return;
WorkspaceSettingsDialog.show(workspaceId, tab);
WorkspaceSettingsDialog.show(workspaceId, tab, highlight);
}
@@ -1,12 +1,12 @@
import { gitClone } from "@yaakapp-internal/git";
import { Banner, VStack } from "@yaakapp-internal/ui";
import { useId, useState } from "react";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { appInfo } from "../lib/appInfo";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
import { promptCredentials } from "./git/credentials";
@@ -29,7 +29,6 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
const [subdirectory, setSubdirectory] = useState<string>("");
const [isCloning, setIsCloning] = useState(false);
const [error, setError] = useState<string | null>(null);
const formId = useId();
const repoName = extractRepoName(url);
const sep = getPathSeparator(baseDirectory);
@@ -84,7 +83,7 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
};
return (
<VStack as="form" id={formId} space={3} alignItems="start" onSubmit={handleClone}>
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
{error && (
<Banner color="danger" className="w-full">
{error}
@@ -133,17 +132,15 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
/>
)}
<DialogFooter
actions={[
{
label: isCloning ? "Cloning..." : "Clone Repository",
color: "primary",
form: formId,
disabled: !url || !directory || isCloning,
isLoading: isCloning,
},
]}
/>
<Button
type="submit"
color="primary"
className="w-full mt-3"
disabled={!url || !directory || isCloning}
isLoading={isCloning}
>
{isCloning ? "Cloning..." : "Clone Repository"}
</Button>
</VStack>
);
}
@@ -1,10 +1,10 @@
import { createWorkspaceModel } from "@yaakapp-internal/models";
import { useId, useState } from "react";
import { useState } from "react";
import { useToggle } from "../hooks/useToggle";
import { ColorIndicator } from "./ColorIndicator";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { ColorPickerWithThemeColors } from "./core/ColorPicker";
import { DialogFooter } from "./core/Dialog";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
@@ -18,11 +18,9 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
const [name, setName] = useState<string>("");
const [color, setColor] = useState<string | null>(null);
const [sharable, toggleSharable] = useToggle(false);
const formId = useId();
return (
<form
id={formId}
className="flex flex-col gap-3"
className="pb-3 flex flex-col gap-3"
onSubmit={async (e) => {
e.preventDefault();
const id = await createWorkspaceModel({
@@ -61,17 +59,10 @@ export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props)
</Label>
<ColorPickerWithThemeColors onChange={setColor} color={color} />
</div>
<DialogFooter
inline
actions={[
{
label: "Create Environment",
color: "secondary",
form: formId,
leftSlot: color != null ? <ColorIndicator color={color} /> : undefined,
},
]}
/>
<Button type="submit" color="secondary" className="mt-3">
{color != null && <ColorIndicator color={color} />}
Create Environment
</Button>
</form>
);
}
@@ -2,13 +2,13 @@ import { gitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import { createGlobalModel, updateModel } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { useId, useState } from "react";
import { useState } from "react";
import { router } from "../lib/router";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { rpc } from "../lib/rpc";
import { showErrorToast } from "../lib/toast";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
import { Label } from "./core/Label";
import { PlainInput } from "./core/PlainInput";
import { EncryptionHelp } from "./EncryptionHelp";
@@ -26,13 +26,12 @@ export function CreateWorkspaceDialog({ hide }: Props) {
initGit?: boolean;
}>({ filePath: null, initGit: false });
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
const formId = useId();
return (
<VStack
as="form"
id={formId}
space={3}
alignItems="start"
className="pb-3"
onSubmit={async (e) => {
e.preventDefault();
const workspaceId = await createGlobalModel({ model: "workspace", name });
@@ -90,7 +89,9 @@ export function CreateWorkspaceDialog({ hide }: Props) {
title="Enable Encryption"
/>
</div>
<DialogFooter actions={[{ label: "Create Workspace", color: "primary", form: formId }]} />
<Button type="submit" color="primary" className="w-full mt-3">
Create Workspace
</Button>
</VStack>
);
}
+23 -4
View File
@@ -31,6 +31,7 @@ import type { Pair } from "./core/PairEditor";
import { PairEditor } from "./core/PairEditor";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { SecretVariableHint } from "./hints/SecretVariableHint";
import { Markdown } from "./Markdown";
import { SelectFile } from "./SelectFile";
@@ -46,6 +47,8 @@ interface Props<T> {
stateKey: string;
className?: string;
disabled?: boolean;
/** Offer to move literal secrets typed into password fields into a variable */
suggestVariables?: boolean;
}
export function DynamicForm<T extends Record<string, JsonPrimitive>>({
@@ -57,6 +60,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
stateKey,
className,
disabled,
suggestVariables,
}: Props<T>) {
const setDataAttr = useCallback(
(name: string, value: JsonPrimitive) => {
@@ -73,6 +77,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
stateKey={stateKey}
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
suggestVariables={suggestVariables}
data={data}
className={classNames(className, "pb-4")} // Pad the bottom to look nice
/>
@@ -103,6 +108,7 @@ type FormInputsProps<T> = Pick<
> & {
setDataAttr: (name: string, value: JsonPrimitive) => void;
disabled?: boolean;
suggestVariables?: boolean;
};
function FormInputs<T extends Record<string, JsonPrimitive>>({
@@ -113,6 +119,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
setDataAttr,
data,
disabled,
suggestVariables,
}: FormInputsProps<T>) {
return (
<>
@@ -147,6 +154,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
arg={input}
autocompleteFunctions={autocompleteFunctions || false}
autocompleteVariables={autocompleteVariables || false}
suggestVariables={suggestVariables || false}
onChange={(v) => setDataAttr(input.name, v)}
value={
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
@@ -287,6 +295,7 @@ function TextArg({
value,
autocompleteFunctions,
autocompleteVariables,
suggestVariables,
stateKey,
}: {
arg: FormInputText;
@@ -294,6 +303,7 @@ function TextArg({
onChange: (v: string) => void;
autocompleteFunctions: boolean;
autocompleteVariables: boolean;
suggestVariables: boolean;
stateKey: string;
}) {
const props: InputProps = {
@@ -316,10 +326,19 @@ function TextArg({
autocompleteFunctions,
autocompleteVariables,
};
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
return <Input {...props} />;
}
return <PlainInput {...props} />;
const input =
autocompleteVariables || autocompleteFunctions || arg.completionOptions ? (
<Input {...props} />
) : (
<PlainInput {...props} />
);
if (!suggestVariables || !arg.password) return input;
return (
<div className="flex flex-col gap-2">
{input}
<SecretVariableHint name={arg.name} value={value} onReplace={onChange} />
</div>
);
}
function EditorArg({
@@ -4,16 +4,8 @@ import type { TreeHandle, TreeNode, TreeProps } from "@yaakapp-internal/ui";
import { Banner, Icon, InlineCode, SplitLayout, Tree } from "@yaakapp-internal/ui";
import { atom, useAtomValue } from "jotai";
import { atomFamily } from "jotai-family";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createSubEnvironment } from "../commands/createEnvironment";
import { useCallback, useLayoutEffect, useRef, useState } from "react";
import { createSubEnvironmentAndActivate } from "../commands/createEnvironment";
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
import {
environmentsBreakdownAtom,
@@ -48,9 +40,6 @@ interface Props {
type TreeModel = Environment | Workspace;
/** Lets tree row slots create a sub-environment and select it in the editor. */
const CreateSubEnvironmentContext = createContext<() => Promise<void>>(async () => {});
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
@@ -126,7 +115,7 @@ function EnvironmentEditDialogSidebar({
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? "";
const treeId = `environment.${activeWorkspaceId}.sidebar`;
const treeRef = useRef<TreeHandle>(null);
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
// oxlint-disable-next-line react-hooks/exhaustive-deps -- none
useLayoutEffect(() => {
@@ -135,31 +124,6 @@ function EnvironmentEditDialogSidebar({
treeRef.current?.focus();
}, []);
// A just-created environment to select once it reaches the model store. Selecting it before
// then would show a "failed to find" error in the editor, and the tree would drop the
// selection of an item it doesn't have yet.
const [pendingSelectionId, setPendingSelectionId] = useState<string | null>(null);
const tree = useAtomValue(treeAtom);
useEffect(() => {
if (pendingSelectionId == null) return;
if (!allEnvironments.some((e) => e.id === pendingSelectionId)) return;
setPendingSelectionId(null);
setSelectedEnvironmentId(pendingSelectionId);
// The tree leaves out sub-environments while there are multiple base environments
const node = tree == null ? null : findTreeNode(tree, pendingSelectionId);
if (node == null) return;
if (node.parent != null) {
// Expand the parent so the new item is visible to be selected
const parentId = node.parent.item.id;
jotaiStore.set(collapsedFamily(treeId), (prev) => ({ ...prev, [parentId]: false }));
}
// Wait a frame so the tree's own pending selection fixups, which may have been scheduled
// before the new item arrived, don't reset this selection
requestAnimationFrame(() => treeRef.current?.selectItem(pendingSelectionId));
}, [allEnvironments, pendingSelectionId, setSelectedEnvironmentId, tree, treeId]);
const handleDeleteEnvironment = useCallback(
async (environment: Environment) => {
await deleteModelWithConfirm(environment);
@@ -170,14 +134,6 @@ function EnvironmentEditDialogSidebar({
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
);
const handleCreateSubEnvironment = useCallback(async () => {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironment.mutateAsync(baseEnvironment);
// Show the new environment in the editor without changing the workspace's active environment
if (id != null) setPendingSelectionId(id);
}, []);
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
const getSelectedTreeModels = useCallback(
@@ -239,14 +195,13 @@ function EnvironmentEditDialogSidebar({
const addEnvironmentItem: DropdownItem = {
label: "Create Sub Environment",
leftSlot: <Icon icon="plus" />,
onSelect: handleCreateSubEnvironment,
onSelect: async () => {
await createSubEnvironment();
},
};
// Sub-environments aren't shown while there are multiple base environments, same as the
// plus button on the base environment row
const canCreateSubEnvironment = baseEnvironments.length <= 1;
if (environment == null || environment.model !== "environment") {
return canCreateSubEnvironment ? [addEnvironmentItem] : [];
return [addEnvironmentItem];
}
const singleEnvironment = items.length === 1;
@@ -302,7 +257,7 @@ function EnvironmentEditDialogSidebar({
];
// Add sub environment to base environment
if (isBaseEnvironment(environment) && singleEnvironment && canCreateSubEnvironment) {
if (isBaseEnvironment(environment) && singleEnvironment) {
menuItems.push({ type: "separator" });
menuItems.push(addEnvironmentItem);
}
@@ -311,7 +266,6 @@ function EnvironmentEditDialogSidebar({
},
[
baseEnvironments.length,
handleCreateSubEnvironment,
handleDeleteEnvironment,
handleDuplicateSelected,
handleRenameSelected,
@@ -363,37 +317,34 @@ function EnvironmentEditDialogSidebar({
);
const renderContextMenuFn = useCallback<NonNullable<TreeProps<TreeModel>["renderContextMenu"]>>(
({ items, position, onClose }) =>
// Right-clicking empty space has nothing to offer while there are multiple base environments
items.length === 0 ? null : (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
({ items, position, onClose }) => (
<ContextMenu items={items as DropdownItem[]} triggerPosition={position} onClose={onClose} />
),
[],
);
const tree = useAtomValue(treeAtom);
return (
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
{tree != null && (
<CreateSubEnvironmentContext.Provider value={handleCreateSubEnvironment}>
<div className="pt-2">
<Tree
ref={treeRef}
treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
className="px-2 pb-10"
root={tree}
getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner}
ItemRightSlot={ItemRightSlot}
ItemInner={ItemInner}
onActivate={handleActivate}
getEditOptions={getEditOptions}
/>
</div>
</CreateSubEnvironmentContext.Provider>
<div className="pt-2">
<Tree
ref={treeRef}
treeId={treeId}
collapsedAtom={collapsedFamily(treeId)}
className="px-2 pb-10"
root={tree}
getContextMenu={getContextMenu}
renderContextMenu={renderContextMenuFn}
onDragEnd={handleDragEnd}
getItemKey={(i) => `${i.id}::${i.name}`}
ItemLeftSlotInner={ItemLeftSlotInner}
ItemRightSlot={ItemRightSlot}
ItemInner={ItemInner}
onActivate={handleActivate}
getEditOptions={getEditOptions}
/>
</div>
)}
</aside>
);
@@ -433,15 +384,6 @@ const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
return root;
});
function findTreeNode(node: TreeNode<TreeModel>, id: string): TreeNode<TreeModel> | null {
if (node.item.id === id) return node;
for (const child of node.children ?? []) {
const found = findTreeNode(child, id);
if (found != null) return found;
}
return null;
}
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
return baseEnvironments.length > 1 ? (
@@ -453,7 +395,6 @@ function ItemLeftSlotInner({ item }: { item: TreeModel }) {
function ItemRightSlot({ item }: { item: TreeModel }) {
const { baseEnvironments } = useEnvironmentsBreakdown();
const handleCreateSubEnvironment = useContext(CreateSubEnvironmentContext);
return (
<>
{item.model === "environment" && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
@@ -464,7 +405,7 @@ function ItemRightSlot({ item }: { item: TreeModel }) {
icon="plus_circle"
className="opacity-50 hover:opacity-100"
title="Add Sub-Environment"
onClick={handleCreateSubEnvironment}
onClick={createSubEnvironment}
/>
)}
</>
@@ -484,6 +425,13 @@ function ItemInner({ item }: { item: TreeModel }) {
);
}
async function createSubEnvironment() {
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
if (baseEnvironment == null) return;
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
return id;
}
function getEditOptions(item: TreeModel) {
const options: ReturnType<NonNullable<TreeProps<TreeModel>["getEditOptions"]>> = {
defaultValue: item.name,
@@ -15,6 +15,7 @@ import {
withEncryptionEnabled,
} from "../lib/setupOrConfigureEncryption";
import { DismissibleBanner } from "./core/DismissibleBanner";
import { FeatureHint } from "./core/FeatureHint";
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
import type { PairEditorHandle, PairWithId } from "./core/PairEditor";
import { ensurePairId } from "./core/PairEditor.util";
@@ -152,6 +153,17 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
This sharable environment contains plain-text secrets
</DismissibleBanner>
)}
{!environment.public && !isEncryptionEnabled && hasSecretLookingVariable(environment) && (
<FeatureHint
id="encrypt-secrets"
className="mr-3"
docsUrl="https://yaak.app/docs/collaboration/secrets-encryption"
action={{ label: "Enable Encryption", onClick: setupOrConfigureEncryption }}
>
Some of these look like secrets. Encrypt them so the workspace is safe to sync to a
folder or share with a team.
</FeatureHint>
)}
</div>
<PairOrBulkEditor
setRef={setRef}
@@ -173,3 +185,9 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
</div>
);
}
const SECRET_NAME = /token|secret|password|passwd|api[_-]?key|private[_-]?key|credential/i;
function hasSecretLookingVariable(environment: Environment) {
return environment.variables.some((v) => v.value.trim() !== "" && SECRET_NAME.test(v.name));
}
@@ -1,6 +1,6 @@
import type { Workspace } from "@yaakapp-internal/models";
import { workspacesAtom } from "@yaakapp-internal/models";
import { VStack } from "@yaakapp-internal/ui";
import { HStack, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useCallback, useMemo, useState } from "react";
import slugify from "slugify";
@@ -8,9 +8,9 @@ import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { pluralizeCount } from "../lib/pluralize";
import { rpc } from "../lib/rpc";
import { CommercialUseBanner } from "./CommercialUseBanner";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DetailsBanner } from "./core/DetailsBanner";
import { DialogFooter } from "./core/Dialog";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
@@ -65,30 +65,29 @@ 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 document = await rpc<string>("cmd_export_data", {
workspaceIds: ids,
includePrivateEnvironments: includePrivateEnvironments,
const exportPath = await platform.dialog.save({
title: "Export Data",
defaultPath: `yaak.${slug}.json`,
});
const savedTo = await platform.files.save(
`yaak.${slug}.json`,
new TextEncoder().encode(document),
[{ name: "JSON", extensions: ["json"] }],
);
if (savedTo == null) {
return; // Cancelled
if (exportPath == null) {
return;
}
await rpc("cmd_export_data", {
workspaceIds: ids,
exportPath,
includePrivateEnvironments: includePrivateEnvironments,
});
onHide();
onSuccess(savedTo);
onSuccess(exportPath);
}, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
const noneSelected = numSelected === 0;
return (
<>
<VStack space={3} className="pb-4">
<div className="h-full w-full grid grid-rows-[minmax(0,1fr)_auto] overflow-hidden rounded-b-lg">
<VStack space={3} className="overflow-auto px-5 pb-6">
<CommercialUseBanner source="data-export" title="Exporting work data?" />
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
@@ -141,22 +140,29 @@ function ExportDataDialogContent({
/>
</DetailsBanner>
</VStack>
<DialogFooter
leftSlot={
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
<div>
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
Create Run Button
</Link>
}
actions={[
{ label: "Cancel", onClick: onHide },
{
label: `Export ${pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}`,
color: "primary",
disabled: noneSelected,
onClick: () => handleExport(),
},
]}
/>
</>
</div>
<HStack space={2} justifyContent="end">
<Button size="sm" className="focus" variant="border" onClick={onHide}>
Cancel
</Button>
<Button
size="sm"
type="submit"
className="focus"
color="primary"
disabled={noneSelected}
onClick={() => handleExport()}
>
Export{" "}
{pluralizeCount("Workspace", numSelected, { omitSingle: true, noneWord: "Nothing" })}
</Button>
</HStack>
</footer>
</div>
);
}
@@ -1,4 +1,3 @@
import type { NavigateWorkspace } from "@yaakapp-internal/tauri-client";
import { activeRequestAtom } from "../hooks/useActiveRequest";
import { useSubscribeActiveWorkspaceId } from "../hooks/useActiveWorkspace";
import { useActiveWorkspaceChangedToast } from "../hooks/useActiveWorkspaceChangedToast";
@@ -6,13 +5,10 @@ import { useHotKey, useSubscribeHotKeys } from "../hooks/useHotKey";
import { useSubscribeHttpAuthentication } from "../hooks/useHttpAuthentication";
import { useSyncFontSizeSetting } from "../hooks/useSyncFontSizeSetting";
import { useSyncWorkspaceChildModels } from "../hooks/useSyncWorkspaceChildModels";
import { usePlatformEvent } from "../hooks/usePlatformEvent";
import { useSyncZoomSetting } from "../hooks/useSyncZoomSetting";
import { useSubscribeTemplateFunctions } from "../hooks/useTemplateFunctions";
import { fireAndForget } from "../lib/fireAndForget";
import { jotaiStore } from "../lib/jotai";
import { renameModelWithPrompt } from "../lib/renameModelWithPrompt";
import { router } from "../lib/router";
export function GlobalHooks() {
useSyncZoomSetting();
@@ -28,20 +24,6 @@ export function GlobalHooks() {
useActiveWorkspaceChangedToast();
useSubscribeHotKeys();
usePlatformEvent("show_home", () => {
fireAndForget(router.navigate({ to: "/", search: { home: true } }));
});
usePlatformEvent<NavigateWorkspace>("navigate_workspace", ({ workspaceId, environmentId }) => {
fireAndForget(
router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId },
search: { environment_id: environmentId },
}),
);
});
useHotKey(
"request.rename",
async () => {
@@ -239,7 +239,6 @@ export function GrpcEditor({
showDialog({
title: "Configure Schema",
size: "md",
className: "h-[30rem]",
id: "grpc-configure-schema",
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
});
@@ -1,11 +1,11 @@
import type { GrpcRequest } from "@yaakapp-internal/models";
import { Banner, InlineCode, VStack } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useActiveRequest } from "../hooks/useActiveRequest";
import { useGrpc } from "../hooks/useGrpc";
import { useGrpcProtoFiles } from "../hooks/useGrpcProtoFiles";
import { pluralizeCount } from "../lib/pluralize";
import { DialogFooter } from "./core/Dialog";
import { PathList } from "./core/PathList";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { Link } from "./core/Link";
import { platform } from "@yaakapp-internal/platform";
@@ -20,10 +20,7 @@ export function GrpcProtoSelectionDialog(props: Props) {
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
}
function GrpcProtoSelectionDialogWithRequest({
request,
onDone,
}: Props & { request: GrpcRequest }) {
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
const protoFilesKv = useGrpcProtoFiles(request.id);
const protoFiles = protoFilesKv.value ?? [];
const grpc = useGrpc(request, null, protoFiles);
@@ -41,8 +38,54 @@ function GrpcProtoSelectionDialogWithRequest({
}
return (
<>
<VStack space={5} className="pb-4">
<VStack className="flex-col-reverse mb-3" space={3}>
{/* Buttons on top so they get focus first */}
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
<Button
color="primary"
variant="border"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
}}
>
Add Proto Files
</Button>
<Button
variant="border"
color="primary"
onClick={async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
}}
>
Add Import Folders
</Button>
<Button
isLoading={grpc.reflect.isFetching}
disabled={grpc.reflect.isFetching}
variant="border"
color="secondary"
onClick={() => grpc.reflect.refetch()}
>
Refresh Schema
</Button>
</HStack>
<VStack space={5}>
{reflectError && (
<Banner color="warning">
<h1 className="font-bold">
@@ -85,47 +128,46 @@ function GrpcProtoSelectionDialogWithRequest({
</Banner>
)}
<PathList
items={protoFiles.map((path) => ({
path,
icon: path.endsWith(".proto") ? "file_code" : "folder_code",
}))}
onRemove={async (path) => {
await protoFilesKv.set(protoFiles.filter((p) => p !== path));
await grpc.reflect.refetch();
}}
addLabel="Add proto files or an import folder"
addItems={[
{
label: "Proto files",
icon: "file_code",
onSelect: async () => {
const selected = await platform.dialog.open({
title: "Select Proto Files",
multiple: true,
filters: [{ name: "Proto Files", extensions: ["proto"] }],
});
if (selected == null) return;
const newFiles = selected.filter((p) => !protoFiles.includes(p));
await protoFilesKv.set([...protoFiles, ...newFiles]);
await grpc.reflect.refetch();
},
},
{
label: "Import folder",
icon: "folder_code",
onSelect: async () => {
const selected = await platform.dialog.open({
title: "Select Proto Directory",
directory: true,
});
if (selected == null) return;
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
await grpc.reflect.refetch();
},
},
]}
/>
{protoFiles.length > 0 && (
<table className="w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="text-text-subtlest" colSpan={3}>
Added File Paths
</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{protoFiles.map((f, i) => {
const parts = f.split("/");
// oxlint-disable-next-line no-array-index-key -- none
return (
<tr key={f + i} className="group">
<td>
<Icon icon={f.endsWith(".proto") ? "file_code" : "folder_code"} />
</td>
<td className="pl-1 font-mono text-sm" title={f}>
{parts.length > 3 && ".../"}
{parts.slice(-3).join("/")}
</td>
<td className="w-0 py-0.5">
<IconButton
title="Remove file"
variant="border"
size="xs"
icon="trash"
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100"
onClick={async () => {
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
}}
/>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{reflectionUnimplemented && protoFiles.length === 0 && (
<Banner>
<InlineCode>{request.url}</InlineCode> doesn&apos;t implement{" "}
@@ -136,17 +178,6 @@ function GrpcProtoSelectionDialogWithRequest({
</Banner>
)}
</VStack>
<DialogFooter
actions={[
{
label: "Refresh Schema",
isLoading: grpc.reflect.isFetching,
disabled: grpc.reflect.isFetching,
onClick: () => grpc.reflect.refetch(),
},
{ label: "Done", color: "primary", onClick: onDone },
]}
/>
</>
</VStack>
);
}
+10 -1
View File
@@ -9,7 +9,6 @@ import type { HeaderValuePreset } from "../lib/data/headerValuePresets";
import { headerNames } from "../lib/data/headerNames";
import { mimeTypes } from "../lib/data/mimetypes";
import { userAgents } from "../lib/data/userAgents";
import { validateHttpHeader } from "../lib/validateHttpHeader";
import { CountBadge } from "./core/CountBadge";
import { DetailsBanner } from "./core/DetailsBanner";
import type { GenericCompletion, GenericCompletionConfig } from "./core/Editor/genericCompletion";
@@ -179,3 +178,13 @@ const nameAutocomplete: PairEditorProps["nameAutocomplete"] = {
},
),
};
const validateHttpHeader = (v: string) => {
if (v === "") {
return true;
}
// Template strings are not allowed so we replace them with a valid example string
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, "123");
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
};
@@ -178,6 +178,7 @@ export function HttpAuthenticationEditor({ model }: Props) {
disabled={model.authentication.disabled === true}
autocompleteVariables
autocompleteFunctions
suggestVariables
stateKey={`auth.${model.id}.${model.authenticationType}`}
inputs={authConfig.data?.args ?? []}
data={model.authentication}
+22 -18
View File
@@ -52,6 +52,7 @@ import { JsonBodyEditor } from "./JsonBodyEditor";
import { MarkdownEditor } from "./MarkdownEditor";
import { RequestMethodDropdown } from "./RequestMethodDropdown";
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
import { SharedOriginHint } from "./hints/SharedOriginHint";
import { UrlBar } from "./UrlBar";
import { UrlParametersEditor } from "./UrlParameterEditor";
@@ -344,24 +345,27 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
>
{activeRequest && (
<>
<UrlBar
stateKey={`url.${activeRequest.id}`}
key={forceUpdateKey + urlKey}
url={activeRequest.url}
placeholder="https://example.com"
onPasteOverwrite={handlePaste}
autocomplete={autocomplete}
onSend={handleSend}
onCancel={cancelResponse}
onUrlChange={handleUrlChange}
leftSlot={
<div className="py-0.5">
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
</div>
}
forceUpdateKey={updateKey}
isLoading={activeResponse != null && activeResponse.state !== "closed"}
/>
<div>
<UrlBar
stateKey={`url.${activeRequest.id}`}
key={forceUpdateKey + urlKey}
url={activeRequest.url}
placeholder="https://example.com"
onPasteOverwrite={handlePaste}
autocomplete={autocomplete}
onSend={handleSend}
onCancel={cancelResponse}
onUrlChange={handleUrlChange}
leftSlot={
<div className="py-0.5">
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
</div>
}
forceUpdateKey={updateKey}
isLoading={activeResponse != null && activeResponse.state !== "closed"}
/>
<SharedOriginHint />
</div>
<Tabs
ref={tabsRef}
label="Request"
@@ -28,6 +28,7 @@ import { TabContent, Tabs } from "./core/Tabs/Tabs";
import { Tooltip } from "./core/Tooltip";
import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { ChainingHint } from "./hints/ChainingHint";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
@@ -280,6 +281,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
{activeResponse.error}
</Banner>
)}
{activeResponse && <ChainingHint response={activeResponse} mimeType={mimeType} />}
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
<Tabs
tabs={tabs}
+342 -310
View File
@@ -1,36 +1,33 @@
import {
type Folder,
type ImportDestination,
type ImportPlan,
type ImportPlanItem,
type ImportSource,
type Workspace,
} from "@yaakapp-internal/models";
import { Banner, FormattedError, HStack, Icon, VStack } from "@yaakapp-internal/ui";
import { Banner, HStack, Icon, type IconProps, InlineCode, VStack } from "@yaakapp-internal/ui";
import { platform } from "@yaakapp-internal/platform";
import classNames from "classnames";
import { type ComponentProps, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { errorMessage } from "../lib/errorMessage";
import { formatDistanceToNowStrict } from "date-fns";
import { useEffect, useMemo, useRef, useState } from "react";
import { pluralize } from "../lib/pluralize";
import { CommercialUseBanner } from "./CommercialUseBanner";
import {
ImportSourceList,
type ImportSourceDetection,
type ImportSourcePath,
InlineButton,
toSourcePath,
} from "./ImportSourceList";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import type { CheckboxTreeNode } from "./core/CheckboxTree";
import { CheckboxTree } from "./core/CheckboxTree";
import { Chip } from "./core/Chip";
import { DialogFooter } from "./core/Dialog";
import { IconTooltip } from "./core/IconTooltip";
import { PlainInput } from "./core/PlainInput";
import { Select } from "./core/Select";
import { SegmentedControl } from "./core/SegmentedControl";
interface Props {
currentWorkspace: Workspace | null;
workspaces: Workspace[];
planSources: (sources: ImportSourcePath[], destination: ImportDestination) => Promise<ImportPlan>;
detectSource: (source: ImportSourcePath) => Promise<string>;
selectedFolder: Folder | null;
planFile: (filePath: string, destination: ImportDestination) => Promise<ImportPlan>;
planUrl: (url: string, destination: ImportDestination) => Promise<ImportPlan>;
listSources: (workspaceId: string) => Promise<ImportSource[]>;
findSourcesForOrigin: (args: { filePath?: string; url?: string }) => Promise<ImportSource[]>;
commit: (plan: ImportPlan) => Promise<void>;
@@ -38,6 +35,25 @@ interface Props {
onError: (err: unknown) => void;
}
/**
* An absolute or relative path is unambiguously a file. Everything else is treated as a URL, so a
* bare host like `example.com/openapi.json` still works (the backend defaults it to https).
*/
function isFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
function fileName(path: string): string {
return path.split(/[/\\]/).at(-1) || path;
}
/**
* Loads the current workspace's linked sources before rendering the dialog, so the inner
* component can construct its initial state (prefilled path, destination) in one pass instead of
@@ -66,19 +82,30 @@ export function ImportDataDialog(props: Props) {
return <LoadedImportDataDialog {...props} initialSources={initialSources} />;
}
function latestSource(sources: ImportSource[]): ImportSource | null {
return sources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
}
function LoadedImportDataDialog({
currentWorkspace,
workspaces,
planSources,
detectSource,
selectedFolder,
planFile,
planUrl,
listSources,
findSourcesForOrigin,
commit,
cancel,
onError,
initialSources,
}: Props & { initialSources: ImportSource[] }) {
// A workspace with a linked source is probably being re-imported, so start from that source
const prefill = latestSource(initialSources);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [sourceError, setSourceError] = useState<unknown>(null);
const [plan, setPlan] = useState<ImportPlan | null>(null);
const [items, setItems] = useState<ImportPlanItem[]>([]);
// null means no explicit choice yet, so the default below applies
@@ -86,82 +113,65 @@ function LoadedImportDataDialog({
null,
);
const [otherWorkspaceId, setOtherWorkspaceId] = useState<string | null>(null);
const [originSources, setOriginSources] = useState<ImportSource[]>(initialSources);
const initialPaths = useMemo(
() => [...new Set(initialSources.map((source) => source.origin))].map(toSourcePath),
[initialSources],
const [targetSelectedFolder, setTargetSelectedFolder] = useState(selectedFolder != null);
const [linkedSources, setLinkedSources] = useState<ImportSource[]>(
prefill != null ? initialSources : [],
);
const [sources, setSources] = useState<ImportSourcePath[]>(initialPaths);
const knownPaths = useRef(new Set(initialPaths.map((source) => source.path)));
const [detections, setDetections] = useState<Record<string, ImportSourceDetection>>({});
const detect = useCallback(
(added: ImportSourcePath[]) => {
setDetections((prev) => ({
...prev,
...Object.fromEntries(added.map((s) => [s.path, { status: "loading" as const }])),
}));
for (const source of added) {
const settle = (detection: ImportSourceDetection) =>
setDetections((prev) =>
source.path in prev ? { ...prev, [source.path]: detection } : prev,
);
detectSource(source).then(
(importer) => settle({ status: "ok", importer }),
(err) => settle({ status: "error", message: errorMessage(err) }),
);
}
},
[detectSource],
const [originSources, setOriginSources] = useState<ImportSource[]>(
prefill != null ? [prefill] : [],
);
const allDetected = sources.every((source) => detections[source.path]?.status === "ok");
useEffect(() => {
detect(initialPaths);
}, [detect, initialPaths]);
// A file path or a URL. Both inputs write here, so there is only ever one thing to import
const [source, setSource] = useState<string | null>(prefill?.origin ?? null);
const [forceUpdateKey, setForceUpdateKey] = useState<number>(0);
const [isHovering, setIsHovering] = useState<boolean>(false);
const ref = useRef<HTMLDivElement>(null);
const trimmedSource = source?.trim() ?? "";
const filePath = isFilePath(trimmedSource) ? trimmedSource : null;
const addSources = useCallback(
(added: ImportSourcePath[]) => {
setSourceError(null);
const fresh = added.filter(({ path }) => {
if (knownPaths.current.has(path)) return false;
knownPaths.current.add(path);
return true;
});
if (fresh.length === 0) return;
setSources((existing) => [...existing, ...fresh]);
detect(fresh);
},
[detect],
);
const removeSource = (path: string) => {
setSourceError(null);
knownPaths.current.delete(path);
setSources((current) => current.filter((source) => source.path !== path));
setDetections(({ [path]: _removed, ...rest }) => rest);
const selectSource = (value: string) => {
setSource(value);
// Remount the input so it shows the path of the newly-picked file
setForceUpdateKey((k) => k + 1);
};
// Accept a file dropped anywhere on the dialog, the way SelectFile does for its button
useEffect(() => {
let cancelled = false;
if (sources.length === 0) {
return platform.window.onDragDrop((event) => {
if (event.type === "over") {
const p = event.position;
const r = ref.current?.getBoundingClientRect();
if (r == null) return;
setIsHovering(p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom);
} else if (event.type === "drop" && isHovering) {
const p = event.paths[0];
if (p) selectSource(p);
setIsHovering(false);
} else {
setIsHovering(false);
}
});
}, [isHovering, setSource]);
useEffect(() => {
if (trimmedSource === "") {
setOriginSources([]);
return;
}
Promise.all(
sources.map(({ path, kind }) =>
findSourcesForOrigin(kind === "url" ? { url: path } : { filePath: path }),
),
)
.then((found) => {
if (!cancelled) setOriginSources(found.flat());
})
.catch(() => {
if (!cancelled) setOriginSources([]);
});
let cancelled = false;
const timeout = setTimeout(() => {
findSourcesForOrigin(filePath != null ? { filePath } : { url: trimmedSource })
.then((sources) => {
if (!cancelled) setOriginSources(sources);
})
.catch(() => setOriginSources([]));
}, 300);
return () => {
cancelled = true;
clearTimeout(timeout);
};
}, [sources, findSourcesForOrigin]);
}, [trimmedSource, filePath, findSourcesForOrigin]);
// The one workspace these sources are linked to, if there is exactly one.
// The one workspace this file is linked to, if there is exactly one.
const linkedWorkspace = useMemo(() => {
const ids = [...new Set(originSources.map((s) => s.workspaceId))];
if (ids.length !== 1) return null;
@@ -183,6 +193,32 @@ function LoadedImportDataDialog({
? otherWorkspaceId
: null;
useEffect(() => {
if (destinationWorkspaceId == null) {
setLinkedSources([]);
return;
}
let cancelled = false;
listSources(destinationWorkspaceId)
.then((sources) => {
if (!cancelled) setLinkedSources(sources);
})
.catch(() => setLinkedSources([]));
return () => {
cancelled = true;
};
}, [destinationWorkspaceId, listSources]);
const handleSelectFile = async () => {
const selected = await platform.dialog.open({ title: "Select File", multiple: false });
if (selected == null) return;
selectSource(selected);
};
// The selected folder belongs to the workspace being viewed, so it is only offerable when that
// is also the destination.
const canTargetSelectedFolder = selectedFolder != null && destinationKind === "current";
const destination = (): ImportDestination => {
if (destinationWorkspaceId == null) {
return { type: "new_workspace" };
@@ -190,18 +226,21 @@ function LoadedImportDataDialog({
return {
type: "existing_workspace",
workspaceId: destinationWorkspaceId,
folderId: canTargetSelectedFolder && targetSelectedFolder ? selectedFolder.id : undefined,
};
};
const handlePreview = async () => {
setIsLoading(true);
setSourceError(null);
try {
const nextPlan = await planSources(sources, destination());
const nextPlan =
filePath != null
? await planFile(filePath, destination())
: await planUrl(trimmedSource, destination());
setPlan(nextPlan);
setItems(nextPlan.items);
} catch (err) {
setSourceError(err);
onError(err);
} finally {
setIsLoading(false);
}
@@ -219,6 +258,8 @@ function LoadedImportDataDialog({
}
};
const itemTree = useMemo(() => buildItemTree(items), [items]);
// A folder row's checkbox carries everything beneath it, deletions included — the row labels
// say which of those are destructive. Checking anything also brings back the folders it needs
// to live in.
@@ -272,215 +313,196 @@ function LoadedImportDataDialog({
if (plan.destination.type === "new_workspace") {
const names = plan.resources.workspaces.map((w) => w.name).filter((n) => n !== "");
if (names.length === 0) return ["New workspace", "Untitled"];
return [
names.length === 1 ? "New workspace" : `${names.length} new workspaces`,
names.join(", "),
];
return [pluralize("New workspace", names.length), names.join(", ")];
}
const { workspaceId } = plan.destination;
const { workspaceId, folderId } = plan.destination;
const name = workspaces.find((w) => w.id === workspaceId)?.name ?? "Unknown workspace";
return ["Destination", name];
return [
"Destination",
folderId != null && folderId === selectedFolder?.id
? `${name} / ${selectedFolder.name}`
: name,
];
})();
// The destination workspace roots the tree. It is not a plan item — commit always applies
// it — so its checkbox only aggregates the subtree.
const workspaceRoots: CheckboxTreeNode<TreeRow>[] = (() => {
const workspaceRoot: CheckboxTreeNode<TreeRow> = (() => {
const planned = plan.resources.workspaces[0];
const planDestination = plan.destination;
const existing =
planDestination.type === "existing_workspace"
? workspaces.find((w) => w.id === planDestination.workspaceId)
: null;
const destinations = existing ? [existing] : plan.resources.workspaces;
if (destinations.length === 1) {
const workspace = destinations[0]!;
return [
{
key: workspace.id,
data: {
kind: "destination",
label: workspace.name,
isNew: planDestination.type === "new_workspace",
},
children: buildItemTree(items),
},
];
}
const workspaceById = new Map(
[
...plan.resources.environments,
...plan.resources.folders,
...plan.resources.httpRequests,
...plan.resources.grpcRequests,
...plan.resources.websocketRequests,
].map((resource) => [resource.id, resource.workspaceId]),
);
return destinations.map((workspace) => ({
key: workspace.id,
return {
key: existing?.id ?? planned?.id ?? "workspace",
data: {
kind: "destination",
label: workspace.name,
label: existing?.name ?? planned?.name ?? "New workspace",
isNew: planDestination.type === "new_workspace",
},
children: buildItemTree(
items.filter((item) => workspaceById.get(item.modelId) === workspace.id),
),
}));
children: itemTree,
};
})();
return (
<>
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label={destinationLabel} value={destinationValue} />
</div>
<VStack space={4} className="pb-4">
<div className="rounded-lg border border-border-subtle divide-y divide-border-subtle">
<PreviewRow label="Detected format" value={plan.importer} />
<PreviewRow label={destinationLabel} value={destinationValue} />
</div>
{plan.warnings
.filter(
(warning, index, warnings) =>
warnings.findIndex(
(other) =>
other.title === warning.title &&
other.detail === warning.detail &&
other.level === warning.level,
) === index,
)
.map((warning) => (
<Banner
key={`${warning.level}:${warning.title}:${warning.detail}`}
color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
<Icon
icon={warning.level === "warning" ? "alert_triangle" : "info"}
size="sm"
className="mt-0.5"
/>
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
</Banner>
))}
{workspaceRoots.map((root) => (
<div key={root.key} className="rounded-lg border border-border-subtle px-3 py-2">
<CheckboxTree
node={root}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) =>
(n.data.kind === "destination" && workspaceRoots.length > 1) ||
(n.data.kind === "item" && n.data.item.action === "ignored")
}
isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => (
<ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />
)}
/>
{plan.warnings.map((warning) => (
<Banner
key={`${warning.title}:${warning.detail}`}
color={warning.level === "warning" ? "warning" : "info"}
className="flex items-start gap-2.5"
>
<Icon
icon={warning.level === "warning" ? "alert_triangle" : "info"}
size="sm"
className="mt-0.5"
/>
<div className="min-w-0">
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-xs text-text-subtle mt-0.5">{warning.detail}</div>
</div>
))}
</VStack>
<DialogFooter
leftSlot={
footerNote !== "" ? (
<div className="text-xs text-text-subtle">{footerNote}</div>
) : undefined
}
actions={[
{
label: "Back",
disabled: isLoading,
onClick: () => {
setPlan(null);
setItems([]);
},
},
{
label: isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done",
color: "primary",
isLoading,
onClick: handleCommit,
},
]}
/>
</>
</Banner>
))}
<div className="rounded-lg border border-border-subtle px-3 py-2 overflow-y-auto max-h-[40vh]">
<CheckboxTree
node={workspaceRoot}
checked={nodeCheckedStatus}
onCheck={toggleNode}
isCheckboxDisabled={(n) => disabledIds.has(n.key)}
isCollapsedByDefault={(n) => n.data.kind === "item" && n.data.item.action === "ignored"}
isRelevant={(n) =>
n.data.kind === "destination" ||
(n.data.kind === "item" && n.data.item.action !== "unchanged")
}
renderRow={(n) => <ImportTreeRow row={n.data} onResolveConflict={resolveConflict} />}
/>
</div>
<HStack space={2} alignItems="center" className="mt-3">
{footerNote !== "" && <div className="text-xs text-text-subtle">{footerNote}</div>}
<Button
className="ml-auto"
color="secondary"
variant="border"
disabled={isLoading}
onClick={() => {
setPlan(null);
setItems([]);
}}
>
Back
</Button>
<Button color="primary" isLoading={isLoading} onClick={handleCommit}>
{isLoading
? "Importing"
: changeCount > 0
? `Apply ${changeCount} ${changeCount === 1 ? "Change" : "Changes"}`
: "Done"}
</Button>
</HStack>
</VStack>
);
}
const destinationPicker = (
<HStack
space={2}
alignItems="center"
className={classNames(workspaces.length === 0 && "hidden")}
>
<Select
name="import-destination-kind"
label="Import into"
labelPosition="left"
size="sm"
value={destinationKind}
onChange={setDestinationChoice}
options={[
{ value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
/>
{destinationKind === "other" && (
<Select
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
)}
</HStack>
);
const lastImported =
originSources.find((s) => s.workspaceId === destinationWorkspaceId) ??
linkedSources.reduce<ImportSource | null>(
(latest, s) => (latest == null || s.lastImportedAt > latest.lastImportedAt ? s : latest),
null,
);
return (
<>
<VStack space={4} className="h-full pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<VStack ref={ref} space={4} className="pb-4">
<CommercialUseBanner source="data-import" title="Importing work data?" />
<ImportSourceList
sources={sources}
detections={detections}
disabled={isLoading}
onAdd={addSources}
onRemove={removeSource}
onError={setSourceError}
/>
{sourceError != null && (
<Banner color="warning">
<FormattedError>{errorMessage(sourceError)}</FormattedError>
</Banner>
<button
type="button"
onClick={handleSelectFile}
className={classNames(
"w-full rounded-lg border border-dashed px-4 py-6",
"flex flex-col items-center gap-1 text-center",
isHovering
? "border-notice bg-surface-highlight"
: "border-border hover:border-text-subtle",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-8! h-8! mb-2" />
{/* Fixed height so the region doesn't resize between the empty and selected states */}
<div className="h-6 w-full flex items-center justify-center">
{filePath == null ? (
<div className="text-text">
<strong className="font-semibold">Choose a file</strong> or drag it here
</div>
) : (
<div className="text-text font-mono text-xs max-w-full truncate" title={filePath}>
{fileName(filePath)}
</div>
)}
</div>
<div className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, and curl
</div>
</button>
{linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId && (
<PlainInput
label="Or enter a file path or URL"
size="sm"
placeholder="https://example.com/openapi.json"
defaultValue={source ?? ""}
forceUpdateKey={String(forceUpdateKey)}
onChange={setSource}
/>
<VStack space={2}>
<Select
name="import-destination-kind"
label="Import location"
size="sm"
value={destinationKind}
onChange={setDestinationChoice}
options={[
{ value: "new", label: "New Workspace" },
...(currentWorkspace != null
? [{ value: "current" as const, label: "Current Workspace" }]
: []),
{ value: "other", label: "Other Workspace" },
]}
/>
{destinationKind === "other" && (
<Select
name="import-destination-workspace"
label="Workspace"
hideLabel
size="sm"
value={otherWorkspaceId ?? ""}
onChange={(id) => setOtherWorkspaceId(id === "" ? null : id)}
filterable
options={[
{ value: "", label: "Select a workspace" },
...workspaces
.filter((w) => w.id !== currentWorkspace?.id)
.map((w) => ({ value: w.id, label: w.name })),
]}
/>
)}
{lastImported != null && destinationWorkspaceId != null ? (
<div className="text-xs text-text-subtle">
{sources.length === 1 ? "This source was" : "These sources were"} last imported into{" "}
<InlineButton
Last imported from {lastImported.originLabel} ·{" "}
{formatDistanceToNowStrict(`${lastImported.lastImportedAt}Z`, { addSuffix: true })}
</div>
) : linkedWorkspace != null && linkedWorkspace.id !== destinationWorkspaceId ? (
<div className="text-xs text-text-subtle">
This file was last imported into{" "}
<button
type="button"
className="underline hocus:text-text"
onClick={() => {
if (linkedWorkspace.id === currentWorkspace?.id) {
setDestinationChoice("current");
@@ -491,28 +513,36 @@ function LoadedImportDataDialog({
}}
>
{linkedWorkspace.name}
</InlineButton>
</button>
</div>
) : null}
{canTargetSelectedFolder && (
<Checkbox
checked={targetSelectedFolder}
title={`Place root resources in selected folder “${selectedFolder.name}”`}
onChange={setTargetSelectedFolder}
/>
)}
</VStack>
<DialogFooter
leftSlot={destinationPicker}
actions={[
{ label: "Cancel", disabled: isLoading, onClick: cancel },
{
label: isLoading ? "Analyzing" : "Preview Import",
color: "primary",
disabled:
sources.length === 0 ||
!allDetected ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null),
isLoading,
onClick: handlePreview,
},
]}
/>
</>
<HStack space={2} justifyContent="end">
<Button color="secondary" variant="border" disabled={isLoading} onClick={cancel}>
Cancel
</Button>
<Button
color="primary"
disabled={
trimmedSource === "" ||
isLoading ||
(destinationKind === "other" && otherWorkspaceId == null)
}
isLoading={isLoading}
onClick={handlePreview}
>
{isLoading ? "Analyzing" : "Preview Import"}
</Button>
</HStack>
</VStack>
);
}
@@ -526,12 +556,10 @@ function ImportTreeRow({
if (row.kind !== "item") {
return (
<>
{row.kind === "destination" && (
<Icon icon="box" size="sm" className="shrink-0 text-text-subtle" />
)}
<Icon color="secondary" icon={row.kind === "destination" ? "house" : row.icon} />
<div className="truncate flex-1">{row.label}</div>
{row.kind === "destination" && row.isNew && (
<ActionChip label="new" help="Created by this import" color="success" />
<ActionChip label="new" help="Created by this import" className="text-success" />
)}
</>
);
@@ -541,6 +569,11 @@ function ImportTreeRow({
const label = actionLabel(item);
return (
<>
{item.model === "folder" || item.model === "environment" ? (
<Icon color="secondary" icon={item.model === "folder" ? "folder" : "variable"} />
) : (
<span aria-hidden className="w-4" />
)}
<div className="truncate flex-1">{item.name}</div>
{item.action === "conflict" ? (
<div className="shrink-0">
@@ -560,7 +593,17 @@ function ImportTreeRow({
</div>
) : (
label != null && (
<ActionChip label={label} help={actionHelp(item)} color={actionColor(item)} />
<ActionChip
label={label}
help={actionHelp(item)}
className={classNames(
item.action === "create" && "text-success",
item.action === "update" && "text-info",
item.action === "delete" && "text-danger",
item.action === "keep_local" && item.selected && "text-warning",
item.action === "ignored" && "text-text-subtlest",
)}
/>
)
)}
</>
@@ -570,37 +613,26 @@ function ImportTreeRow({
function ActionChip({
label,
help,
color,
className,
}: {
label: string;
help: string | null;
color: ComponentProps<typeof Chip>["color"];
className?: string;
}) {
return (
<Chip color={color}>
<InlineCode
className={classNames(
"py-0 bg-transparent w-32 shrink-0 whitespace-nowrap text-xs",
"inline-flex items-center justify-center gap-1.5",
className,
)}
>
{label}
{help != null && <IconTooltip content={help} iconSize="xs" />}
</Chip>
</InlineCode>
);
}
function actionColor(item: ImportPlanItem): ComponentProps<typeof Chip>["color"] {
switch (item.action) {
case "create":
return "success";
case "update":
return "info";
case "delete":
return "danger";
case "keep_local":
return item.selected ? "warning" : "default";
case "unchanged":
case "conflict":
case "ignored":
return "default";
}
}
function actionLabel(item: ImportPlanItem): string | null {
switch (item.action) {
case "create":
@@ -669,7 +701,7 @@ function ancestorsOf(item: ImportPlanItem, byId: Map<string, ImportPlanItem>): I
*/
type TreeRow =
| { kind: "destination"; label: string; isNew: boolean }
| { kind: "group"; label: string }
| { kind: "group"; label: string; icon: IconProps["icon"] }
| { kind: "item"; item: ImportPlanItem };
function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
@@ -711,7 +743,7 @@ function buildItemTree(items: ImportPlanItem[]): CheckboxTreeNode<TreeRow>[] {
return [
{
key: "group:environments",
data: { kind: "group", label: "Variables" },
data: { kind: "group", label: "Variables", icon: "variable" },
children: environments.map((e) => toNode(e, new Set())),
},
...nodes,
@@ -1,271 +0,0 @@
import { platform } from "@yaakapp-internal/platform";
import { Icon, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "./core/Button";
import { IconButton } from "./core/IconButton";
import { PathList, splitPath } from "./core/PathList";
import { PlainInput } from "./core/PlainInput";
export interface ImportSourcePath {
path: string;
kind?: "file" | "folder" | "url";
}
export type ImportSourceDetection =
| { status: "loading" }
| { status: "ok"; importer: string }
| { status: "error"; message: string };
interface Props {
sources: ImportSourcePath[];
detections: Record<string, ImportSourceDetection>;
disabled?: boolean;
onAdd: (sources: ImportSourcePath[]) => void;
onRemove: (path: string) => void;
onError: (err: unknown) => void;
}
/** A bare hostname is a URL; local paths need an absolute or explicit relative prefix. */
export function isImportFilePath(value: string): boolean {
return (
value.startsWith("/") ||
value.startsWith("./") ||
value.startsWith("../") ||
value.startsWith("~/") ||
value.startsWith("\\\\") ||
/^[a-zA-Z]:[\\/]/.test(value)
);
}
/** A typed or prefilled path is a URL unless it looks like a local path; kind is left for the host */
export function toSourcePath(path: string): ImportSourcePath {
return { path, kind: isImportFilePath(path) ? undefined : "url" };
}
export function ImportSourceList({
sources,
detections,
disabled,
onAdd,
onRemove,
onError,
}: Props) {
const ref = useRef<HTMLDivElement>(null);
const [isHovering, setIsHovering] = useState(false);
const [pathInput, setPathInput] = useState("");
const [showPathInput, setShowPathInput] = useState(false);
const pathInputRef = useRef<{ focus: () => void }>(null);
const trimmedPath = pathInput.trim();
const openPathInput = () => {
setShowPathInput(true);
// The add-row menu hands focus back to its trigger as it closes, on a timer, so focus after it
setTimeout(() => pathInputRef.current?.focus(), 50);
};
const closePathInput = () => {
setPathInput("");
setShowPathInput(false);
};
const addPath = () => {
if (!trimmedPath || disabled) return;
onAdd([toSourcePath(trimmedPath)]);
closePathInput();
};
useEffect(() => {
if (disabled) return;
return platform.window.onDragDrop((event) => {
if (event.type === "leave") {
setIsHovering(false);
return;
}
const rect = ref.current?.getBoundingClientRect();
const { x, y } = event.position;
const isOver =
rect != null && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
if (event.type === "drop") {
if (isOver) onAdd(event.paths.map((path) => ({ path })));
setIsHovering(false);
} else {
setIsHovering(isOver);
}
});
}, [disabled, onAdd]);
const pick = async (directory: boolean) => {
try {
const selected = await platform.dialog.open({
title: directory ? "Select Import Folder" : "Select Import Files",
multiple: true,
directory,
});
if (selected == null) return;
onAdd(selected.map((path) => ({ path, kind: directory ? "folder" : "file" })));
} catch (err) {
onError(err);
}
};
const handlePathKeyDown = (event: KeyboardEvent) => {
if (event.key === "Enter") {
event.preventDefault();
addPath();
} else if (event.key === "Escape") {
event.stopPropagation();
closePathInput();
}
};
const pathInputEl = (
<PlainInput
ref={pathInputRef}
label="URL or file path"
hideLabel
size="sm"
autoFocus
disabled={disabled}
placeholder="Paste a URL or a file path"
onChange={setPathInput}
onKeyDownCapture={handlePathKeyDown}
rightSlot={
<div className="flex items-center gap-1 my-1 mr-1">
{trimmedPath !== "" && (
<Button size="xs" variant="border" onClick={addPath}>
Add
</Button>
)}
<IconButton size="xs" icon="x" title="Cancel" onClick={closePathInput} />
</div>
}
/>
);
if (sources.length === 0) {
return (
<div className="flex-1 flex flex-col gap-2">
<div
ref={ref}
className={classNames(
"flex-1 rounded-lg border min-h-28 flex flex-col items-center justify-center gap-2 px-4 py-5 text-center text-text-subtle",
isHovering ? "border-notice bg-surface-highlight" : "border-dashed border-border",
)}
>
<Icon icon="folder_input" className="text-text-subtlest w-10! h-10! mb-1" />
{isHovering ? (
<span>Drop to add</span>
) : (
<span>
Choose{" "}
<InlineButton disabled={disabled} onClick={() => pick(false)}>
files
</InlineButton>
, a{" "}
<InlineButton disabled={disabled} onClick={() => pick(true)}>
folder
</InlineButton>
, or a{" "}
<InlineButton disabled={disabled} onClick={openPathInput}>
URL
</InlineButton>
, or drag them here
</span>
)}
<span className="text-xs text-text-subtlest">
Supports OpenAPI, Swagger, Postman, Insomnia, Bruno, curl, and Yaak exports
</span>
</div>
{showPathInput && pathInputEl}
</div>
);
}
return (
<PathList
ref={ref}
hovering={isHovering}
disabled={disabled}
onRemove={onRemove}
addLabel="Add files, a folder, or a URL"
addHint="or drop them here"
addItems={[
{ label: "Files", icon: "file", onSelect: () => pick(false) },
{ label: "Folder", icon: "folder", onSelect: () => pick(true) },
{ label: "URL or file path", icon: "globe", onSelect: openPathInput },
]}
items={sources.map((source) => {
const detection = detections[source.path];
return {
path: source.path,
icon: sourceIcon(source),
failed: detection?.status === "error",
subtitle: <DetectionSubtitle path={source.path} detection={detection} />,
};
})}
>
{showPathInput && pathInputEl}
</PathList>
);
}
function DetectionSubtitle({
path,
detection,
}: {
path: string;
detection: ImportSourceDetection | undefined;
}) {
const { parent } = splitPath(path);
if (detection?.status === "loading") {
return (
<>
<LoadingIcon size="xs" />
Detecting format
</>
);
}
if (detection?.status === "error") return detection.message;
return (
<>
{detection?.status === "ok" && <span>{detection.importer}</span>}
{detection?.status === "ok" && parent !== "" && <span>·</span>}
{parent !== "" && <span className="truncate font-mono">{parent}</span>}
</>
);
}
function sourceIcon({ kind }: ImportSourcePath) {
switch (kind) {
case "url":
return "globe";
case "folder":
return "folder";
case "file":
return "file";
case undefined:
return "import";
}
}
export function InlineButton({
children,
disabled,
onClick,
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
}) {
return (
<button
type="button"
disabled={disabled}
className="underline decoration-current/40 underline-offset-3 hocus:decoration-current disabled:opacity-disabled"
onClick={onClick}
>
{children}
</button>
);
}
-212
View File
@@ -1,212 +0,0 @@
import type { Color } from "@yaakapp-internal/plugins";
import { platform } from "@yaakapp-internal/platform";
import { settingsAtom } from "@yaakapp-internal/models";
import { HeaderSize, Heading, Icon, type IconProps, LoadingIcon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
import { showDialog } from "../lib/dialog";
import { importData } from "../lib/importData";
import {
createExampleWorkspace,
createFreshWorkspace,
type OnboardingChoice,
recordOnboardingChoice,
} from "../lib/onboarding";
import { showErrorToast } from "../lib/toast";
import { CloneGitRepositoryDialog } from "./CloneGitRepositoryDialog";
import { Button } from "./core/Button";
/** Shown instead of a workspace when there are none. */
export function Onboarding() {
const settings = useAtomValue(settingsAtom);
const { capabilities } = platform;
const canImport = capabilities.plugins;
const showAlreadyUsing = capabilities.localFiles || capabilities.git || canImport;
const [busy, setBusy] = useState<OnboardingChoice | null>(null);
const choose = (choice: OnboardingChoice, run: () => Promise<void> | void) => async () => {
recordOnboardingChoice(choice);
setBusy(choice);
try {
await run();
} catch (err) {
showErrorToast({
id: "onboarding-failed",
title: "Something went wrong",
message: String(err),
});
} finally {
setBusy(null);
}
};
return (
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
<HeaderSize
data-tauri-drag-region
size="lg"
className="x-theme-appHeader bg-surface"
osType={platform.osType()}
hideWindowControls={settings.hideWindowControls}
useNativeTitlebar={settings.useNativeTitlebar}
interfaceScale={settings.interfaceScale}
/>
<div className="overflow-auto px-6 py-10 grid">
<div className="m-auto w-full max-w-lg flex flex-col gap-7">
<div className="flex flex-col gap-1.5">
<Heading>How would you like to get started?</Heading>
<p className="text-text-subtle">
{canImport
? "Bring over existing work, try a real API, or start fresh."
: "Try a real API, or start fresh."}
</p>
</div>
<div className="flex flex-col gap-2">
{canImport && (
<StartOption
color="primary"
icon="folder_input"
title="Migrate from another tool"
description="Postman, Insomnia, Bruno, OpenAPI, or curl"
busy={busy === "import"}
disabled={busy != null}
onClick={choose("import", () => importData.mutateAsync())}
/>
)}
<StartOption
color="info"
icon="flask"
title="Try Yaak with a real API"
description="Ready-made requests you can send right away"
busy={busy === "example"}
disabled={busy != null}
onClick={choose("example", createExampleWorkspace)}
/>
<StartOption
color="success"
icon="plus"
title="Start fresh"
description="An empty workspace for your first request"
busy={busy === "fresh"}
disabled={busy != null}
onClick={choose("fresh", createFreshWorkspace)}
/>
</div>
{showAlreadyUsing && (
<div className="pt-5 border-t border-dashed border-border-subtle flex flex-col items-start gap-2.5">
<span className="text-sm text-text-subtle">Already using Yaak?</span>
<div className="flex flex-wrap gap-2">
{capabilities.localFiles && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="folder_open" size="sm" />}
disabled={busy != null}
onClick={choose("open_folder", async () => {
const dir = await platform.dialog.open({
title: "Select Workspace Directory",
directory: true,
multiple: false,
});
if (dir == null) return;
await openWorkspaceFromSyncDir.mutateAsync(dir);
})}
>
Open folder
</Button>
)}
{capabilities.git && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="git_branch" size="sm" />}
disabled={busy != null}
onClick={choose("clone_git", () => {
showDialog({
id: "clone-git-repository",
size: "md",
title: "Clone Git Repository",
render: ({ hide }) => <CloneGitRepositoryDialog hide={hide} />,
});
})}
>
Clone repository
</Button>
)}
{canImport && (
<Button
size="xs"
variant="border"
color="secondary"
leftSlot={<Icon icon="import" size="sm" />}
disabled={busy != null}
onClick={choose("import_yaak", () => importData.mutateAsync())}
>
Import
</Button>
)}
</div>
</div>
)}
</div>
</div>
</div>
);
}
function StartOption({
color,
icon,
title,
description,
onClick,
busy,
disabled,
}: {
color: Color;
icon: IconProps["icon"];
title: string;
description: string;
onClick: () => void;
busy: boolean;
disabled: boolean;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={classNames(
"group w-full text-left rounded-lg px-3 py-2.5 border border-border-subtle",
"grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-3.5",
"enabled:hocus:bg-surface-highlight/50 enabled:hocus:border-border",
"outline-border-focus focus-visible:outline-2",
disabled && !busy && "opacity-disabled",
)}
>
{/* The banner theme tints this tile from the theme's own color for each option */}
<div
className={classNames(
`x-theme-banner--${color}`,
"size-10 rounded-md bg-surface-highlight grid place-items-center",
)}
>
{busy ? <LoadingIcon size="sm" /> : <Icon icon={icon} color={color} size="md" />}
</div>
<div className="min-w-0">
<div className="font-semibold text-text">{title}</div>
<div className="text-sm text-text-subtle">{description}</div>
</div>
<Icon
icon="chevron_right"
className="text-text-subtlest group-enabled:group-hover:text-text-subtle group-enabled:group-focus-visible:text-text-subtle"
/>
</button>
);
}
@@ -7,7 +7,6 @@ import { getRecentRequests } from "../hooks/useRecentRequests";
import { useRecentWorkspaces } from "../hooks/useRecentWorkspaces";
import { fireAndForget } from "../lib/fireAndForget";
import { router } from "../lib/router";
import { Onboarding } from "./Onboarding";
export function RedirectToLatestWorkspace() {
const workspaces = useAtomValue(workspacesAtom);
@@ -15,6 +14,10 @@ export function RedirectToLatestWorkspace() {
useEffect(() => {
if (workspaces.length === 0 || recentWorkspaces == null) {
console.log("No workspaces found to redirect to. Skipping.", {
workspaces,
recentWorkspaces,
});
return;
}
@@ -31,21 +34,11 @@ export function RedirectToLatestWorkspace() {
request_id: requestId,
};
// Whatever created the first workspace may have already navigated into it
const currentId = router.history.location.pathname.match(/^\/workspaces\/([^/]+)/)?.[1];
if (workspaces.some((w) => w.id === currentId)) return;
console.log("Redirecting to workspace", params, search);
await router.navigate({ to: "/workspaces/$workspaceId", params, search });
})(),
);
}, [recentWorkspaces, workspaces, workspaces.length]);
// The global models are loaded before the router mounts, so an empty list is a real
// first launch (or the last workspace was just deleted), not a store still loading
if (workspaces.length === 0) {
return <Onboarding />;
}
return null;
}
+3 -7
View File
@@ -941,14 +941,10 @@ const sidebarTreeAtom = atom<
if (node.children != null) {
childItems.sort((a, b) => {
if (a.sortPriority !== b.sortPriority) {
return a.sortPriority - b.sortPriority;
if (a.sortPriority === b.sortPriority) {
return a.updatedAt > b.updatedAt ? 1 : -1;
}
// Keep equal-priority items in a stable order when they are edited.
if (a.createdAt !== b.createdAt) {
return a.createdAt > b.createdAt ? 1 : -1;
}
return a.id === b.id ? 0 : a.id > b.id ? 1 : -1;
return a.sortPriority - b.sortPriority;
});
for (const item of childItems) {
@@ -1,11 +1,11 @@
import type { Workspace } from "@yaakapp-internal/models";
import { patchModel, settingsAtom } from "@yaakapp-internal/models";
import { Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { HStack, Icon, InlineCode, VStack } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useState } from "react";
import { switchWorkspace } from "../commands/switchWorkspace";
import { Button } from "./core/Button";
import { Checkbox } from "./core/Checkbox";
import { DialogFooter } from "./core/Dialog";
interface Props {
hide: () => void;
@@ -16,33 +16,45 @@ export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
const settings = useAtomValue(settingsAtom);
const [remember, setRemember] = useState<boolean>(false);
const open = async (inNewWindow: boolean) => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: inNewWindow });
}
};
return (
<VStack space={3}>
<p>
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
</p>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
className="focus"
color="primary"
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: false });
}
}}
>
This Window
</Button>
<Button
className="focus"
color="secondary"
rightSlot={<Icon icon="external_link" />}
onClick={async () => {
hide();
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
if (remember) {
await patchModel(settings, { openWorkspaceNewWindow: true });
}
}}
>
New Window
</Button>
</HStack>
{settings && (
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
<HStack justifyContent="end">
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
</HStack>
)}
<DialogFooter
inline
actions={[
{
label: "New Window",
rightSlot: <Icon icon="external_link" />,
onClick: () => open(true),
},
{ label: "This Window", color: "primary", autoFocus: true, onClick: () => open(false) },
]}
/>
</VStack>
);
}
@@ -59,6 +59,7 @@ export function SyncToFilesystemSetting({
)}
<SettingRowDirectory
highlightKey="sync_dir"
title="Local directory sync"
description="Sync data to a folder for backup and Git integration."
filePath={value.filePath}
@@ -11,7 +11,7 @@ import type { FnArg, Tokens } from "@yaakapp-internal/templates";
import { parseTemplate } from "@yaakapp-internal/templates";
import { HStack, InlineCode, LoadingIcon, useDebouncedValue } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useEffect, useId, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { useRenderTemplate } from "../hooks/useRenderTemplate";
import { useTemplateFunctionConfig } from "../hooks/useTemplateFunctionConfig";
@@ -24,7 +24,7 @@ import { showDialog } from "../lib/dialog";
import { convertTemplateToInsecure } from "../lib/encryption";
import { jotaiStore } from "../lib/jotai";
import { setupOrConfigureEncryption } from "../lib/setupOrConfigureEncryption";
import { DialogFooter } from "./core/Dialog";
import { Button } from "./core/Button";
import { collectArgumentValues } from "./core/Editor/twig/util";
import { IconButton } from "./core/IconButton";
import { PlainInput } from "./core/PlainInput";
@@ -91,7 +91,6 @@ function InitializedTemplateFunctionDialog({
const previewType = ogPreviewType == null ? "live" : ogPreviewType;
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
const formId = useId();
const tokens: Tokens = useMemo(() => {
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
@@ -161,14 +160,13 @@ function InitializedTemplateFunctionDialog({
return (
<form
id={formId}
className="grid grid-rows-[minmax(0,1fr)_auto] h-full"
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
onSubmit={(e) => {
e.preventDefault();
handleDone();
}}
>
<div className="overflow-y-auto h-full">
<div className="overflow-y-auto h-full px-6">
{name === "secure" ? (
<PlainInput
required
@@ -190,71 +188,75 @@ function InitializedTemplateFunctionDialog({
/>
)}
</div>
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto] border-t border-t-border pt-3">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isLoading && <LoadingIcon size="xs" />}
</HStack>
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
/>
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
{previewType !== "none" ? (
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
<HStack space={0.5}>
<HStack className="text-sm text-text-subtle" space={1.5}>
Rendered Preview
{rendered.isLoading && <LoadingIcon size="xs" />}
</HStack>
<IconButton
size="xs"
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
iconSize="sm"
icon={showSecretsInPreview ? "lock" : "lock_open"}
title={showSecretsInPreview ? "Show preview" : "Hide preview"}
onClick={toggleShowSecretsInPreview}
className={classNames(
"ml-auto text-text-subtlest",
!dataContainsSecrets && "invisible",
)}
/>
</HStack>
<div className="relative w-full max-h-40">
<InlineCode
className={classNames(
"block whitespace-pre-wrap select-text! cursor-text max-h-40 overflow-auto hide-scrollbars border-text-subtlest!",
tooLarge && "italic text-danger",
)}
>
{rendered.error || tagText.error ? (
<em className="text-danger">
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, "")}
</em>
) : dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
"too large to preview"
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
<IconButton
size="xs"
icon="refresh"
className="text-text-subtle"
title="Refresh preview"
spin={rendered.isPending}
onClick={() => {
setRenderKey(new Date().toISOString());
}}
/>
</div>
</div>
</div>
) : (
<span />
)}
<div className="flex justify-stretch w-full grow gap-2 *:flex-1">
{templateFunction.data.name === "secure" && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)}
<Button type="submit" color="primary">
Save
</Button>
</div>
) : (
<span />
)}
<DialogFooter
actions={[
...(templateFunction.data.name === "secure"
? [{ label: "Reveal Encryption Key", onClick: setupOrConfigureEncryption }]
: []),
{ label: "Save", color: "primary", form: formId },
]}
/>
</div>
</form>
);
}
@@ -270,6 +272,7 @@ TemplateFunctionDialog.show = (
id: `template-function-${Math.random()}`, // Allow multiple at once
size: "md",
className: "h-240",
noPadding: true,
title: <InlineCode>{fn.name}(…)</InlineCode>,
description: fn.description,
render: ({ hide }) => {
+2 -2
View File
@@ -40,7 +40,6 @@ import { ErrorBoundary } from "./ErrorBoundary";
import { FolderLayout } from "./FolderLayout";
import { GrpcConnectionLayout } from "./GrpcConnectionLayout";
import { HttpRequestLayout } from "./HttpRequestLayout";
import { RedirectToLatestWorkspace } from "./RedirectToLatestWorkspace";
import Sidebar from "./Sidebar";
import { SidebarActions } from "./SidebarActions";
import { WebsocketRequestLayout } from "./WebsocketRequestLayout";
@@ -67,8 +66,9 @@ export function Workspace() {
return { background };
}, [activeEnvironment?.color]);
// We're loading still
if (workspaces.length === 0) {
return <RedirectToLatestWorkspace />;
return null;
}
const header = (
@@ -25,8 +25,12 @@ interface Props {
workspaceId: string;
hide: () => void;
tab?: WorkspaceSettingsTab;
highlight?: WorkspaceSettingsHighlight;
}
/** Settings rows that can be pointed out when the dialog opens */
export type WorkspaceSettingsHighlight = "sync_dir";
const TAB_AUTH = "auth";
const TAB_DNS = "dns";
const TAB_HEADERS = "headers";
@@ -42,7 +46,7 @@ export type WorkspaceSettingsTab =
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
export function WorkspaceSettingsDialog({ workspaceId, hide, tab, highlight }: Props) {
const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);
const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
@@ -104,7 +108,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
/>
</TabContent>
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
<SettingsList className="space-y-8 pb-3">
<SettingsList className="space-y-8 pb-3" highlight={highlight}>
<SettingsSection title={null}>
<SyncToFilesystemSetting
layout="settings"
@@ -178,14 +182,23 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
);
}
WorkspaceSettingsDialog.show = (workspaceId: string, tab?: WorkspaceSettingsTab) => {
WorkspaceSettingsDialog.show = (
workspaceId: string,
tab?: WorkspaceSettingsTab,
highlight?: WorkspaceSettingsHighlight,
) => {
showDialog({
id: "workspace-settings",
size: "lg",
className: "h-[calc(100vh-5rem)] max-h-200!",
noPadding: true,
render: ({ hide }) => (
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
<WorkspaceSettingsDialog
workspaceId={workspaceId}
hide={hide}
tab={tab}
highlight={highlight}
/>
),
});
};
@@ -1,10 +1,5 @@
import { describe, expect, test } from "vite-plus/test";
import {
formatBulkPairLine,
formatBulkPairs,
parseBulkPairLine,
parseBulkPairs,
} from "./BulkPairEditor";
import { parseBulkPairLine } from "./BulkPairEditor";
describe("parseBulkPairLine", () => {
test("parses colon-space pairs as name and value", () => {
@@ -38,130 +33,4 @@ describe("parseBulkPairLine", () => {
value: "bar\nbaz",
});
});
test("parses commented-out pairs as disabled", () => {
expect(parseBulkPairLine("# foo: bar")).toMatchObject({
enabled: false,
name: "foo",
value: "bar",
});
});
test("allows any whitespace after the comment marker", () => {
expect(parseBulkPairLine("# foo: bar")).toMatchObject({
enabled: false,
name: "foo",
value: "bar",
});
expect(parseBulkPairLine("#\tfoo: bar")).toMatchObject({
enabled: false,
name: "foo",
value: "bar",
});
});
test("treats a # without whitespace after it as part of the name", () => {
expect(parseBulkPairLine("#foo: bar")).toMatchObject({
enabled: true,
name: "#foo",
value: "bar",
});
});
test("parses commented-out pairs with an empty value as disabled", () => {
expect(parseBulkPairLine("# token:")).toMatchObject({
enabled: false,
name: "token",
value: "",
});
expect(parseBulkPairLine("# token: ")).toMatchObject({
enabled: false,
name: "token",
value: "",
});
});
test("drops comments that are not pairs", () => {
expect(parseBulkPairLine("# just a comment")).toBeNull();
expect(parseBulkPairLine("# see http://example.com")).toBeNull();
expect(parseBulkPairLine("#")).toBeNull();
});
test("leaves values that start with # alone", () => {
expect(parseBulkPairLine("color: #fff")).toMatchObject({
enabled: true,
name: "color",
value: "#fff",
});
});
});
describe("formatBulkPairLine", () => {
test("formats enabled pairs as name and value", () => {
expect(formatBulkPairLine({ enabled: true, name: "foo", value: "bar" })).toBe("foo: bar");
});
test("treats pairs without an enabled flag as enabled", () => {
expect(formatBulkPairLine({ name: "foo", value: "bar" })).toBe("foo: bar");
});
test("comments out disabled pairs", () => {
expect(formatBulkPairLine({ enabled: false, name: "foo", value: "bar" })).toBe("# foo: bar");
});
test("escapes newlines in values", () => {
expect(formatBulkPairLine({ enabled: false, name: "foo", value: "a\nb" })).toBe("# foo: a\\nb");
});
});
describe("bulk pair round trip", () => {
test("preserves order and enabled state", () => {
const pairs = [
{ enabled: true, name: "host", value: "example.com" },
{ enabled: false, name: "token", value: "old" },
{ enabled: true, name: "color", value: "#fff" },
{ enabled: false, name: "multi", value: "a\nb" },
{ enabled: true, name: "token", value: "new" },
];
const text = formatBulkPairs(pairs);
expect(text).toBe(
["host: example.com", "# token: old", "color: #fff", "# multi: a\\nb", "token: new"].join(
"\n",
),
);
const parsed = parseBulkPairs(text);
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual(pairs);
});
test("preserves enabled pairs whose name starts with #", () => {
const pairs = [{ enabled: true, name: "#foo", value: "bar" }];
const text = formatBulkPairs(pairs);
expect(text).toBe("#foo: bar");
const parsed = parseBulkPairs(text);
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual(pairs);
});
test("preserves disabled pairs whose name starts with #", () => {
const pairs = [{ enabled: false, name: "#foo", value: "bar" }];
const text = formatBulkPairs(pairs);
expect(text).toBe("# #foo: bar");
const parsed = parseBulkPairs(text);
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual(pairs);
});
test("preserves disabled pairs with empty values", () => {
const pairs = [{ enabled: false, name: "token", value: "" }];
const parsed = parseBulkPairs(formatBulkPairs(pairs));
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual(pairs);
});
test("drops free-text comments and blank lines between pairs", () => {
const parsed = parseBulkPairs("# Auth\nfoo: bar\n\n# baz: qux\n# see the docs");
expect(parsed.map(({ enabled, name, value }) => ({ enabled, name, value }))).toEqual([
{ enabled: true, name: "foo", value: "bar" },
{ enabled: false, name: "baz", value: "qux" },
]);
});
});
@@ -14,11 +14,20 @@ export function BulkPairEditor({
forcedEnvironmentId,
stateKey,
}: Props) {
const pairsText = useMemo(() => formatBulkPairs(pairs), [pairs]);
const pairsText = useMemo(() => {
return pairs
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
.map(formatBulkPairLine)
.join("\n");
}, [pairs]);
const handleChange = useCallback(
(text: string) => {
onChange(parseBulkPairs(text));
const pairs = text
.split("\n")
.filter((l: string) => l.trim())
.map(parseBulkPairLine);
onChange(pairs);
},
[onChange],
);
@@ -38,57 +47,13 @@ export function BulkPairEditor({
);
}
export function formatBulkPairs(pairs: Pair[]): string {
return pairs
.filter((p) => !(p.name.trim() === "" && p.value.trim() === ""))
.map(formatBulkPairLine)
.join("\n");
}
export function parseBulkPairs(text: string): PairWithId[] {
return text
.split("\n")
.filter((l: string) => l.trim())
.map(parseBulkPairLine)
.filter((p) => p != null);
}
/**
* Format a pair as a `name: value` line. Disabled pairs are commented out dotenv-style, as
* `# name: value`, so the enabled state survives a round trip through {@link parseBulkPairLine}.
*/
export function formatBulkPairLine(pair: Pair) {
const value = pair.value.replaceAll("\n", "\\n");
const line = `${pair.name}: ${value}`;
return pair.enabled === false ? `# ${line}` : line;
return `${pair.name}: ${value}`;
}
const PAIR_REGEX = /^([^:]+):\s+(.*)$/;
// Commented-out pairs may also end at the colon, so a hand-typed `# name:` isn't dropped
const COMMENTED_PAIR_REGEX = /^([^:]+):(?:\s+(.*))?$/;
// A # only marks a comment when followed by whitespace (or nothing), so `#foo: bar` stays an
// enabled pair named `#foo`
const COMMENT_PREFIX_REGEX = /^\s*#(?:\s+|$)/;
/**
* Parse a `name: value` line into an enabled pair. A line starting with `# ` is a disabled pair
* if the rest of it parses as `name: value`, otherwise it's a free-text comment and `null` is
* returned so it can be dropped.
*/
export function parseBulkPairLine(line: string): PairWithId | null {
const commentPrefix = line.match(COMMENT_PREFIX_REGEX);
if (commentPrefix != null) {
const uncommented = line.slice(commentPrefix[0].length);
const match = uncommented.match(COMMENTED_PAIR_REGEX);
if (match == null) return null;
return { ...pairFromMatch(uncommented, match), enabled: false };
}
return pairFromMatch(line, line.match(PAIR_REGEX));
}
function pairFromMatch(line: string, match: RegExpMatchArray | null): PairWithId {
const [, name, value] = match ?? [];
export function parseBulkPairLine(line: string): PairWithId {
const [, name, value] = line.match(/^([^:]+):\s+(.*)$/) ?? [];
return {
enabled: true,
name: (name ?? line).trim(),
-32
View File
@@ -1,32 +0,0 @@
import type { Color } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { ReactNode } from "react";
interface Props {
color?: Color | "default";
className?: string;
children: ReactNode;
}
/** A small tinted label, like a Gmail or Trello label */
export function Chip({ color = "default", className, children }: Props) {
return (
<span
className={classNames(
className,
"text-xs font-medium rounded-md px-2 py-0.5 bg-current/10 border border-current/10 shrink-0 whitespace-nowrap",
"inline-flex items-center gap-1",
color === "default" && "text-text-subtle",
color === "primary" && "text-primary",
color === "secondary" && "text-secondary",
color === "info" && "text-info",
color === "success" && "text-success",
color === "notice" && "text-notice",
color === "warning" && "text-warning",
color === "danger" && "text-danger",
)}
>
{children}
</span>
);
}
+12 -17
View File
@@ -1,8 +1,9 @@
import type { Color } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import type { FormEvent } from "react";
import { useId, useState } from "react";
import { useState } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { DialogFooter } from "./Dialog";
import { Button } from "./Button";
import { PlainInput } from "./PlainInput";
export interface ConfirmProps {
@@ -21,7 +22,6 @@ export function Confirm({
color = "primary",
}: ConfirmProps) {
const [confirm, setConfirm] = useState<string>("");
const formId = useId();
const handleHide = () => {
onResult(false);
onHide();
@@ -38,7 +38,7 @@ export function Confirm({
};
return (
<form id={formId} className="flex flex-col" onSubmit={handleSuccess}>
<form className="flex flex-col" onSubmit={handleSuccess}>
{requireTyping && (
<PlainInput
autoFocus
@@ -61,19 +61,14 @@ export function Confirm({
}
/>
)}
<DialogFooter
inline
actions={[
{ label: "Cancel", onClick: handleHide },
{
label: confirmText ?? "Confirm",
color,
form: formId,
disabled: !didConfirm,
autoFocus: !requireTyping,
},
]}
/>
<HStack space={2} justifyContent="start" className="mt-2 mb-4 flex-row-reverse">
<Button type="submit" color={color} disabled={!didConfirm}>
{confirmText ?? "Confirm"}
</Button>
<Button onClick={handleHide} variant="border">
Cancel
</Button>
</HStack>
</form>
);
}
+4 -73
View File
@@ -1,11 +1,9 @@
import type { DialogSize } from "@yaakapp-internal/plugins";
import { Heading, HStack, Overlay } from "@yaakapp-internal/ui";
import { Heading, Overlay } from "@yaakapp-internal/ui";
import classNames from "classnames";
import * as m from "motion/react-m";
import type { ReactNode } from "react";
import { createContext, useContext, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { Button, type ButtonProps } from "./Button";
import { useMemo } from "react";
import { IconButton } from "./IconButton";
export interface DialogProps {
@@ -40,7 +38,6 @@ export function Dialog({
vAlign = "center",
}: DialogProps) {
const titleId = useMemo(() => Math.random().toString(36).slice(2), []);
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
const descriptionId = useMemo(
() => (description ? Math.random().toString(36).slice(2) : undefined),
[description],
@@ -74,7 +71,7 @@ export function Dialog({
animate={{ top: 0, scale: 1 }}
className={classNames(
className,
"grid grid-rows-[auto_auto_minmax(0,1fr)_auto]",
"grid grid-rows-[auto_auto_minmax(0,1fr)]",
"grid-cols-1", // must be here for inline code blocks to correctly break words
"relative bg-surface pointer-events-auto",
"rounded-lg",
@@ -111,11 +108,9 @@ export function Dialog({
!noScroll && "overflow-y-auto overflow-x-hidden",
)}
>
<DialogFooterSlot.Provider value={footerEl}>{children}</DialogFooterSlot.Provider>
{children}
</div>
<div ref={setFooterEl} className="contents" />
{/*Put close at the end so that it's the last thing to be tabbed to*/}
{!disableClose && !hideX && (
<div className="ml-auto absolute right-1 top-1">
@@ -134,67 +129,3 @@ export function Dialog({
</Overlay>
);
}
const DialogFooterSlot = createContext<HTMLDivElement | null>(null);
export interface DialogFooterAction {
label: string;
onClick?: () => void;
/** Submit the form with this id, which can be anywhere in the dialog body */
form?: string;
color?: ButtonProps["color"];
variant?: ButtonProps["variant"];
disabled?: boolean;
isLoading?: boolean;
autoFocus?: boolean;
leftSlot?: ReactNode;
rightSlot?: ReactNode;
}
/**
* The action row at the bottom of a dialog. Render it anywhere in the dialog's content and it is
* placed below the scrolling body, outside its padding. `inline` drops the divider for small
* dialogs whose body never scrolls.
*/
export function DialogFooter({
actions,
leftSlot,
inline,
}: {
actions: DialogFooterAction[];
leftSlot?: ReactNode;
inline?: boolean;
}) {
const slot = useContext(DialogFooterSlot);
const footer = (
<footer
className={classNames(
"px-6 flex items-center gap-3",
inline ? "pt-1 pb-4" : "py-3 border-t border-border-subtle",
)}
>
{leftSlot != null && <div className="mr-auto min-w-0">{leftSlot}</div>}
<HStack space={2} justifyContent="end" className="ml-auto shrink-0">
{actions.map((action, i) => (
<Button
// Labels change with state ("Cloning...", "Apply 3 Changes"), so they can't be keys
key={i}
type={action.form != null ? "submit" : "button"}
form={action.form}
color={action.color ?? "secondary"}
variant={action.variant ?? (action.color == null ? "border" : "solid")}
disabled={action.disabled}
isLoading={action.isLoading}
autoFocus={action.autoFocus}
leftSlot={action.leftSlot}
rightSlot={action.rightSlot}
onClick={action.onClick}
>
{action.label}
</Button>
))}
</HStack>
</footer>
);
return slot == null ? footer : createPortal(footer, slot);
}
@@ -2,11 +2,12 @@ import type { Color } from "@yaakapp-internal/plugins";
import type { BannerProps } from "@yaakapp-internal/ui";
import { Banner } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { MouseEvent } from "react";
import type { MouseEvent, ReactNode } from "react";
import { useEffect } from "react";
import { useKeyValue } from "../../hooks/useKeyValue";
import type { ButtonProps } from "./Button";
import { Button } from "./Button";
import { IconButton } from "./IconButton";
type DismissibleBannerSize = "sm" | "xs";
@@ -18,17 +19,21 @@ export function DismissibleBanner({
onDismiss,
onShow,
actions,
variant = "banner",
...props
}: BannerProps & {
id: string;
size?: DismissibleBannerSize;
onDismiss?: () => void | Promise<void>;
onShow?: () => void | Promise<void>;
/** A tip dismisses with an X in the corner instead of a text button */
variant?: "banner" | "tip";
actions?: {
label: string;
onClick: () => void;
color?: Color;
variant?: ButtonProps["variant"];
rightSlot?: ReactNode;
}[];
}) {
const {
@@ -56,54 +61,69 @@ export function DismissibleBanner({
event.preventDefault();
event.stopPropagation();
};
const dismiss = (event: MouseEvent) => {
stopParentClick(event);
setDismissed(true).catch(console.error);
Promise.resolve(onDismiss?.()).catch(console.error);
};
const isTip = variant === "tip";
const hasActionRow = !isTip || (actions?.length ?? 0) > 0;
return (
<Banner
className={classNames(
className,
"relative",
size === "xs" && "!px-2 !py-2 text-xs",
)}
className={classNames(className, "relative", size === "xs" && "!px-2 !py-2 text-xs")}
{...props}
>
<div className="@container">
<div className={classNames("@container", isTip && "relative")}>
{isTip && (
<IconButton
icon="x"
size="2xs"
iconSize="sm"
title="Dismiss message"
className="absolute! -top-0.5 -right-1 @[34rem]:top-1/2 @[34rem]:-translate-y-1/2 opacity-50 hover:opacity-100"
onClick={dismiss}
/>
)}
<div
className={classNames(
"grid @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center",
isTip && "@[34rem]:pr-5",
size === "xs" ? "gap-1.5 @[34rem]:gap-2" : "gap-2 @[34rem]:gap-3",
)}
>
{children}
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
<Button
variant="border"
color={props.color}
size={actionSize}
onClick={(event) => {
stopParentClick(event);
setDismissed(true).catch(console.error);
Promise.resolve(onDismiss?.()).catch(console.error);
}}
title="Dismiss message"
>
Dismiss
</Button>
{actions?.map((a) => (
<Button
key={a.label}
variant={a.variant ?? "border"}
color={a.color ?? props.color}
size={actionSize}
onClick={(event) => {
stopParentClick(event);
a.onClick();
}}
title={a.label}
>
{a.label}
</Button>
))}
</div>
{isTip ? <div className="pr-5 @[34rem]:pr-0">{children}</div> : children}
{hasActionRow && (
<div className="flex flex-col gap-1.5 @[16rem]:flex-row @[16rem]:flex-wrap @[16rem]:justify-end">
{!isTip && (
<Button
variant="border"
color={props.color}
size={actionSize}
onClick={dismiss}
title="Dismiss message"
>
Dismiss
</Button>
)}
{actions?.map((a) => (
<Button
key={a.label}
rightSlot={a.rightSlot}
variant={a.variant ?? "border"}
color={a.color ?? props.color}
size={actionSize}
onClick={(event) => {
stopParentClick(event);
a.onClick();
}}
title={a.label}
>
{a.label}
</Button>
))}
</div>
)}
</div>
</div>
</Banner>
@@ -442,21 +442,7 @@ function EditorInner({
? EditorState.fromJSON(cachedJsonState, config, stateFields)
: EditorState.create(config);
// PERF: Built detached and attached in a microtask, rather than passing `parent` and
// letting the constructor attach it first.
//
// The constructor sets `contenteditable` on the content element and then reads
// `document.fonts.ready`. Both force the browser to flush style and layout when the
// element is already in the document, and whatever the previous editor attached has
// just dirtied it again — so mounting n editors in one commit costs n forced layouts.
// The environment editor mounts two per variable row (~60 of them), which is where its
// half-second open went.
//
// Building detached moves that work off the live document, and deferring the attach to
// a microtask means every editor in the commit is built before any of them is inserted:
// one forced layout for the batch instead of one each. The microtask still runs before
// paint, so nothing is visible in the meantime.
const view = new EditorView({ state });
const view = new EditorView({ state, parent: container });
// For large documents, the parser may parse the max number of lines and fail to add
// things like fold markers because of it.
@@ -464,26 +450,12 @@ function EditorInner({
forceParsing(view, 9e6, 100);
cm.current = { view, languageCompartment };
queueMicrotask(() => {
// The editor was torn down (or replaced) before we got here
if (cm.current?.view !== view) return;
// Runs after the surrounding try/catch has returned, so it needs its own
try {
container.appendChild(view.dom);
// Everything the constructor measured, it measured detached
view.requestMeasure();
if (autoFocus) {
view.focus();
}
if (autoSelect) {
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
} catch (e) {
console.log("Failed to attach Codemirror", e);
}
});
if (autoFocus) {
view.focus();
}
if (autoSelect) {
view.dispatch({ selection: { anchor: 0, head: view.state.doc.length } });
}
setRef?.(view);
} catch (e) {
console.log("Failed to initialize Codemirror", e);
@@ -1,46 +0,0 @@
import type { EditorView } from "@codemirror/view";
import { describe, expect, test } from "vite-plus/test";
import { jsonParseLinter } from "./json-lint";
// The linter only reads the document text off the view
function view(doc: string): EditorView {
return { state: { doc: { toString: () => doc } } } as EditorView;
}
const lint = jsonParseLinter();
describe("jsonParseLinter", () => {
test("reports nothing for an empty document", () => {
expect(lint(view(" "))).toEqual([]);
});
test("reports nothing for valid JSON", () => {
expect(lint(view('{"a": 1}'))).toEqual([]);
});
test("reports a message for invalid JSON", () => {
expect(lint(view('{"a": }'))).toHaveLength(1);
});
test("ignores a template tag standing in for a value", () => {
expect(lint(view('{"a": "${[ my_var ]}"}'))).toEqual([]);
});
test("ignores a tag whose quoted argument contains `]}`", () => {
expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}"}`))).toEqual([]);
});
test("ignores a tag whose quoted argument contains an escaped quote", () => {
expect(lint(view(`{"a": "\${[ fn(arg='it\\'s ]}') ]}"}`))).toEqual([]);
});
test("still reports errors around a tag with a quoted `]}`", () => {
expect(lint(view(`{"a": "\${[ fn(arg='x]}y') ]}",}`))).toHaveLength(1);
});
test("keeps the tag's length so error offsets stay correct", () => {
const doc = `{"a" "\${[ fn(arg='x]}y') ]}"}`;
const [error] = lint(view(doc));
expect(error?.from).toEqual(doc.indexOf('"${['));
});
});
@@ -1,7 +1,8 @@
import type { Diagnostic } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { type ParseError, parse, printParseErrorCode } from "jsonc-parser";
import { replaceTemplateTags } from "../../../lib/templateTags";
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
// jsonc-parser reports error codes, so these are the words the editor shows for them
const MESSAGES: Record<string, string> = {
@@ -31,11 +32,9 @@ interface JsonLintOptions {
export function jsonParseLinter(options?: JsonLintOptions) {
return (view: EditorView): Diagnostic[] => {
const doc = view.state.doc.toString();
if (doc.trim() === "") return [];
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = replaceTemplateTags(doc, (m) => "1".repeat(m.end - m.start));
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
const errors: ParseError[] = [];
parse(escapedDoc, errors, {
@@ -1,41 +0,0 @@
import { syntaxTree } from "@codemirror/language";
import type { Extension } from "@codemirror/state";
import type { EditorView, ViewUpdate } from "@codemirror/view";
import { ViewPlugin } from "@codemirror/view";
import type { JsonPathSegment } from "./jsonPath";
import { jsonPathSegmentsAt } from "./jsonPath";
export interface BreadcrumbUpdate {
/** Path to the cursor, or `null` when the document isn't JSON. */
segments: JsonPathSegment[] | null;
}
/**
* Reports the JSON path under the cursor when the editor is created (it may restore a
* cached selection), and whenever the selection, document, or parsed tree changes. The
* tree matters because large documents parse in the background, so a path read early
* can be wrong until the parser reaches the cursor.
*
* The callback is not fired on blur: moving focus to the filter box (by clicking
* a crumb) must not erase the breadcrumb that was just acted on.
*/
export function jsonBreadcrumbExtension(onUpdate: (update: BreadcrumbUpdate) => void): Extension {
const report = (view: EditorView) => {
onUpdate({ segments: jsonPathSegmentsAt(view.state, view.state.selection.main.head) });
};
return ViewPlugin.define((view) => {
report(view);
return {
update(update: ViewUpdate) {
if (
update.selectionSet ||
update.docChanged ||
syntaxTree(update.startState) !== syntaxTree(update.state)
) {
report(update.view);
}
},
};
});
}
@@ -1,136 +0,0 @@
import { EditorState } from "@codemirror/state";
import { jsonc } from "@shopify/lang-jsonc";
import { forceParsing } from "@codemirror/language";
import { describe, expect, test } from "vite-plus/test";
import { jsonPathSegmentsAt, jsonPathToSegments, segmentsToJsonPath } from "./jsonPath";
function stateFor(doc: string): EditorState {
const state = EditorState.create({ doc, extensions: [jsonc()] });
// Ensure the whole doc is parsed so resolveInner sees a complete tree.
forceParsing({ state } as never, doc.length, 5000);
return state;
}
/** JSONPath produced when the cursor sits just after the first occurrence of `needle`. */
function pathAt(doc: string, needle: string): string {
const pos = doc.indexOf(needle) + needle.length;
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
if (segs == null) throw new Error("no JSON tree");
return segmentsToJsonPath(segs);
}
/** JSONPath produced when the cursor sits just before the first occurrence of `needle`. */
function pathBefore(doc: string, needle: string): string {
const pos = doc.indexOf(needle);
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
if (segs == null) throw new Error("no JSON tree");
return segmentsToJsonPath(segs);
}
describe("jsonPathSegmentsAt", () => {
test("nested object keys", () => {
const doc = `{ "address": { "geo": { "lat": "-37" } } }`;
expect(pathAt(doc, `-37`)).toBe(`$.address.geo.lat`);
});
test("array element index", () => {
const doc = `{ "featured": [ { "id": 1 }, { "id": 2 } ] }`;
expect(pathAt(doc, `2`)).toBe(`$.featured[1].id`);
});
test("root array element", () => {
const doc = `[ { "id": 1 }, { "id": 2 } ]`;
expect(pathAt(doc, `"id": 2`)).toBe(`$[1].id`);
});
test("key containing a dot uses bracket-quote notation", () => {
const doc = `{ "sort": { "link": { "filter": { "category.id": [ "100" ] } } } }`;
expect(pathAt(doc, `"100"`)).toBe(`$.sort.link.filter["category.id"][0]`);
});
test("cursor on a property name resolves that key", () => {
const doc = `{ "outer": { "inner": 5 } }`;
expect(pathAt(doc, `"inner"`)).toBe(`$.outer.inner`);
});
test("deeply nested arrays and objects", () => {
const doc = `{ "a": [ { "b": [ 10, 20, 30 ] } ] }`;
expect(pathAt(doc, `30`)).toBe(`$.a[0].b[2]`);
});
test("root scalar yields the root path", () => {
expect(pathAt(`"hello"`, `hello`)).toBe(`$`);
});
test("caret just before a key still resolves that key", () => {
// The character to the left is the object's whitespace, not the property.
const doc = `[\n {\n "userId": 1,\n "body": "x"\n }\n]`;
expect(pathBefore(doc, `"body"`)).toBe(`$[0].body`);
});
test("caret in a line's indentation resolves that line's member", () => {
// Caret in the blank indentation of the `title` line (structurally the
// object's whitespace) reads as being on `title`, not a neighbour.
const doc = `[\n {\n "userId": 1,\n "id": 1,\n "title": "x"\n }\n]`;
const pos = doc.indexOf(`"title"`) - 2; // two spaces into the title line
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0].title`);
});
test("caret at the end of an array element keeps the element index", () => {
// The caret sits after `}` where the left token is the object's close brace.
const doc = `[\n { "id": 1 },\n { "id": 2 }\n]`;
const pos = doc.indexOf(`},`) + 1;
const segs = jsonPathSegmentsAt(stateFor(doc), pos);
expect(segs == null ? null : segmentsToJsonPath(segs)).toBe(`$[0]`);
});
test("non-JSON documents return null", () => {
const state = EditorState.create({ doc: `<xml/>` });
expect(jsonPathSegmentsAt(state, 3)).toBeNull();
});
});
describe("segmentsToJsonPath", () => {
test("empty segment list is the root", () => {
expect(segmentsToJsonPath([])).toBe(`$`);
});
test("partial path via count", () => {
const segs = [
{ kind: "key", key: "a" },
{ kind: "index", index: 2 },
{ kind: "key", key: "b" },
] as const;
expect(segmentsToJsonPath([...segs], 2)).toBe(`$.a[2]`);
});
});
describe("jsonPathToSegments", () => {
test("round-trips paths built by segmentsToJsonPath", () => {
for (const path of ["$", "$[0]", "$[0].id", '$.data["user.name"][12]', '$["say \\"hi\\""]']) {
const segments = jsonPathToSegments(path);
expect(segments).not.toBeNull();
expect(segmentsToJsonPath(segments ?? [])).toBe(path);
}
});
test("returns null for paths that don't name a single location", () => {
for (const path of [
"$..id",
"$[*]",
"$.items[?(@.id)]",
"$[0:2]",
"items[0]",
"$.a b",
'$["\\q"]',
'$["unterminated]',
'$["line\nbreak"]',
"$[01]",
"$[9007199254740993]",
"$.$",
]) {
expect(jsonPathToSegments(path)).toBeNull();
}
});
});
@@ -1,102 +0,0 @@
import { syntaxTree } from "@codemirror/language";
import type { EditorState } from "@codemirror/state";
import type { SyntaxNode } from "@lezer/common";
import type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
export { jsonPathToSegments } from "@yaakapp-internal/lib/jsonPath";
export type { JsonPathSegment } from "@yaakapp-internal/lib/jsonPath";
// Lezer node names from the JSONC grammar (@shopify/lang-jsonc). A JSON value is
// exactly one of these; everything else in the tree is punctuation or a comment.
const VALUE_NODES = new Set(["Object", "Array", "String", "Number", "True", "False", "Null"]);
// The nodes with children that a caret can land "between" — its whitespace and
// punctuation belong to the container, not to any value inside it.
const CONTAINER_NODES = new Set(["Object", "Array", "JsoncText"]);
/** Read a `PropertyName` (a quoted JSON string) back to its raw key. */
function keyFromPropertyName(node: SyntaxNode, state: EditorState): string {
const raw = state.doc.sliceString(node.from, node.to);
try {
return JSON.parse(raw) as string;
} catch {
// A half-typed or malformed key still deserves a best-effort label.
return raw.replace(/^"|"$/g, "");
}
}
/** Index of `child` among the value elements of its parent `Array` node. */
function indexInArray(array: SyntaxNode, child: SyntaxNode): number {
let idx = 0;
for (let c = array.firstChild; c != null; c = c.nextSibling) {
if (c.from === child.from && c.to === child.to) return idx;
if (VALUE_NODES.has(c.name)) idx++;
}
return idx;
}
/**
* The path from the document root to the member on the caret's line, or `null`
* when the document has no JSON tree (a different language, or empty). An empty
* array means the caret sits at the root value itself.
*
* A caret exactly on a token uses that token. A caret in a line's whitespace
* resolves to the enclosing object or array, so it's re-anchored to the first
* non-whitespace character on that line — in pretty-printed JSON each member
* starts its own line, so that's the member's own token. This tracks "the line
* I'm on" without guessing between neighbouring keys.
*
* From the anchor, each ancestor contributes a segment: a `Property` its key, a
* value directly inside an `Array` its index. Collected deepest-first, reversed.
*/
export function jsonPathSegmentsAt(state: EditorState, pos: number): JsonPathSegment[] | null {
const tree = syntaxTree(state);
if (tree.topNode.name !== "JsoncText") return null;
let node: SyntaxNode = tree.resolveInner(pos, -1);
if (CONTAINER_NODES.has(node.name)) {
const line = state.doc.lineAt(pos);
const indent = line.text.length - line.text.trimStart().length;
if (indent < line.text.length) {
node = tree.resolveInner(line.from + indent, 1);
}
}
const segments: JsonPathSegment[] = [];
let cur: SyntaxNode | null = node;
while (cur != null) {
const parent: SyntaxNode | null = cur.parent;
if (cur.name === "Property") {
const nameNode = cur.getChild("PropertyName");
if (nameNode != null) {
segments.push({ kind: "key", key: keyFromPropertyName(nameNode, state) });
}
} else if (parent != null && parent.name === "Array" && VALUE_NODES.has(cur.name)) {
segments.push({ kind: "index", index: indexInArray(parent, cur) });
}
cur = parent;
}
segments.reverse();
return segments;
}
const BARE_KEY = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
/** A single segment as JSONPath, choosing dot vs. bracket form for a key. */
function segmentToJsonPath(segment: JsonPathSegment): string {
if (segment.kind === "index") return `[${segment.index}]`;
// jsonpath-plus interprets a bare `$` as its root operator.
if (BARE_KEY.test(segment.key) && segment.key !== "$") return `.${segment.key}`;
// Anything with a dot, space, quote, etc. must be a quoted bracket accessor.
// JSON.stringify gives correct double-quoting and escaping, matching the
// convention the JSONPath filter box already displays.
return `[${JSON.stringify(segment.key)}]`;
}
/**
* Build a JSONPath expression for the first `count` segments (all of them by
* default). `segmentsToJsonPath([])` is the root, `$`.
*/
export function segmentsToJsonPath(segments: JsonPathSegment[], count = segments.length): string {
return "$" + segments.slice(0, count).map(segmentToJsonPath).join("");
}
@@ -1,155 +0,0 @@
/* oxlint-disable no-template-curly-in-string */
import { CompletionContext } from "@codemirror/autocomplete";
import { json } from "@codemirror/lang-json";
import { EditorState, type Extension, type TransactionSpec } from "@codemirror/state";
import type { EditorView } from "@codemirror/view";
import { describe, expect, test } from "vite-plus/test";
import { singleLineExtensions } from "../singleLine";
import { twigCompletion, type TwigCompletionOption } from "./completion";
import { parser } from "./twig";
const options: TwigCompletionOption[] = [
{ name: "someVar", label: "someVar", type: "variable", value: "value", onClick: () => {} },
{ name: "uuid.v4", label: "uuid.v4", type: "function", args: [], value: null, onClick: () => {} },
];
const source = twigCompletion({ options });
// Both keyboard acceptance and clicking a suggestion invoke Completion.apply.
function complete(input: string, label: string, extensions: Extension = []) {
const cursor = input.indexOf("|");
if (cursor < 0) throw new Error("Missing cursor marker");
let state = EditorState.create({
doc: input.slice(0, cursor) + input.slice(cursor + 1),
selection: { anchor: cursor },
extensions,
});
const result = source(new CompletionContext(state, cursor, false));
const completion = result?.options.find((option) => option.label === label);
expect(completion).toBeDefined();
const view = {
get state() {
return state;
},
dispatch(spec: TransactionSpec) {
state = state.update(spec).state;
},
} as EditorView;
if (typeof completion?.apply !== "function" || !result) throw new Error("Missing apply");
completion.apply(view, completion, result.from, cursor);
return state;
}
function expectValidTag(state: EditorState, expected: string) {
expect(state.doc.toString()).toBe(expected);
const tags: string[] = [];
parser.parse(expected).iterate({
enter(node) {
expect(node.type.isError).toBe(false);
if (node.name === "Tag") tags.push(expected.slice(node.from, node.to));
},
});
expect(tags).toHaveLength(1);
}
describe("twig completion", () => {
test.each(["${[\nsome|", "${[ \n\n\tsome|", "${[\nsome|\n ]}", "${[\n |"])(
"completes across multiline whitespace: %s",
(input) => expectValidTag(complete(input, "someVar"), "${[ someVar ]}"),
);
test.each(["${[\nuuid.v|", "${[\n uuid.v|()\n ]}"])(
"completes a function across multiline whitespace: %s",
(input) => expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}"),
);
test("preserves surrounding lines and literal pipes", () => {
expect(complete("before\n${[\nsome|\n]}\na | b", "someVar").doc.toString()).toBe(
"before\n${[ someVar ]}\na | b",
);
});
test("does not search past non-whitespace for an opener or closer", () => {
expect(complete("prefix\nsome|\nother ]}", "someVar").doc.toString()).toBe(
"prefix\n${[ someVar ]}\nother ]}",
);
expect(complete("${[some|\nother ]}", "someVar").doc.toString()).toBe(
"${[ someVar ]}\nother ]}",
);
});
test.each(["${[|", "${[ |", "${[ |"])(
"offers completion immediately after a template opener: %s",
(input) => {
expectValidTag(complete(input, "someVar"), "${[ someVar ]}");
},
);
test.each(["$", "{", "${", '{"value": "'])(
"does not automatically offer empty-name completions after %s",
(doc) => {
const state = EditorState.create({ doc });
expect(source(new CompletionContext(state, doc.length, false))).toBeNull();
},
);
test.each(["uuid|", "${[uuid|", "${[ uuid| ]}"])(
"completes a namespace before wrapping the final function: %s",
(input) => {
const namespace = complete(input, "uuid.*");
expect(namespace.doc.toString()).toBe(input.replace("uuid|", "uuid."));
const cursor = namespace.selection.main.head;
const next = namespace.sliceDoc(0, cursor) + "|" + namespace.sliceDoc(cursor);
expectValidTag(complete(next, "uuid.v4"), "${[ uuid.v4() ]}");
},
);
test.each(["some|", "${[some|", "${[ some|", "${[ someVar|", "${[\tsome|"])(
"completes a variable in a single-line value: %s",
(input) => {
const state = complete(input, "someVar", singleLineExtensions());
expectValidTag(state, "${[ someVar ]}");
expect(state.selection.main.head).toBe(state.doc.length);
},
);
test.each(["${[some|]}", "${[ some| ]}", "${[someVar|]}"])(
"reuses closing delimiters: %s",
(input) => {
expectValidTag(complete(input, "someVar"), "${[ someVar ]}");
},
);
test.each(["uuid.v|", "${[uuid.v|", "${[ uuid.v| ]}", "${[uuid.v|()]}"])(
"completes a function: %s",
(input) => {
expectValidTag(complete(input, "uuid.v4"), "${[ uuid.v4() ]}");
},
);
test("preserves surrounding JSON and its closing punctuation", () => {
expectValidTag(
complete('{"value":"${[some|","other":1}', "someVar", json()),
'{"value":"${[ someVar ]}","other":1}',
);
});
test("preserves surrounding text and other complete tags", () => {
expect(complete("${[ someVar ]} ${[some|]} trailing", "someVar").doc.toString()).toBe(
"${[ someVar ]} ${[ someVar ]} trailing",
);
});
test("does not consume closing punctuation without a template opener", () => {
expect(complete("[some|]}", "someVar").doc.toString()).toBe("[${[ someVar ]}]}");
});
test("invalidates cached suggestions when typing delimiters or a namespace separator", () => {
const state = EditorState.create({ doc: "some" });
const result = source(new CompletionContext(state, 4, false));
expect(result?.validFor).toBeInstanceOf(RegExp);
const validFor = result?.validFor as RegExp;
expect(validFor.test("someVar")).toBe(true);
expect(validFor.test("some ${[some")).toBe(false);
expect(validFor.test("uuid.")).toBe(false);
});
});
@@ -1,6 +1,5 @@
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import { startCompletion } from "@codemirror/autocomplete";
import type { EditorState } from "@codemirror/state";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
const openTag = "${[ ";
@@ -37,22 +36,6 @@ export interface TwigCompletionConfig {
const MIN_MATCH_NAME = 1;
// Only inspect adjacent whitespace, including newlines, rather than copying the
// entire document to look for a delimiter in large request bodies.
function skipWhitespace(state: EditorState, pos: number, direction: -1 | 1) {
while (direction === -1 ? pos > 0 : pos < state.doc.length) {
const from = direction === -1 ? pos - 1 : pos;
if (!/\s/.test(state.sliceDoc(from, from + 1))) break;
pos += direction;
}
return pos;
}
function findOpenTag(state: EditorState, from: number) {
const end = skipWhitespace(state, from, -1);
return end >= 3 && state.sliceDoc(end - 3, end) === "${[" ? end - 3 : null;
}
export function twigCompletion({ options }: TwigCompletionConfig) {
return function completions(context: CompletionContext) {
const toStartOfName = context.matchBefore(/[\w_.]*/);
@@ -61,8 +44,7 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
if (toMatch === null) return null;
const matchLen = toMatch.to - toMatch.from;
const hasOpenTag = findOpenTag(context.state, toMatch.from) !== null;
if (!context.explicit && !hasOpenTag && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
if (!context.explicit && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
return null;
}
@@ -101,16 +83,6 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
detail: o.type,
type: o.type === "variable" ? "variable" : "function",
apply: (view, _completion, from, to) => {
// Keep the completion range name-only for filtering, but replace a manually
// typed opener when accepting (through either the keyboard or the menu).
const opener = findOpenTag(view.state, from);
if (opener !== null) {
from = opener;
const hasParentheses =
o.type === "function" && view.state.sliceDoc(to, to + 2) === "()";
const closer = skipWhitespace(view.state, to + (hasParentheses ? 2 : 0), 1);
if (view.state.sliceDoc(closer, closer + 2) === "]}") to = closer + 2;
}
const insert = openTag + inner + closeTag;
view.dispatch({
changes: { from, to, insert: insert },
@@ -131,8 +103,7 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
return {
matchLen,
// Delimiters change the replacement range; dots change namespace options.
validFor: /^\w*$/,
validFor: () => true, // Not really sure why this is all it needs
from: toMatch.from,
options: sortedCompletions,
};
@@ -4,6 +4,4 @@ export const highlight = styleTags({
TagOpen: t.bracket,
TagClose: t.bracket,
TagContent: t.keyword,
// Strings are part of the tag body, so they get the same color as the rest of it
TagString: t.keyword,
});
@@ -1,17 +1,13 @@
@top Template { (Tag | Text)* }
// `TagString` keeps a quoted argument from closing the tag, so `${[ fn(a='x]}y') ]}` stays a
// single Tag. Only a terminated string matches, so a half-typed `'` degrades to TagContent and
// the tag still closes at the next `]}`.
@local tokens {
TagClose { "]}" }
TagString { "'" (![\\'] | "\\" _)* "'" }
@else TagContent
}
@skip { } {
TagOpen { "${[" }
Tag { TagOpen (TagContent | TagString)+ TagClose }
Tag { TagOpen (TagContent)+ TagClose }
}
@tokens {
@@ -3,6 +3,5 @@ export const Template = 1,
Tag = 2,
TagOpen = 3,
TagContent = 4,
TagString = 5,
TagClose = 6,
Text = 7;
TagClose = 5,
Text = 6;
@@ -23,10 +23,6 @@ function hasError(input: string): boolean {
return getNodeNames(input).includes("⚠");
}
function countTags(input: string): number {
return getNodeNames(input).filter((n) => n === "Tag").length;
}
describe("twig grammar", () => {
describe("${[var]} format (valid template tags)", () => {
test("parses simple variable as Tag", () => {
@@ -87,50 +83,6 @@ describe("twig grammar", () => {
});
});
describe("quoted strings inside tags", () => {
test("keeps a quoted `]}` inside the tag", () => {
const input = "${[ fn(a='x]}y') ]}";
expect(countTags(input)).toBe(1);
expect(hasError(input)).toBe(false);
});
test("keeps an escaped quote inside the string", () => {
const input = "${[ fn(a='it\\'s ]}') ]}";
expect(countTags(input)).toBe(1);
expect(hasError(input)).toBe(false);
});
test("parses a b64 string as one Tag", () => {
const input = "${[ b64'Zm9v' ]}";
expect(countTags(input)).toBe(1);
expect(hasError(input)).toBe(false);
});
test("closes at the first `]}` when a quote is left unterminated", () => {
// Half-typed strings must not swallow the rest of the document
const input = "${[ fn(a='unterminated ]} after";
expect(countTags(input)).toBe(1);
expect(hasError(input)).toBe(false);
const tag = parser.parse(input).topNode.getChild("Tag");
expect(input.slice(tag?.from ?? 0, tag?.to ?? 0)).toBe("${[ fn(a='unterminated ]}");
});
test("handles two tags with quoted `]}` in the same string", () => {
const input = "${[ a(x='1]}') ]} and ${[ b(y='2]}') ]}";
expect(countTags(input)).toBe(2);
expect(hasError(input)).toBe(false);
});
test("keeps JSON around a tag with a quoted `]}` intact", () => {
const tag = "${[ fn(a='x]}y') ]}";
const json = '{"a": "' + tag + '"}';
expect(countTags(json)).toBe(1);
expect(hasError(json)).toBe(false);
const node = parser.parse(json).topNode.getChild("Tag");
expect(json.slice(node?.from ?? 0, node?.to ?? 0)).toBe(tag);
});
});
describe("edge cases", () => {
test("handles $ at end of string", () => {
expect(hasError("hello$")).toBe(false);
@@ -4,24 +4,17 @@ import { highlight } from "./highlight";
export const parser = LRParser.deserialize({
version: 14,
states:
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cd'#CdQQOPOOOOOO'#Ce'#CeObOQO,58xOOOO-E6b-E6bOOOO-E6c-E6cOOOO1G.d1G.d",
stateData: "m~OVROZPO~OSTOTTO~OSTOTTOUXO~O",
goto: "oYPPZ_PPPPciTROSTQOSQSORVSQUQRWU",
nodeNames: "⚠ Template Tag TagOpen TagContent TagString TagClose Text",
maxTerm: 11,
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
stateData: "g~OUROYPO~OSTO~OSTOTXO~O",
goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU",
nodeNames: "⚠ Template Tag TagOpen TagContent TagClose Text",
maxTerm: 10,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 2,
tokenData:
"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTV~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOV~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OZ~",
tokenizers: [
1,
new LocalTokenGroup(
"#R~RQwxX#P#Q!u~[VOwXwxqx#OX#O#Pv#P;'SX;'S;=`!o<%lOX~vOT~~yRO;'SX;'S;=`!S;=`OX~!VWOwXwxqx#OX#O#Pv#P;'SX;'S;=`!o;=`<%lX<%lOX~!rP;=`<%lX~!xP#q#r!{~#QOU~~",
94,
4,
),
],
"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~",
tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
topRules: { Template: [0, 1] },
tokenPrec: 0,
});
@@ -0,0 +1,78 @@
import { platform } from "@yaakapp-internal/platform";
import { Icon } from "@yaakapp-internal/ui";
import type { ReactNode } from "react";
import { setKeyValue } from "../../lib/keyValueStore";
import { DismissibleBanner } from "./DismissibleBanner";
interface Props {
/** Stable id; the dismissal is remembered under it. */
id: string;
children: ReactNode;
docsUrl?: string;
action?: { label: string; onClick: () => void | Promise<void> };
onDismiss?: () => void;
className?: string;
}
/**
* A one-time nudge toward a feature the user hasn't found yet. Dismissed once, gone for good.
*/
export function FeatureHint({ id, children, docsUrl, action, onDismiss, className }: Props) {
const actions = [
...(action ? [{ ...action, variant: "solid" as const, color: "info" as const }] : []),
...(action != null && docsUrl != null
? [
{
label: "Docs",
rightSlot: <Icon icon="external_link" size="xs" className="opacity-60" />,
onClick: () => platform.openUrl(docsUrl).catch(console.error),
},
]
: []),
];
return (
<DismissibleBanner
id={`feature-hint:${id}`}
variant="tip"
color="info"
size="xs"
onDismiss={onDismiss}
className={className}
actions={actions}
>
<div className="flex items-start gap-2 text-xs">
<Icon icon="sparkles" size="xs" className="hidden @[16rem]:block mt-0.5 shrink-0" />
<div className="min-w-0">
{children}
{action == null && docsUrl != null && (
<>
{" "}
(
<button
type="button"
className="group inline-flex items-center gap-0.5 underline hover:text-info cursor-pointer! [&_*]:cursor-pointer!"
onClick={() => platform.openUrl(docsUrl).catch(console.error)}
>
docs
<Icon
icon="external_link"
size="xs"
className="opacity-80 group-hover:opacity-100"
/>
</button>
)
</>
)}
</div>
</div>
</DismissibleBanner>
);
}
export function dismissFeatureHint(id: string) {
return setKeyValue({
namespace: "global",
key: ["dismiss-banner", `feature-hint:${id}`],
value: true,
});
}
+1 -5
View File
@@ -1,5 +1,4 @@
import { Link as RouterLink } from "@tanstack/react-router";
import { platform } from "@yaakapp-internal/platform";
import classNames from "classnames";
import type { HTMLAttributes } from "react";
import { appInfo } from "../../lib/appInfo";
@@ -34,10 +33,7 @@ export function Link({ href, children, noUnderline, className, ...other }: Props
href={finalHref}
target="_blank"
rel={isYaakLink ? undefined : "noopener noreferrer"}
onClick={(e) => {
e.preventDefault();
void platform.openUrl(finalHref);
}}
onClick={(e) => e.preventDefault()}
className={className}
{...other}
>
@@ -1,124 +0,0 @@
import { Icon, type IconProps } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { forwardRef, type ReactNode } from "react";
import { Dropdown, type DropdownItem } from "./Dropdown";
import { IconButton } from "./IconButton";
export interface PathListItem {
path: string;
icon: IconProps["icon"];
/** Replaces the parent-path line under the name */
subtitle?: ReactNode;
failed?: boolean;
}
interface Props {
items: PathListItem[];
disabled?: boolean;
onRemove: (path: string) => void;
addItems: DropdownItem[];
addLabel: string;
addHint?: string;
hovering?: boolean;
/** Rows rendered between the items and the add row, such as an inline input */
children?: ReactNode;
}
/** Selected files, folders, or URLs as cards, with a dashed add row that opens a menu. */
export const PathList = forwardRef<HTMLDivElement, Props>(function PathList(
{ items, disabled, onRemove, addItems, addLabel, addHint, hovering, children },
ref,
) {
return (
<div
ref={ref}
className={classNames(
"flex flex-col gap-1.5 rounded-lg -m-1.5 p-1.5",
hovering && "bg-surface-highlight",
)}
>
{items.map((item) => {
const { name, parent } = splitPath(item.path);
return (
<div
key={item.path}
className={classNames(
"flex items-center gap-3 rounded-md pl-3 pr-1.5 py-2",
item.failed ? "bg-danger/10" : "bg-surface-highlight/50",
)}
>
<Icon
icon={item.icon}
className={classNames("shrink-0", item.failed ? "text-danger" : "text-text-subtle")}
/>
<div className="min-w-0 flex-1" title={item.path}>
<div className="truncate text-sm font-semibold">{name}</div>
<div
className={classNames(
"flex items-center gap-1.5 truncate text-xs",
item.failed ? "text-danger" : "text-text-subtlest",
)}
>
{item.subtitle ??
(parent !== "" && <span className="truncate font-mono">{parent}</span>)}
</div>
</div>
<IconButton
size="xs"
icon="trash"
iconColor="secondary"
title={`Remove ${name}`}
disabled={disabled}
className="shrink-0"
onClick={() => onRemove(item.path)}
/>
</div>
);
})}
{children}
<Dropdown items={addItems}>
<button
type="button"
disabled={disabled}
className={classNames(
"h-sm w-full flex items-center gap-2 rounded-md border border-dashed px-3 text-sm text-text-subtle",
"hocus:border-border-focus hocus:text-text disabled:opacity-disabled",
hovering ? "border-notice" : "border-border",
)}
>
<Icon icon="plus" size="sm" className="text-text-subtlest" />
{hovering ? (
"Drop to add"
) : (
<>
<span>{addLabel}</span>
{addHint != null && (
<span className="ml-auto text-xs text-text-subtlest">{addHint}</span>
)}
</>
)}
</button>
</Dropdown>
</div>
);
});
/** The last segment as the name and everything before it, shortened, as the parent. */
export function splitPath(path: string): { name: string; parent: string } {
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(path)) {
try {
const url = new URL(path);
const segments = url.pathname.split("/").filter(Boolean);
const name = segments.at(-1) ?? url.host;
const parent = segments.length > 0 ? `${url.host}/${segments.slice(0, -1).join("/")}` : "";
return { name, parent: parent.replace(/\/$/, "") };
} catch {
return { name: path, parent: "" };
}
}
const parts = path.split(/[/\\]/).filter(Boolean);
const name = parts.at(-1) ?? path;
const dirs = parts.slice(0, -1);
const parent = dirs.length > 3 ? `…/${dirs.slice(-3).join("/")}` : dirs.join("/");
return { name, parent };
}
+14 -9
View File
@@ -1,9 +1,10 @@
import type { FormInput, JsonPrimitive } from "@yaakapp-internal/plugins";
import { HStack } from "@yaakapp-internal/ui";
import type { FormEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { generateId } from "../../lib/generateId";
import { DynamicForm } from "../DynamicForm";
import { DialogFooter } from "./Dialog";
import { Button } from "./Button";
export interface PromptProps {
inputs: FormInput[];
@@ -47,15 +48,19 @@ export function Prompt({
const id = `prompt.form.${useRef(generateId()).current}`;
return (
<form id={id} className="grid grid-cols-[minmax(0,1fr)] mb-2" onSubmit={handleSubmit}>
<form
className="grid grid-rows-[auto_auto] grid-cols-[minmax(0,1fr)] gap-4 mb-4"
onSubmit={handleSubmit}
>
<DynamicForm inputs={inputs} onChange={setValue} data={value} stateKey={id} />
<DialogFooter
inline
actions={[
{ label: cancelText || "Cancel", onClick: onCancel },
{ label: confirmText || "Done", color: "primary", form: id },
]}
/>
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
{cancelText || "Cancel"}
</Button>
<Button type="submit" color="primary">
{confirmText || "Done"}
</Button>
</HStack>
</form>
);
}
@@ -2,6 +2,7 @@ import type { AnyModel } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import classNames from "classnames";
import type { ReactNode } from "react";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { CopyIconButton } from "../CopyIconButton";
import { Checkbox } from "./Checkbox";
import { IconButton, type IconButtonProps } from "./IconButton";
@@ -19,11 +20,31 @@ type SettingRowBaseProps = {
controlClassName?: string;
description?: ReactNode;
disabled?: boolean;
/** Matched against the surrounding `SettingsList` highlight to point this row out when it opens */
highlightKey?: string;
title: ReactNode;
};
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
return <div className={classNames("w-full", className)}>{children}</div>;
const SettingsHighlightContext = createContext<string | null>(null);
const FOCUSABLE =
"button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";
export function SettingsList({
children,
className,
highlight = null,
}: {
children: ReactNode;
className?: string;
/** The `highlightKey` of a row to scroll to, focus, and briefly highlight */
highlight?: string | null;
}) {
return (
<SettingsHighlightContext.Provider value={highlight}>
<div className={classNames("w-full", className)}>{children}</div>
</SettingsHighlightContext.Provider>
);
}
export function SettingsSection({
@@ -58,17 +79,37 @@ export function SettingRow({
controlClassName,
description,
disabled,
highlightKey,
title,
}: {
children: ReactNode;
} & SettingRowBaseProps) {
const highlight = useContext(SettingsHighlightContext);
const isHighlighted = highlightKey != null && highlightKey === highlight;
const [flashing, setFlashing] = useState(isHighlighted);
const rowRef = useRef<HTMLDivElement>(null);
const controlRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isHighlighted) return;
rowRef.current?.scrollIntoView({ block: "nearest" });
controlRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus();
}, [isHighlighted]);
return (
<div
ref={rowRef}
aria-disabled={disabled || undefined}
onAnimationEnd={(event) => {
if (event.target === event.currentTarget) setFlashing(false);
}}
className={classNames(
className,
"@container border-b border-border-subtle py-4",
disabled && "opacity-disabled",
// Drawn on a layer so the tint gets even padding and stops short of the row's border
flashing &&
"relative isolate before:absolute before:-inset-x-3 before:inset-y-1 before:-z-10 before:rounded-lg before:animate-settingHighlight",
)}
>
<div
@@ -84,6 +125,7 @@ export function SettingRow({
)}
</div>
<div
ref={controlRef}
className={classNames(
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
controlClassName,
@@ -0,0 +1,43 @@
import { HStack, VStack } from "@yaakapp-internal/ui";
import { useState } from "react";
import { Button } from "../core/Button";
import { Select } from "../core/Select";
interface Props {
branches: string[];
onCancel: () => void;
onSelect: (branch: string) => void;
selectText: string;
}
export function BranchSelectionDialog({ branches, onCancel, onSelect, selectText }: Props) {
const [branch, setBranch] = useState<string>("__NONE__");
return (
<VStack
className="mb-4"
as="form"
space={4}
onSubmit={(e) => {
e.preventDefault();
onSelect(branch);
}}
>
<Select
name="branch"
hideLabel
label="Branch"
value={branch}
options={branches.map((b) => ({ label: b, value: b }))}
onChange={setBranch}
/>
<HStack space={2} justifyContent="end">
<Button onClick={onCancel} variant="border" color="secondary">
Cancel
</Button>
<Button type="submit" color="primary">
{selectText}
</Button>
</HStack>
</VStack>
);
}
@@ -1,4 +1,4 @@
import type { GitStatus, GitStatusEntry } from "@yaakapp-internal/git";
import type { GitStatusEntry } from "@yaakapp-internal/git";
import { useGit } from "@yaakapp-internal/git";
import type {
Environment,
@@ -9,7 +9,8 @@ import type {
Workspace,
} from "@yaakapp-internal/models";
import { Banner, HStack, Icon, InlineCode, SplitLayout } from "@yaakapp-internal/ui";
import { type ComponentProps, useCallback, useMemo, useState } from "react";
import classNames from "classnames";
import { useCallback, useMemo, useState } from "react";
import { modelToYaml } from "../../lib/diffYaml";
import { trackFeatureUsage } from "../../lib/featureFeedback";
import { resolvedModelName } from "../../lib/resolvedModelName";
@@ -18,7 +19,6 @@ import { showErrorToast } from "../../lib/toast";
import { sync } from "../../init/sync";
import { CommercialUseBanner } from "../CommercialUseBanner";
import { Button } from "../core/Button";
import { Chip } from "../core/Chip";
import type { CheckboxProps } from "../core/Checkbox";
import { Checkbox } from "../core/Checkbox";
import type { CheckboxTreeNode } from "../core/CheckboxTree";
@@ -333,7 +333,16 @@ function CommitTreeRow({ node }: { node: CommitTreeNode }) {
)}
<div className="truncate flex-1">{resolvedModelName(node.model)}</div>
{node.status.status !== "current" && (
<Chip color={statusColor(node.status.status)}>{node.status.status}</Chip>
<InlineCode
className={classNames(
"py-0 bg-transparent w-24 text-center shrink-0",
node.status.status === "modified" && "text-info",
node.status.status === "untracked" && "text-success",
node.status.status === "removed" && "text-danger",
)}
>
{node.status.status}
</InlineCode>
)}
</>
);
@@ -367,9 +376,16 @@ function ExternalTreeNode({
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] gap-1 w-full items-center">
<Icon color="secondary" icon="file_code" />
<div className="truncate">{displayPath}</div>
<Chip className="ml-auto" color={statusColor(entry.status)}>
<InlineCode
className={classNames(
"py-0 ml-auto bg-transparent w-24 text-center",
entry.status === "modified" && "text-info",
entry.status === "untracked" && "text-success",
entry.status === "removed" && "text-danger",
)}
>
{entry.status}
</Chip>
</InlineCode>
</div>
}
/>
@@ -463,20 +479,3 @@ function DiffPanel({
</div>
);
}
function statusColor(status: GitStatus): ComponentProps<typeof Chip>["color"] {
switch (status) {
case "modified":
return "info";
case "untracked":
return "success";
case "removed":
return "danger";
case "conflict":
return "warning";
case "current":
case "renamed":
case "type_change":
return "default";
}
}
@@ -1,10 +1,8 @@
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
import type { WorkspaceMeta } from "@yaakapp-internal/models";
import classNames from "classnames";
import { useAtomValue } from "jotai";
import type { HTMLAttributes } from "react";
import { forwardRef, useCallback, useMemo } from "react";
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
import { useKeyValue } from "../../hooks/useKeyValue";
import { useRandomKey } from "../../hooks/useRandomKey";
@@ -18,6 +16,7 @@ import { showErrorToast, showToast } from "../../lib/toast";
import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
import { SyncWorkspaceHint } from "../hints/SyncWorkspaceHint";
import { useGitCallbacks } from "./callbacks";
import { GitCommitDialog } from "./GitCommitDialog";
import { GitRemotesDialog } from "./GitRemotesDialog";
@@ -31,7 +30,7 @@ export function GitDropdown() {
if (workspaceMeta == null) return null;
if (workspaceMeta.settingSyncDir == null) {
return <SetupSyncDropdown workspaceMeta={workspaceMeta} />;
return <SyncWorkspaceHint workspaceId={workspaceMeta.workspaceId} />;
}
return <SyncDropdownWithSyncDir syncDir={workspaceMeta.settingSyncDir} />;
@@ -567,64 +566,6 @@ const GitMenuButton = forwardRef<HTMLButtonElement, HTMLAttributes<HTMLButtonEle
},
);
function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta }) {
const { value: hidden, set: setHidden } = useKeyValue<Record<string, boolean>>({
key: "setup_sync",
fallback: {},
});
if (hidden == null || hidden[workspaceMeta.workspaceId]) {
return null;
}
const banner = (
<Banner color="info">
When enabled, workspace data syncs to the chosen folder as text files, ideal for backup and
Git collaboration.
</Banner>
);
return (
<Dropdown
fullWidth
items={[
{
type: "content",
label: banner,
},
{
color: "success",
label: "Open Workspace Settings",
leftSlot: <Icon icon="settings" />,
onSelect: () => openWorkspaceSettings("settings"),
},
{ type: "separator" },
{
label: "Hide This Message",
leftSlot: <Icon icon="eye_closed" />,
async onSelect() {
const confirmed = await showConfirm({
id: "hide-sync-menu-prompt",
title: "Hide Setup Message",
description: "You can configure filesystem sync or Git it in the workspace settings",
});
if (confirmed) {
await setHidden((prev) => ({ ...prev, [workspaceMeta.workspaceId]: true }));
}
},
},
]}
>
<GitMenuButton>
<div className="text-sm text-text-subtle grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2">
<Icon icon="wrench" />
<div className="truncate">Setup FS Sync or Git</div>
</div>
</GitMenuButton>
</Dropdown>
);
}
function SetupGitDropdown({
workspaceId,
initRepo,
+15 -15
View File
@@ -1,8 +1,8 @@
import type { DivergedStrategy } from "@yaakapp-internal/git";
import { InlineCode } from "@yaakapp-internal/ui";
import { HStack, InlineCode } from "@yaakapp-internal/ui";
import { useState } from "react";
import { showDialog } from "../../lib/dialog";
import { DialogFooter } from "../core/Dialog";
import { Button } from "../core/Button";
import { RadioCards } from "../core/RadioCards";
type Resolution = "force_reset" | "merge";
@@ -34,7 +34,7 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
};
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 mb-4">
<p className="text-text-subtle">
Your local branch has diverged from{" "}
<InlineCode>
@@ -59,18 +59,18 @@ function DivergedDialog({ remote, branch, onResult, onHide }: DivergedDialogProp
},
]}
/>
<DialogFooter
inline
actions={[
{ label: "Cancel", onClick: handleCancel },
{
label: selected != null ? resolutionLabel[selected] : "Select an option",
color: selected === "force_reset" ? "danger" : "primary",
disabled: selected == null,
onClick: handleSubmit,
},
]}
/>
<HStack space={2} justifyContent="start" className="flex-row-reverse">
<Button
color={selected === "force_reset" ? "danger" : "primary"}
disabled={selected == null}
onClick={handleSubmit}
>
{selected != null ? resolutionLabel[selected] : "Select an option"}
</Button>
<Button variant="border" onClick={handleCancel}>
Cancel
</Button>
</HStack>
</div>
);
}
@@ -0,0 +1,29 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { httpRequestsAtom } from "@yaakapp-internal/models";
import { InlineCode } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { FeatureHint } from "../core/FeatureHint";
const MIN_REQUESTS = 2;
const DOCS_URL = "https://yaak.app/docs/templating/request-chaining-scripting";
interface Props {
response: HttpResponse;
mimeType: string | null;
}
/** A successful JSON response, in a workspace with somewhere to send its values. */
export function ChainingHint({ response, mimeType }: Props) {
const requestCount = useAtomValue(httpRequestsAtom).length;
const status = response.status ?? 0;
const isJson = mimeType != null && /json/i.test(mimeType);
if (response.state !== "closed" || status < 200 || status >= 300) return null;
if (!isJson || requestCount < MIN_REQUESTS) return null;
return (
<FeatureHint id="chaining" className="mx-3 mt-1 shrink-0" docsUrl={DOCS_URL}>
Need a value from here in another request? Press <InlineCode>Ctrl+Space</InlineCode> in any
field and pick <InlineCode>response()</InlineCode> to pull it out with a JSONPath
</FeatureHint>
);
}
@@ -0,0 +1,44 @@
import { useEnvironmentsBreakdown } from "../../hooks/useEnvironmentsBreakdown";
import {
addVariableToBaseEnvironment,
isTemplate,
variableTemplate,
} from "../../lib/addVariableToBaseEnvironment";
import { dismissFeatureHint, FeatureHint } from "../core/FeatureHint";
const HINT_ID = "secret-variable";
const DOCS_URL = "https://yaak.app/docs/templating/environments-and-variables";
interface Props {
/** Becomes the variable name, so it should describe the value (eg. "password", "token") */
name: string;
value: string;
onReplace: (template: string) => void;
}
/**
* A literal secret typed straight into an auth field. Offer to move it to a variable so the
* request references it instead of holding it.
*/
export function SecretVariableHint({ name, value, onReplace }: Props) {
const { baseEnvironment } = useEnvironmentsBreakdown();
if (value.trim() === "" || isTemplate(value) || baseEnvironment == null) return null;
return (
<FeatureHint
id={HINT_ID}
docsUrl={DOCS_URL}
action={{
label: "Move to Variable",
onClick: async () => {
const finalName = await addVariableToBaseEnvironment(baseEnvironment, name, value);
onReplace(variableTemplate(finalName));
await dismissFeatureHint(HINT_ID);
},
}}
>
Typing this in by hand? Store it in a variable to reuse it across requests and swap it per
environment.
</FeatureHint>
);
}
@@ -0,0 +1,73 @@
import { httpRequestsAtom, patchModel } from "@yaakapp-internal/models";
import { InlineCode } from "@yaakapp-internal/ui";
import { useAtomValue } from "jotai";
import { useMemo } from "react";
import { useEnvironmentsBreakdown } from "../../hooks/useEnvironmentsBreakdown";
import {
addVariableToBaseEnvironment,
isTemplate,
variableTemplate,
} from "../../lib/addVariableToBaseEnvironment";
import { FeatureHint } from "../core/FeatureHint";
const MIN_SHARED_REQUESTS = 3;
const DOCS_URL = "https://yaak.app/docs/templating/environments-and-variables";
/**
* Once a few requests spell out the same literal origin, offer to hoist it into a variable and
* rewrite them all at once.
*/
export function SharedOriginHint() {
const requests = useAtomValue(httpRequestsAtom);
const { baseEnvironment } = useEnvironmentsBreakdown();
const shared = useMemo(() => {
const byOrigin = new Map<string, typeof requests>();
for (const r of requests) {
if (isTemplate(r.url)) continue;
const origin = literalOrigin(r.url);
if (origin == null) continue;
byOrigin.set(origin, [...(byOrigin.get(origin) ?? []), r]);
}
let best: { origin: string; requests: typeof requests } | null = null;
for (const [origin, group] of byOrigin) {
if (group.length >= MIN_SHARED_REQUESTS && group.length > (best?.requests.length ?? 0)) {
best = { origin, requests: group };
}
}
return best;
}, [requests]);
if (shared == null || baseEnvironment == null) return null;
const { origin } = shared;
const count = shared.requests.length;
return (
<FeatureHint
id={`shared-origin:${origin}`}
className="mt-1"
docsUrl={DOCS_URL}
action={{
label: "Use a Variable",
onClick: async () => {
const name = await addVariableToBaseEnvironment(baseEnvironment, "base_url", origin);
const template = variableTemplate(name);
await Promise.all(
shared.requests.map((r) =>
patchModel(r, { url: template + r.url.slice(origin.length) }),
),
);
},
}}
>
{count} requests start with <InlineCode>{origin}</InlineCode>. Put it in a variable and switch
hosts by switching environments.
</FeatureHint>
);
}
function literalOrigin(url: string): string | null {
const m = /^(https?:\/\/[^/?#\s]+)/i.exec(url.trim());
return m?.[1] ?? null;
}
@@ -0,0 +1,44 @@
import { VStack } from "@yaakapp-internal/ui";
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
import { useKeyValue } from "../../hooks/useKeyValue";
import { showToast } from "../../lib/toast";
import { FeatureHint } from "../core/FeatureHint";
const DOCS_URL = "https://yaak.app/docs/collaboration/local-directory-git-sync";
/** What the sidebar's Git slot shows while the workspace only lives in the app's database. */
export function SyncWorkspaceHint({ workspaceId }: { workspaceId: string }) {
// Hidden from the menu this replaced, so honor it
const { value: legacyHidden } = useKeyValue<Record<string, boolean>>({
key: "setup_sync",
fallback: {},
});
if (legacyHidden == null || legacyHidden[workspaceId]) return null;
return (
<FeatureHint
id={`sync-workspace:${workspaceId}`}
className="mx-2 mb-2!"
docsUrl={DOCS_URL}
action={{ label: "Configure", onClick: () => openWorkspaceSettings("settings", "sync_dir") }}
onDismiss={showSyncLocationToast}
>
Sync this workspace to a folder of plain files to back it up or track it with Git.
</FeatureHint>
);
}
function showSyncLocationToast() {
showToast({
id: "sync-hint-dismissed",
color: "info",
timeout: 8000,
message: (
<VStack>
<h2 className="font-semibold">Sync whenever you're ready</h2>
<p className="text-text-subtle text-sm">Directory sync and Git are in Workspace Settings</p>
</VStack>
),
});
}
@@ -3,13 +3,18 @@ import "react-pdf/dist/Page/AnnotationLayer.css";
import "./PdfViewer.css";
import type { PDFDocumentProxy } from "pdfjs-dist";
import { useMemo, useRef, useState } from "react";
import { Document, Page, pdfjs } from "react-pdf";
import { Document, Page } from "react-pdf";
import { useContainerSize } from "@yaakapp-internal/ui";
import pdfWorkerUrl from "./pdfWorker?worker&url";
import { fireAndForget } from "../../lib/fireAndForget";
// Document can start loading during render, so configure its worker synchronously.
// Vite bundles the installed PDF.js worker as a local asset, including offline builds.
pdfjs.GlobalWorkerOptions.workerSrc = new URL(pdfWorkerUrl, import.meta.url).toString();
fireAndForget(
import("react-pdf").then(({ pdfjs }) => {
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
}),
);
interface Props {
/** A URL for the body the host already stored. */
@@ -54,8 +59,6 @@ export function PdfViewer({ bodyUrl, data }: Props) {
return (
<div ref={containerRef} className="w-full h-full overflow-y-auto">
<Document
// Keep the copied data stable: suspending the initial mount discards useMemo.
suspense={false}
file={src}
options={options}
onLoadSuccess={onDocumentLoadSuccess}
@@ -1,192 +0,0 @@
import { Icon } from "@yaakapp-internal/ui";
import classNames from "classnames";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import type { JsonPathSegment } from "../core/Editor/json/jsonPath";
import { IconButton } from "../core/IconButton";
import { Tooltip } from "../core/Tooltip";
interface Props {
segments: JsonPathSegment[];
appliedFilter: string | null;
/**
* How many leading segments make up the applied filter. `null` when unfiltered, or
* when the filter isn't a plain path and is shown as raw text instead.
*/
appliedDepth: number | null;
filterError: boolean;
/** Called with the number of leading segments to filter to. `0` clears the filter. */
onSelect: (count: number) => void;
}
/**
* Shows where the cursor sits inside a response (`$ > sort > [0] > id`). An applied
* plain-path filter is the start of the trail, so any crumb moves it. While filtered,
* the bar is tinted so a lingering filter is hard to miss.
*/
export function ResponseBreadcrumbBar({
segments,
appliedFilter,
appliedDepth,
filterError,
onSelect,
}: Props) {
const scrollRef = useRef<HTMLDivElement>(null);
const [overflow, setOverflow] = useState({ left: false, right: false });
const measure = useCallback(() => {
const el = scrollRef.current;
if (el == null) return;
const maxScroll = el.scrollWidth - el.clientWidth;
setOverflow({
left: el.scrollLeft > 1,
right: el.scrollLeft < maxScroll - 1,
});
}, []);
// Keep the deepest crumb (where the cursor is) in view as the path changes.
useLayoutEffect(() => {
const el = scrollRef.current;
if (el == null) return;
el.scrollLeft = el.scrollWidth;
measure();
}, [segments, measure]);
useEffect(() => {
const el = scrollRef.current;
if (el == null) return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [measure]);
const scrollBy = useCallback((direction: -1 | 1) => {
const el = scrollRef.current;
if (el == null) return;
el.scrollBy({ left: direction * Math.max(120, el.clientWidth * 0.6), behavior: "smooth" });
}, []);
const isFiltered = appliedFilter != null;
return (
<div className="rounded bg-surface shadow pointer-events-auto">
<div
className={classNames(
"flex items-stretch h-6 rounded border overflow-hidden font-mono text-xs text-text-subtle select-none",
!isFiltered && "border-border-subtle",
isFiltered && !filterError && "border-primary/40 bg-primary/10",
filterError && "border-danger/40 bg-danger/10",
)}
>
{isFiltered && appliedDepth == null ? (
<span
className={classNames(
"flex items-center min-w-0 truncate px-2",
filterError ? "text-danger" : "text-primary",
)}
>
{appliedFilter}
</span>
) : (
<Crumb
label="$"
tooltip="Clear filter"
isApplied={isFiltered}
onClick={appliedDepth != null && appliedDepth > 0 ? () => onSelect(0) : null}
/>
)}
{overflow.left && (
<IconButton
size="xs"
icon="chevron_left"
title="Scroll breadcrumbs left"
iconColor="secondary"
onClick={() => scrollBy(-1)}
className="shrink-0 h-auto!"
/>
)}
<div
ref={scrollRef}
onScroll={measure}
className="flex items-stretch min-w-0 overflow-x-auto hide-scrollbars whitespace-nowrap"
>
{segments.map((segment, i) => {
const count = i + 1;
const isApplied = appliedDepth != null && count <= appliedDepth;
const isClickable = (!isFiltered || appliedDepth != null) && count !== appliedDepth;
return (
<div key={i} className="flex items-stretch shrink-0">
<Icon
icon="chevron_right"
size="xs"
className="text-text-subtlest shrink-0 self-center"
/>
<Crumb
label={segment.kind === "index" ? `[${segment.index}]` : segment.key}
tooltip={
segment.kind === "index"
? `Filter to element ${segment.index}`
: `Filter to ${segment.key}`
}
isApplied={isApplied}
onClick={isClickable ? () => onSelect(count) : null}
/>
</div>
);
})}
</div>
{overflow.right && (
<IconButton
size="xs"
icon="chevron_right"
title="Scroll breadcrumbs right"
iconColor="secondary"
onClick={() => scrollBy(1)}
className="shrink-0 h-auto!"
/>
)}
</div>
</div>
);
}
function Crumb({
label,
tooltip,
isApplied,
onClick,
}: {
label: string;
tooltip: string;
isApplied: boolean;
onClick: (() => void) | null;
}) {
const outerClassName = classNames("flex items-center px-0.5 py-0.5", isApplied && "text-primary");
const innerClassName = "flex items-center h-full px-1 rounded-sm";
if (onClick == null) {
return (
<span className={classNames(outerClassName, "shrink-0")}>
<span className={innerClassName}>{label}</span>
</span>
);
}
return (
<Tooltip content={tooltip} className="shrink-0 items-stretch!">
<button
type="button"
onClick={onClick}
className={classNames(outerClassName, "group/crumb focus-visible:outline-none")}
>
<span
className={classNames(
innerClassName,
"transition-colors group-hover/crumb:text-text group-hover/crumb:bg-surface-highlight",
"group-focus-visible/crumb:ring-1 group-focus-visible/crumb:ring-border-focus",
)}
>
{label}
</span>
</button>
</Tooltip>
);
}
@@ -1,18 +1,18 @@
import type { ReactNode } from "react";
import { Children, useCallback, useMemo, useState } from "react";
import { Children, useCallback, useMemo } from "react";
import { Banner, HStack, Icon, InlineCode } from "@yaakapp-internal/ui";
import { useFormatText } from "../../hooks/useFormatText";
import type { ResponseFilterApi } from "../../hooks/useResponseFilter";
import { Button } from "../core/Button";
import type { EditorProps } from "../core/Editor/Editor";
import { jsonBreadcrumbExtension } from "../core/Editor/json/breadcrumbExtension";
import type { JsonPathSegment } from "../core/Editor/json/jsonPath";
import { jsonPathToSegments, segmentsToJsonPath } from "../core/Editor/json/jsonPath";
import { hyperlink } from "../core/Editor/hyperlink/extension";
import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import { Input } from "../core/Input";
import { ResponseBreadcrumbBar } from "./ResponseBreadcrumbBar";
import { RecentFiltersDropdown } from "./RecentFiltersDropdown";
const extraExtensions = [hyperlink];
interface Props {
text: string;
language: EditorProps["language"];
@@ -38,43 +38,12 @@ export function TextViewer({
filter,
filterResult,
}: Props) {
// Track the JSON path under the cursor to drive the breadcrumb bar. Selection
// works even in this read-only editor, so it updates as the user clicks around.
const [breadcrumbSegments, setBreadcrumbSegments] = useState<JsonPathSegment[]>([]);
const handleBreadcrumbUpdate = useCallback(
({ segments }: { segments: JsonPathSegment[] | null }) =>
setBreadcrumbSegments((prev) => {
const next = segments ?? [];
return segmentsToJsonPath(prev) === segmentsToJsonPath(next) ? prev : next;
}),
[],
);
const extraExtensions = useMemo(
() =>
language === "json"
? [hyperlink, jsonBreadcrumbExtension(handleBreadcrumbUpdate)]
: [hyperlink],
[language, handleBreadcrumbUpdate],
);
const canFilter =
filter != null && (language === "json" || language === "xml" || language === "html");
const isSearching = filter?.isSearching ?? false;
const appliedFilter = filter?.appliedFilter ?? null;
const resultError = filterResult?.error ?? false;
// Filter output is always an array of matches, so under a plain-path filter the
// cursor's leading index is that single match and the rest extends the filter.
const appliedSegments =
appliedFilter != null && language === "json" ? jsonPathToSegments(appliedFilter) : null;
const pathSegments =
appliedSegments != null
? [...appliedSegments, ...breadcrumbSegments.slice(1)]
: breadcrumbSegments;
const showBreadcrumbs =
filter != null &&
(appliedFilter != null || (language === "json" && breadcrumbSegments.length > 0));
const handleFilterKeyDown = useCallback(
(e: KeyboardEvent) => {
if (filter == null) return;
@@ -186,7 +155,16 @@ export function TextViewer({
}
return (
<div className="relative h-full w-full">
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full w-full">
{appliedFilter && filter != null ? (
<AppliedFilterBar
filter={appliedFilter}
error={resultError}
onClear={() => filter.replaceFilter("")}
/>
) : (
<span />
)}
<Editor
readOnly
className={className}
@@ -196,23 +174,45 @@ export function TextViewer({
extraExtensions={extraExtensions}
stateKey={stateKey}
/>
{showBreadcrumbs && (
<div className="absolute top-0 right-3 max-w-[70%] pointer-events-none">
<ResponseBreadcrumbBar
segments={language === "json" ? pathSegments : []}
appliedFilter={appliedFilter}
appliedDepth={appliedSegments?.length ?? null}
filterError={resultError}
onSelect={(count) =>
filter.replaceFilter(count === 0 ? "" : segmentsToJsonPath(pathSegments, count))
}
/>
</div>
)}
</div>
);
}
/**
* Shows what's actually filtering the body, which the filter box below can't convey
* once it holds an edited expression that hasn't been applied yet.
*/
function AppliedFilterBar({
filter,
error,
onClear,
}: {
filter: string;
error: boolean;
onClear: () => void;
}) {
return (
<Banner color={error ? "danger" : "info"} className="py-1! mb-2! text-sm">
<HStack space={2} className="min-w-0">
<Icon icon="filter" size="xs" className="shrink-0 opacity-70" />
<span className="truncate min-w-0" title={filter}>
Response filtered by <InlineCode>{filter}</InlineCode>
{error && " (invalid expression)"}
</span>
<Button
size="2xs"
variant="border"
color={error ? "danger" : "info"}
className="ml-auto shrink-0"
onClick={onClear}
>
Clear
</Button>
</HStack>
</Banner>
);
}
/** Convert \uXXXX to actual Unicode characters */
function decodeUnicodeLiterals(text: string): string {
return text.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => {
@@ -1,3 +0,0 @@
// The worker has its own globals, so it needs the polyfills before PDF.js evaluates
import "../../polyfills";
import "pdfjs-dist/build/pdf.worker.min.mjs";
+1
View File
@@ -23,6 +23,7 @@ export function useExportData() {
id: "export-data",
title: "Export Data",
size: "md",
noPadding: true,
render: ({ hide }) => (
<ExportDataDialog
onHide={hide}
@@ -1,133 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
const mocks = vi.hoisted(() => ({
platform: { httpProxyUrl: "https://web.yaak.test" as string | null },
showConfirm: vi.fn<() => Promise<boolean>>(),
flushAllModelWrites: vi.fn(),
rpc: vi.fn(),
}));
vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform }));
vi.mock("@yaakapp-internal/models", () => ({ flushAllModelWrites: mocks.flushAllModelWrites }));
vi.mock("../lib/confirm", () => ({ showConfirm: mocks.showConfirm }));
vi.mock("../lib/appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } }));
vi.mock("../lib/rpc", () => ({ rpc: mocks.rpc }));
vi.mock("../lib/toast", () => ({ showToast: vi.fn() }));
vi.mock("./useActiveCookieJar", () => ({ getActiveCookieJar: () => ({ id: "cj_test" }) }));
vi.mock("./useActiveEnvironment", () => ({ getActiveEnvironment: () => ({ id: "ev_test" }) }));
async function sender() {
return (await import("./useSendAnyHttpRequest")).sendAnyHttpRequest;
}
describe("web proxy confirmation before sending", () => {
let stored: Map<string, string>;
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
mocks.platform.httpProxyUrl = "https://web.yaak.test";
mocks.rpc.mockResolvedValue({ id: "rs_test" });
stored = new Map();
vi.stubGlobal("localStorage", {
getItem: (key: string) => stored.get(key) ?? null,
setItem: (key: string, value: string) => stored.set(key, value),
});
});
afterEach(() => vi.unstubAllGlobals());
test("blocks all pending sends until one shared confirmation is accepted", async () => {
let decide!: (accepted: boolean) => void;
mocks.showConfirm.mockReturnValue(
new Promise((resolve) => {
decide = resolve;
}),
);
const send = await sender();
const first = send.mutateAsync("rq_one");
const second = send.mutateAsync("rq_two");
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
expect(mocks.flushAllModelWrites).not.toHaveBeenCalled();
expect(mocks.rpc).not.toHaveBeenCalled();
decide(true);
await Promise.all([first, second]);
expect(mocks.rpc).toHaveBeenCalledTimes(2);
expect(mocks.rpc).toHaveBeenCalledWith("cmd_send_http_request", {
requestId: "rq_one",
environmentId: "ev_test",
cookieJarId: "cj_test",
});
expect(stored.size).toBe(1);
});
test("cancel sends nothing, remembers nothing, and asks again next time", async () => {
mocks.showConfirm.mockResolvedValue(false);
const send = await sender();
expect(await send.mutateAsync("rq_one")).toBeNull();
expect(await send.mutateAsync("rq_one")).toBeNull();
expect(mocks.showConfirm).toHaveBeenCalledTimes(2);
expect(mocks.flushAllModelWrites).not.toHaveBeenCalled();
expect(mocks.rpc).not.toHaveBeenCalled();
expect(stored.size).toBe(0);
});
test("cancel applies to every send waiting on the dialog", async () => {
mocks.showConfirm.mockResolvedValue(false);
const send = await sender();
expect(await Promise.all([send.mutateAsync("rq_one"), send.mutateAsync("rq_two")])).toEqual([
null,
null,
]);
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
expect(mocks.rpc).not.toHaveBeenCalled();
});
test("remembers acceptance across module reloads for the same proxy", async () => {
mocks.showConfirm.mockResolvedValue(true);
await (await sender()).mutateAsync("rq_one");
vi.resetModules();
await (await sender()).mutateAsync("rq_two");
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
expect(mocks.rpc).toHaveBeenCalledTimes(2);
});
test("requires a new decision when the proxy changes", async () => {
mocks.showConfirm.mockResolvedValueOnce(true).mockResolvedValueOnce(false);
const send = await sender();
await send.mutateAsync("rq_one");
mocks.platform.httpProxyUrl = "https://another-proxy.test";
expect(await send.mutateAsync("rq_two")).toBeNull();
expect(mocks.showConfirm).toHaveBeenCalledTimes(2);
expect(mocks.rpc).toHaveBeenCalledTimes(1);
});
test("still asks and remembers in this tab if browser storage is unavailable", async () => {
const unavailable = () => {
throw new Error("Storage unavailable");
};
vi.stubGlobal("localStorage", { getItem: unavailable, setItem: unavailable });
mocks.showConfirm.mockResolvedValue(true);
const send = await sender();
await send.mutateAsync("rq_one");
await send.mutateAsync("rq_two");
expect(mocks.showConfirm).toHaveBeenCalledTimes(1);
expect(mocks.rpc).toHaveBeenCalledTimes(2);
});
test("desktop sends directly without confirmation", async () => {
mocks.platform.httpProxyUrl = null;
await (await sender()).mutateAsync("rq_one");
expect(mocks.showConfirm).not.toHaveBeenCalled();
expect(mocks.rpc).toHaveBeenCalledTimes(1);
expect(stored.size).toBe(0);
});
test("an empty selection does not prompt or send", async () => {
expect(await (await sender()).mutateAsync(null)).toBeNull();
expect(mocks.showConfirm).not.toHaveBeenCalled();
expect(mocks.rpc).not.toHaveBeenCalled();
});
});
@@ -1,6 +1,5 @@
import type { HttpResponse } from "@yaakapp-internal/models";
import { flushAllModelWrites } from "@yaakapp-internal/models";
import { confirmWebProxy } from "../lib/confirmWebProxy";
import { rpc } from "../lib/rpc";
import { getActiveCookieJar } from "./useActiveCookieJar";
import { getActiveEnvironment } from "./useActiveEnvironment";
@@ -11,10 +10,6 @@ async function sendAnyHttpRequestById(id: string | null): Promise<HttpResponse |
return null;
}
if (!(await confirmWebProxy())) {
return null;
}
await flushAllModelWrites();
return rpc("cmd_send_http_request", {
-4
View File
@@ -3,9 +3,6 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" href="/icon-128.png" sizes="128x128" />
<link rel="apple-touch-icon" href="/icon-128.png" />
<title>Yaak App</title>
<!-- <script src="http://localhost:8097"></script>-->
@@ -31,7 +28,6 @@
<div id="cm-portal" class="cm-portal"></div>
<div id="react-portal"></div>
<div id="radix-portal" class="cm-portal"></div>
<script type="module" src="/polyfills.ts"></script>
<script type="module" src="/theme.ts"></script>
<script type="module" src="/font-size.ts"></script>
<script type="module" src="/font.ts"></script>
@@ -0,0 +1,31 @@
import type { Environment } from "@yaakapp-internal/models";
import { patchModel } from "@yaakapp-internal/models";
import { generateId } from "./generateId";
/** Adds a variable, suffixing the name if it's taken, and returns the name that was used. */
export async function addVariableToBaseEnvironment(
environment: Environment,
name: string,
value: string,
): Promise<string> {
const taken = new Set(environment.variables.map((v) => v.name));
let finalName = name;
for (let i = 2; taken.has(finalName); i++) {
finalName = `${name}_${i}`;
}
await patchModel(environment, {
variables: [
...environment.variables,
{ id: generateId(), name: finalName, value, enabled: true },
],
});
return finalName;
}
export function variableTemplate(name: string) {
return `\${[ ${name} ]}`;
}
export function isTemplate(value: string) {
return value.includes("${[");
}
@@ -1,64 +0,0 @@
import { renderToStaticMarkup } from "react-dom/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vite-plus/test";
import type { DialogInstance } from "../components/Dialogs";
const mocks = vi.hoisted(() => ({
platform: { httpProxyUrl: "https://web.yaak.app" },
showDialog: vi.fn<(dialog: DialogInstance) => void>(),
}));
vi.mock("@yaakapp-internal/platform", () => ({ platform: mocks.platform }));
vi.mock("./appInfo", () => ({ appInfo: { identifier: "app.yaak.web" } }));
vi.mock("./dialog", () => ({ showDialog: mocks.showDialog }));
vi.mock("../hooks/useHotKey", () => ({
useHotKey: vi.fn(),
useFormattedHotkey: () => null,
}));
describe("proxy consent disclosure", () => {
beforeEach(() => {
vi.resetModules();
vi.clearAllMocks();
vi.stubGlobal("localStorage", { getItem: () => null });
});
afterEach(() => vi.unstubAllGlobals());
test.each([
["Yaak-hosted", "https://web.yaak.app"],
["self-hosted", "https://yaak.example.com"],
["local", "http://localhost:8080"],
["split deployment", "https://send.example.com/relay"],
])("identifies the %s proxy and requires an explicit decision", async (_, proxyUrl) => {
mocks.platform.httpProxyUrl = proxyUrl;
const { confirmWebProxy } = await import("./confirmWebProxy");
void confirmWebProxy();
const dialog = mocks.showDialog.mock.calls[0]?.[0];
expect(dialog).toMatchObject({
id: "web-proxy-consent",
title: "Requests in Yaak Web use a proxy",
size: "sm",
disableClose: true,
});
if (dialog == null) throw new Error("Expected the proxy consent dialog");
const disclosure = renderToStaticMarkup(<>{dialog.description}</>);
expect(disclosure).toContain(`>${proxyUrl}</code>`);
expect(disclosure).toMatch(/request and response data/i);
expect(disclosure).toContain("credentials");
expect(disclosure).toContain("server shown below");
expect(disclosure).not.toContain("Yaak’s servers");
expect(disclosure).toContain(
'href="https://yaak.app/docs/getting-started/web-proxy?ref=app.yaak.web"',
);
expect(disclosure).toContain('target="_blank"');
expect(disclosure).toContain("how the proxy works");
const Actions = dialog.render;
const actions = renderToStaticMarkup(<Actions hide={vi.fn()} />);
expect(actions).toMatch(/<button[^>]*type="submit"/);
expect(actions).toContain(">Send via Proxy<");
expect(actions).toContain(">Cancel<");
});
});
-60
View File
@@ -1,60 +0,0 @@
import { platform } from "@yaakapp-internal/platform";
import { InlineCode } from "@yaakapp-internal/ui";
import { Link } from "../components/core/Link";
import { showConfirm } from "./confirm";
const acceptedProxies = new Set<string>();
const pendingConfirmations = new Map<string, Promise<boolean>>();
/** One decision for all pending sends; only acceptance is remembered. */
export function confirmWebProxy(): Promise<boolean> {
const proxyUrl = platform.httpProxyUrl;
if (proxyUrl == null) return Promise.resolve(true);
const key = `yaak.webProxyConsent.v1:${proxyUrl}`;
try {
if (localStorage.getItem(key) === "accepted") return Promise.resolve(true);
} catch {
// Storage can be unavailable in private or restricted browsing.
}
if (acceptedProxies.has(proxyUrl)) return Promise.resolve(true);
const pending = pendingConfirmations.get(proxyUrl);
if (pending != null) return pending;
const confirmation = showConfirm({
id: "web-proxy-consent",
title: "Requests in Yaak Web use a proxy",
confirmText: "Send via Proxy",
description: (
<div className="space-y-3">
<p>
Yaak Web sends requests through a hosted proxy. Request and response data, including
credentials, pass through the server shown below.
</p>
<p>
<InlineCode className="break-all">{proxyUrl}</InlineCode>
</p>
<p>
Learn more about{" "}
<Link href="https://yaak.app/docs/getting-started/web-proxy">how the proxy works</Link>.
</p>
</div>
),
})
.then((accepted) => {
if (accepted) {
acceptedProxies.add(proxyUrl);
try {
localStorage.setItem(key, "accepted");
} catch {
// Keep acceptance for this tab if it cannot be persisted.
}
}
return accepted;
})
.finally(() => pendingConfirmations.delete(proxyUrl));
pendingConfirmations.set(proxyUrl, confirmation);
return confirmation;
}
+4 -4
View File
@@ -1,4 +1,4 @@
import { MIMEType } from "whatwg-mimetype";
import MimeType from "whatwg-mimetype";
import type { EditorProps } from "../components/core/Editor/Editor";
export function languageFromContentType(
@@ -90,10 +90,10 @@ export function isProbablyTextContentType(contentType: string | null): boolean {
].some((textType) => normalized === textType || normalized.endsWith(textType));
}
export function getMimeTypeFromContentType(contentType: string): MIMEType {
export function getMimeTypeFromContentType(contentType: string): MimeType {
try {
return new MIMEType(contentType);
return new MimeType(contentType);
} catch {
return new MIMEType("text/plain");
return new MimeType("text/plain");
}
}
-3
View File
@@ -1,3 +0,0 @@
export function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
+4 -1
View File
@@ -1,5 +1,4 @@
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
import { errorMessage } from "./errorMessage";
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
@@ -46,3 +45,7 @@ export function tryBuildIntrospectionFromFile(
};
}
}
function errorMessage(e: unknown): string {
return e instanceof Error ? e.message : String(e);
}
+9 -15
View File
@@ -8,7 +8,7 @@ import {
import { FormattedError, VStack } from "@yaakapp-internal/ui";
import { Button } from "../components/core/Button";
import { ImportDataDialog } from "../components/ImportDataDialog";
import type { ImportSourcePath } from "../components/ImportSourceList";
import { activeFolderAtom } from "../hooks/useActiveFolder";
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
import { createFastMutation } from "../hooks/useFastMutation";
import { showAlert } from "./alert";
@@ -20,17 +20,10 @@ import { rpc } from "./rpc";
// Stable identities so the dialog's effects don't re-run (and cancel in-flight
// fetches) every time the dialog container re-renders.
const planSources = (sources: ImportSourcePath[], destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", {
filePaths: sources.filter((source) => source.kind !== "url").map((source) => source.path),
urls: sources.filter((source) => source.kind === "url").map((source) => source.path),
destination,
});
const detectSource = (source: ImportSourcePath) =>
rpc<string>(
"cmd_detect_import_source",
source.kind === "url" ? { url: source.path } : { filePath: source.path },
);
const planFile = (filePath: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_data", { filePath, destination });
const planUrl = (url: string, destination: ImportDestination) =>
rpc<ImportPlan>("cmd_import_url", { url, destination });
const listSources = (workspaceId: string) =>
rpc<ImportSource[]>("cmd_list_import_sources", { workspaceId });
const findSourcesForOrigin = (args: { filePath?: string; url?: string }) =>
@@ -50,11 +43,11 @@ export const importData = createFastMutation({
return new Promise<void>((resolve, reject) => {
const currentWorkspace = jotaiStore.get(activeWorkspaceAtom);
const workspaces = jotaiStore.get(workspacesAtom);
const selectedFolder = jotaiStore.get(activeFolderAtom);
showDialog({
id: "import",
title: "Import Data",
size: "lg",
className: "h-[36rem]",
disableClose: true,
render: ({ hide }) => {
const cancel = () => {
@@ -75,8 +68,9 @@ export const importData = createFastMutation({
<ImportDataDialog
currentWorkspace={currentWorkspace}
workspaces={workspaces}
planSources={planSources}
detectSource={detectSource}
selectedFolder={selectedFolder}
planFile={planFile}
planUrl={planUrl}
listSources={listSources}
findSourcesForOrigin={findSourcesForOrigin}
commit={commit}
+22 -12
View File
@@ -6,6 +6,7 @@ import type { SniffedValue } from "../components/core/Editor/sniffValue";
import { isEncodedRun } from "../components/core/Editor/sniffValue";
import { copyToClipboard } from "./copy";
import { fireAndForget } from "./fireAndForget";
import { rpc } from "./rpc";
import { showToast } from "./toast";
import { platform } from "@yaakapp-internal/platform";
@@ -211,26 +212,35 @@ export function copyImage(text: string, sniffed: SniffedValue, fallback: string)
});
}
/** Base64 of a byte array, in chunks so a few megabytes don't blow the argument limit. */
function toBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
/**
* Writes a collapsed value to a file the user picks.
*
* A value that is already base64 is handed over as base64. These are the values
* the editor collapsed for being large, so decoding one here only for a host to
* A value that is already base64 goes straight to the backend as-is — decoding it here only to
* encode it again would walk megabytes twice for nothing.
*/
export async function saveValue(text: string, sniffed: SniffedValue | null, name: string) {
const ext = sniffed == null ? "txt" : (mime.getExtension(sniffed.mime) ?? "bin");
const content =
sniffed == null
? new TextEncoder().encode(text)
: sniffed.encoding === "base64"
? { base64: normalizeBase64(payloadOf(text, sniffed)) }
: decodeValue(text, sniffed);
const savedTo = await platform.files.save(`${name}.${ext}`, content);
if (savedTo == null) {
const filepath = await platform.dialog.save({ defaultPath: `${name}.${ext}`, title: "Save Value" });
if (filepath == null) {
return; // Cancelled
}
showToast({ message: `Saved to ${savedTo}` });
const data =
sniffed == null
? toBase64(new TextEncoder().encode(text))
: sniffed.encoding === "base64"
? normalizeBase64(payloadOf(text, sniffed))
: toBase64(decodeValue(text, sniffed));
await rpc("cmd_save_base64_to_binary", { filepath, data });
showToast({ message: `Saved to ${filepath}` });
}
-44
View File
@@ -1,44 +0,0 @@
import type { BatchUpsertResult } from "@yaakapp-internal/models";
import { createGlobalModel } from "@yaakapp-internal/models";
import { router } from "./router";
import { rpc } from "./rpc";
import { setKeyValue } from "./keyValueStore";
export type OnboardingChoice =
| "import"
| "import_yaak"
| "example"
| "fresh"
| "open_folder"
| "clone_git";
const NEW_WORKSPACE_NAME = "My Workspace";
/**
* Remembered so later surfaces can lean toward what the user came for (an importer, the
* example, or a blank slate). Nothing reads it yet.
*/
export function recordOnboardingChoice(choice: OnboardingChoice) {
setKeyValue({
namespace: "global",
key: "onboarding",
value: { choice, at: new Date().toISOString() },
}).catch(console.error);
}
export async function createFreshWorkspace() {
const workspaceId = await createGlobalModel({ model: "workspace", name: NEW_WORKSPACE_NAME });
await router.navigate({ to: "/workspaces/$workspaceId", params: { workspaceId } });
}
export async function createExampleWorkspace() {
const created = await rpc<BatchUpsertResult>("cmd_create_example_workspace", {});
const workspace = created.workspaces[0];
if (workspace == null) throw new Error("Example workspace was not created");
const firstRequest = created.httpRequests.find((r) => r.name === "List posts");
await router.navigate({
to: "/workspaces/$workspaceId",
params: { workspaceId: workspace.id },
search: firstRequest ? { request_id: firstRequest.id } : {},
});
}
@@ -1,79 +0,0 @@
/* oxlint-disable no-template-curly-in-string */
/*
* Shared expectations for `resolvedModelName`.
*
* The request template function plugin carries a hand-copied `resolvedModelName` and template-tag
* scanner, because importing this module into the plugin bundle fails to build on Windows in CI.
* Both `resolvedModelName.test.ts` here and `plugins/template-function-request/tests/
* resolvedModelName.test.ts` iterate these cases, so changing an expectation on this side
* automatically exercises the plugin's copy and a drifting copy fails its own suite.
*
* Keep this file dependency-free — the plugin test imports it by relative path, so anything it
* pulls in would have to resolve from both packages.
*/
export interface ResolvedModelNameCase {
/** Test name, used as the `test.each` title on both sides. */
name: string;
/** `url` of the `http_request` under test. */
url: string;
/** `name` of the `http_request`, when the case is about an explicit name. */
requestName?: string;
/** What `resolvedModelName` must return. */
expected: string;
}
export const resolvedModelNameCases: ResolvedModelNameCase[] = [
{
name: "returns the name when there is one",
url: "https://example.com",
requestName: "My Request",
expected: "My Request",
},
{
name: "replaces a simple tag with its contents",
url: "${[ base_url ]}/users",
expected: "base_url/users",
},
{
name: "replaces a tag containing a quoted argument with a space",
url: "${[ fn(arg='my key') ]}/users",
expected: "fn(arg='my key')/users",
},
{
name: "replaces each tag in a multi-tag string",
url: "${[ fn(arg='my key') ]}/a/${[ other(b='x y') ]}/b",
expected: "fn(arg='my key')/a/other(b='x y')/b",
},
{
name: "keeps a quoted `]}` inside the tag",
url: "${[ fn(arg='x]}y') ]}/users",
expected: "fn(arg='x]}y')/users",
},
{
name: "keeps an escaped quote inside the tag",
url: "${[ fn(arg='it\\'s ]}') ]}/users",
expected: "fn(arg='it\\'s ]}')/users",
},
{
name: "closes at the first `]}` when a quote is left unterminated",
url: "${[ fn(arg='oops ]}/users",
expected: "fn(arg='oops/users",
},
{
name: "stops each tag at its first closing bracket",
url: "${[ a ]}${[ b ]}",
expected: "ab",
},
{
name: "strips the protocol",
url: "https://example.com/${[ path ]}",
expected: "example.com/path",
},
{
name: "falls back to a generic name when the url is empty",
url: "",
expected: "HTTP Request",
},
];
@@ -1,21 +0,0 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { describe, expect, test, vi } from "vite-plus/test";
import { resolvedModelNameCases } from "./resolvedModelName.cases";
// resolvedModelName only reads plain model fields, but its module imports pull in the
// platform singleton, which needs a browser. Stub them out to keep this a pure unit test.
vi.mock("@yaakapp-internal/models", () => ({ foldersAtom: {} }));
vi.mock("./jotai", () => ({ jotaiStore: { get: () => [] } }));
const { resolvedModelName } = await import("./resolvedModelName");
function httpRequest(url: string, name = ""): HttpRequest {
return { id: "rq_test", model: "http_request", name, url } as HttpRequest;
}
describe("resolvedModelName", () => {
// Shared with the plugin's hand-copied scanner. See `resolvedModelName.cases.ts`.
test.each(resolvedModelNameCases)("$name", ({ url, requestName, expected }) => {
expect(resolvedModelName(httpRequest(url, requestName))).toEqual(expected);
});
});
+1 -2
View File
@@ -1,7 +1,6 @@
import type { AnyModel } from "@yaakapp-internal/models";
import { foldersAtom } from "@yaakapp-internal/models";
import { jotaiStore } from "./jotai";
import { replaceTemplateTags } from "./templateTags";
export function resolvedModelName(r: AnyModel | null): string {
if (r == null) return "";
@@ -16,7 +15,7 @@ export function resolvedModelName(r: AnyModel | null): string {
}
// Replace variable syntax with variable name
const withoutVariables = replaceTemplateTags(r.url, (m) => m.inner.trim());
const withoutVariables = r.url.replace(/\$\{\[\s*([^\]\s]+)\s*]}/g, "$1");
if (withoutVariables.trim() === "") {
return r.model === "http_request"
? r.bodyType && r.bodyType === "graphql"
-176
View File
@@ -1,176 +0,0 @@
/* oxlint-disable no-template-curly-in-string */
import { describe, expect, test } from "vite-plus/test";
import { findTemplateTags, replaceTemplateTags } from "./templateTags";
/** Seeded so a fuzz failure reproduces. */
function random(seed: number): () => number {
let state = seed;
return () => {
state = (state + 0x6d2b79f5) | 0;
let t = Math.imul(state ^ (state >>> 15), 1 | state);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function millis(run: () => void): number {
const started = performance.now();
run();
return performance.now() - started;
}
describe("findTemplateTags", () => {
test("finds a simple tag", () => {
expect(findTemplateTags("${[ my_var ]}")).toEqual([{ start: 0, end: 13, inner: " my_var " }]);
});
test("finds a tag surrounded by text", () => {
const text = 'a="${[ my_var ]}"';
expect(findTemplateTags(text)).toEqual([{ start: 3, end: 16, inner: " my_var " }]);
expect(text.slice(3, 16)).toEqual("${[ my_var ]}");
});
test("finds an empty tag", () => {
expect(findTemplateTags("${[]}")).toEqual([{ start: 0, end: 5, inner: "" }]);
});
test("keeps a quoted `]}` inside the tag", () => {
const text = "${[ fn(a='x]}y') ]}";
expect(findTemplateTags(text)).toEqual([
{ start: 0, end: text.length, inner: " fn(a='x]}y') " },
]);
});
test("keeps an escaped quote inside the string", () => {
const text = "${[ fn(a='it\\'s ]}') ]}";
expect(findTemplateTags(text)).toEqual([
{ start: 0, end: text.length, inner: " fn(a='it\\'s ]}') " },
]);
});
test("keeps a b64 string in one tag", () => {
const text = "${[ b64'Zm9v' ]}";
expect(findTemplateTags(text)).toEqual([{ start: 0, end: text.length, inner: " b64'Zm9v' " }]);
});
test("closes at the first `]}` when a quote is left unterminated", () => {
const text = "${[ fn(a='unterminated ]} rest";
expect(findTemplateTags(text)).toEqual([{ start: 0, end: 25, inner: " fn(a='unterminated " }]);
expect(text.slice(0, 25)).toEqual("${[ fn(a='unterminated ]}");
});
test("finds two tags with text between them", () => {
expect(findTemplateTags("one ${[ a ]} two ${[ b ]} three")).toEqual([
{ start: 4, end: 12, inner: " a " },
{ start: 17, end: 25, inner: " b " },
]);
});
test("finds two adjacent tags", () => {
expect(findTemplateTags("${[ a ]} ${[ b ]}")).toEqual([
{ start: 0, end: 8, inner: " a " },
{ start: 9, end: 17, inner: " b " },
]);
});
test("finds two tags that each quote a `]}`", () => {
const text = "${[ a(x='1]}') ]} and ${[ b(y='2]}') ]}";
expect(findTemplateTags(text)).toEqual([
{ start: 0, end: 17, inner: " a(x='1]}') " },
{ start: 22, end: 39, inner: " b(y='2]}') " },
]);
});
test("finds nothing in an unclosed tag", () => {
expect(findTemplateTags("${[ no close")).toEqual([]);
});
test("finds nothing in text without tags", () => {
expect(findTemplateTags('{"a": "${b}", "c": 1}')).toEqual([]);
});
test("treats a nested `${[` as ordinary content", () => {
expect(findTemplateTags("${[ a ${[ b ]}")).toEqual([{ start: 0, end: 14, inner: " a ${[ b " }]);
});
test("finishes fast on a long run of quotes", () => {
const text = `\${[${"'".repeat(20_000)} ]}`;
let matches: unknown;
const elapsed = millis(() => {
matches = findTemplateTags(text);
});
expect(matches).toEqual([{ start: 0, end: text.length, inner: `${"'".repeat(20_000)} ` }]);
expect(elapsed).toBeLessThan(200);
});
test("finishes fast on a long run of unpairable quotes", () => {
// Every quote is escaped, so none of them opens a string that closes
const text = `\${[${"\\'".repeat(20_000)} ]}`;
let matches: unknown;
const elapsed = millis(() => {
matches = findTemplateTags(text);
});
expect(matches).toHaveLength(1);
expect(elapsed).toBeLessThan(200);
});
test("finishes fast on a long run of openers that never close", () => {
const text = "${[".repeat(20_000);
let matches: unknown;
const elapsed = millis(() => {
matches = findTemplateTags(text);
});
expect(matches).toEqual([]);
expect(elapsed).toBeLessThan(200);
});
test("never throws, and always reports real bounds", () => {
const alphabet = ["$", "{", "[", "]", "}", "'", "\\", "a", "b"];
const next = random(0x5eed);
for (let n = 0; n < 2_000; n++) {
let text = "";
const length = Math.floor(next() * 31);
for (let i = 0; i < length; i++) {
text += alphabet[Math.floor(next() * alphabet.length)];
}
let matches: ReturnType<typeof findTemplateTags> = [];
expect(() => {
matches = findTemplateTags(text);
}, text).not.toThrow();
let previousEnd = 0;
for (const match of matches) {
expect(text.slice(match.start, match.start + 3), text).toEqual("${[");
expect(text.slice(match.end - 2, match.end), text).toEqual("]}");
expect(text.slice(match.start + 3, match.end - 2), text).toEqual(match.inner);
expect(match.start, text).toBeGreaterThanOrEqual(previousEnd);
previousEnd = match.end;
}
}
});
});
describe("replaceTemplateTags", () => {
test("leaves text without tags alone", () => {
expect(replaceTemplateTags('{"a": 1}', () => "x")).toEqual('{"a": 1}');
});
test("replaces each tag with its trimmed contents", () => {
expect(replaceTemplateTags("${[ a ]}/x/${[ b ]}", (m) => m.inner.trim())).toEqual("a/x/b");
});
test("keeps the replaced length when padding", () => {
const tag = "${[ fn(arg='x]}y') ]}";
const text = `{"a": "${tag}"}`;
const replaced = replaceTemplateTags(text, (m) => "1".repeat(m.end - m.start));
expect(replaced).toEqual(`{"a": "${"1".repeat(tag.length)}"}`);
expect(replaced).toHaveLength(text.length);
});
test("leaves an unclosed tag in place", () => {
expect(replaceTemplateTags("${[ oops", () => "x")).toEqual("${[ oops");
});
});
-137
View File
@@ -1,137 +0,0 @@
/*
* Finding template tags in text.
*
* A tag closes at `]}`, but a quoted argument is allowed to contain `]}` of its own — the
* printer in `crates/yaak-templates` emits `${[ fn(a='x]}y') ]}` verbatim — so the scan has to
* step over quoted strings. It mirrors the Lezer grammar in
* `components/core/Editor/twig/twig.grammar`: a terminated `'…'` (where `\` escapes whatever
* follows it) is consumed whole, and an unterminated quote is just another character, so a
* half-typed tag still closes at the next `]}` instead of swallowing the rest of the document.
*
* This was a regex until CodeQL flagged it (js/polynomial-redos): matching an unterminated
* quote needed a negative lookahead that rescanned to the end of the input at every unpaired
* quote, which is quadratic on text an attacker picks. The scan below is linear instead — see
* the notes on `closingQuoteTable` and on the dead-end set in `findTemplateTags` for why.
*/
const QUOTE = 0x27; // '
const BACKSLASH = 0x5c; // \
const CLOSE_BRACKET = 0x5d; // ]
const CLOSE_BRACE = 0x7d; // }
/** The `${[`…`]}` bounds of one tag, and what sits between them. */
export interface TemplateTagMatch {
/** Index of the `$` that opens the tag. */
start: number;
/** Index just past the closing `]}`. */
end: number;
/** Everything between `${[` and `]}`, not trimmed. */
inner: string;
}
/**
* For every index `i`, the index of the `'` that closes a string whose body starts at `i`, or
* `-1` when nothing does.
*
* Built right to left in a single pass, which is the trick that replaces the regex lookahead:
* whether a quote opens a terminated string depends only on the text after it, so the answer
* can be computed once for every position instead of re-derived at each quote. Skipping a
* string of any length then costs one lookup.
*/
function closingQuoteTable(text: string): Int32Array {
// Two extra slots so a `\` in the last position can read past the end and find `-1`
const table = new Int32Array(text.length + 2).fill(-1);
for (let i = text.length - 1; i >= 0; i--) {
const c = text.charCodeAt(i);
// A backslash escapes whatever follows it, including a quote
const next = c === BACKSLASH ? table[i + 2] : table[i + 1];
table[i] = c === QUOTE ? i : (next ?? -1);
}
return table;
}
/**
* Finds every template tag in `text`, in order and without overlaps.
*
* A `${[` with no `]}` after it isn't a tag, and neither is a `${[` nested inside a tag body —
* both are ordinary content, same as the regex this replaced treated them.
*/
export function findTemplateTags(text: string): TemplateTagMatch[] {
const matches: TemplateTagMatch[] = [];
// Both built only once a scan actually needs them, so short values allocate nothing
let closingQuotes: Int32Array | null = null;
/*
* Body positions a previous scan already ran off the end of the text from. Where a scan ends
* up depends only on where it starts, so a scan that walks into one of these would repeat
* that same walk and fail the same way, and can stop right there.
*
* That bound is what keeps the whole pass linear: every position enters this set at most
* once, and every step of every scan either visits a position for the first time or ends
* that scan.
*/
let deadEnds: Set<number> | null = null;
let searchFrom = 0;
while (searchFrom < text.length) {
const start = text.indexOf("${[", searchFrom);
if (start < 0) break;
const visited: number[] = [];
let end = -1;
let i = start + 3;
while (i < text.length) {
if (deadEnds?.has(i)) break;
visited.push(i);
const c = text.charCodeAt(i);
if (c === QUOTE) {
closingQuotes ??= closingQuoteTable(text);
const close = closingQuotes[i + 1] ?? -1;
// An unterminated quote is an ordinary character, so only step over a closed string
i = close < 0 ? i + 1 : close + 1;
continue;
}
if (c === CLOSE_BRACKET && text.charCodeAt(i + 1) === CLOSE_BRACE) {
end = i + 2;
break;
}
i += 1;
}
if (end < 0) {
// Not a tag. Resume past the opener: a real tag may start inside a string this scan
// stepped over.
deadEnds ??= new Set();
for (const position of visited) deadEnds.add(position);
searchFrom = start + 3;
continue;
}
matches.push({ start, end, inner: text.slice(start + 3, end - 2) });
searchFrom = end;
}
return matches;
}
/** Rewrites every template tag in `text` with whatever `replace` returns for it. */
export function replaceTemplateTags(
text: string,
replace: (match: TemplateTagMatch) => string,
): string {
const matches = findTemplateTags(text);
if (matches.length === 0) return text;
let result = "";
let copiedTo = 0;
for (const match of matches) {
result += text.slice(copiedTo, match.start) + replace(match);
copiedTo = match.end;
}
return result + text.slice(copiedTo);
}
@@ -1,44 +0,0 @@
import { describe, expect, test } from "vite-plus/test";
import { validateHttpHeader } from "./validateHttpHeader";
describe("validateHttpHeader", () => {
test("allows an empty value", () => {
expect(validateHttpHeader("")).toEqual(true);
});
test("allows a plain header name", () => {
expect(validateHttpHeader("X-Api-Key")).toEqual(true);
});
test("rejects a header name with a space", () => {
expect(validateHttpHeader("X Api Key")).toEqual(false);
});
test("allows a simple template tag", () => {
expect(validateHttpHeader("${[ my_var ]}")).toEqual(true);
});
test("allows a tag containing a quoted argument with a space", () => {
expect(validateHttpHeader("${[ fn(arg='my key') ]}")).toEqual(true);
});
test("allows a multi-tag value", () => {
expect(validateHttpHeader("${[ fn(arg='my key') ]}-${[ other(b='x y') ]}")).toEqual(true);
});
test("allows a tag whose quoted argument contains `]}`", () => {
expect(validateHttpHeader("${[ fn(arg='x]}y') ]}")).toEqual(true);
});
test("allows a tag whose quoted argument contains an escaped quote", () => {
expect(validateHttpHeader("${[ fn(arg='it\\'s ]}') ]}")).toEqual(true);
});
test("rejects a tag with a quoted `]}` followed by an invalid character", () => {
expect(validateHttpHeader("${[ fn(arg='x]}y') ]} oops")).toEqual(false);
});
test("rejects a tag followed by an invalid character", () => {
expect(validateHttpHeader("${[ my_var ]} oops")).toEqual(false);
});
});
@@ -1,11 +0,0 @@
import { replaceTemplateTags } from "./templateTags";
export function validateHttpHeader(v: string): boolean {
if (v === "") {
return true;
}
// Template strings are not allowed so we replace them with a valid example string
const withoutTemplateStrings = replaceTemplateTags(v, () => "123");
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
}
+24 -22
View File
@@ -9,7 +9,7 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/commands": "^6.8.1",
"@codemirror/lang-javascript": "^6.2.4",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
@@ -19,7 +19,7 @@
"@codemirror/merge": "^6.11.2",
"@codemirror/search": "^6.5.11",
"@dnd-kit/core": "^6.3.1",
"@gilbarbara/deep-equal": "^0.4.1",
"@gilbarbara/deep-equal": "^0.3.1",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.3",
"@mjackson/multipart-parser": "^0.10.1",
@@ -27,14 +27,14 @@
"@replit/codemirror-vim": "^6.3.0",
"@replit/codemirror-vscode-keymap": "^6.0.2",
"@shopify/lang-jsonc": "^1.0.1",
"@tanstack/react-query": "^5.103.2",
"@tanstack/react-router": "^1.170.38",
"@tanstack/react-virtual": "^3.14.13",
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-router": "^1.133.13",
"@tanstack/react-virtual": "^3.14.9",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-fs": "^2.5.1",
"@tauri-apps/plugin-log": "^2.9.2",
"@tauri-apps/plugin-log": "^2.8.0",
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.5",
@@ -45,26 +45,26 @@
"date-fns": "^4.1.0",
"deep-equal": "^2.2.3",
"eventemitter3": "^5.0.1",
"focus-trap-react": "^12.0.3",
"focus-trap-react": "^11.0.4",
"fuzzbunny": "^1.0.1",
"graphql": "^17.0.2",
"hexy": "^0.4.0",
"graphql": "^16.13.1",
"hexy": "^0.3.5",
"history": "^5.3.0",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"js-md5": "^0.9.2",
"js-md5": "^0.8.3",
"jsonc-parser": "^3.3.1",
"lucide-react": "^1.47.0",
"lucide-react": "^0.525.0",
"mime": "^4.0.4",
"motion": "^13.4.0",
"nanoid": "^6.0.1",
"motion": "^12.4.7",
"nanoid": "^5.1.16",
"papaparse": "^5.4.1",
"parse-color": "^1.0.0",
"react": "^19.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"react-pdf": "^11.0.0",
"react-pdf": "^10.0.1",
"react-syntax-highlighter": "^16.1.0",
"react-use": "^17.6.1",
"rehype-stringify": "^10.0.1",
@@ -73,30 +73,32 @@
"slugify": "^1.6.6",
"uuid": "^14.0.0",
"vkbeautify": "^0.99.3",
"whatwg-mimetype": "^5.0.0",
"whatwg-mimetype": "^4.0.0",
"yaml": "^2.8.3"
},
"devDependencies": {
"@lezer/generator": "^1.8.0",
"@rolldown/plugin-babel": "^0.2.3",
"@tanstack/router-plugin": "^1.168.40",
"@tanstack/router-plugin": "^1.127.5",
"@types/babel__core": "^7.20.5",
"@types/node": "^22.20.4",
"@types/node": "^24.0.13",
"@types/papaparse": "^5.3.16",
"@types/parse-color": "^1.0.3",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/uuid": "^10.0.0",
"@types/whatwg-mimetype": "^5.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"@vitejs/plugin-react": "^6.0.1",
"@yaakapp-internal/theme": "^1.0.0",
"@yaakapp-internal/ui": "^1.0.0",
"babel-plugin-react-compiler": "^1.0.0",
"rollup": "^4.63.4",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.3",
"vite-plugin-static-copy": "^4.1.1",
"internal-ip": "^8.0.0",
"rollup": "^4.60.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
"vite-plugin-static-copy": "^3.3.0",
"vite-plugin-svgr": "^4.5.0",
"vite-plugin-wasm": "^3.5.0",
"vite-plus": "^0.3.3"
"vite-plus": "^0.3.0"
}
}
-167
View File
@@ -1,167 +0,0 @@
import { describe, expect, test, vi } from "vite-plus/test";
import { getOrInsert, getOrInsertComputed, readableStreamValues, sumPrecise } from "./polyfills";
describe("getOrInsert", () => {
test("returns the existing value without replacing it", () => {
const map = new Map([["a", 1]]);
expect(getOrInsert.call(map, "a", 2)).toBe(1);
expect(map.get("a")).toBe(1);
});
test("inserts and returns the value for a missing key", () => {
const map = new Map<string, number>();
expect(getOrInsert.call(map, "a", 2)).toBe(2);
expect(map.get("a")).toBe(2);
});
test("works on a WeakMap", () => {
const key = {};
const map = new WeakMap<object, number>();
expect(getOrInsert.call(map, key, 1)).toBe(1);
expect(getOrInsert.call(map, key, 2)).toBe(1);
});
});
describe("getOrInsertComputed", () => {
test("returns the existing value without calling the callback", () => {
const map = new Map([["a", 1]]);
const callback = vi.fn(() => 2);
expect(getOrInsertComputed.call(map, "a", callback)).toBe(1);
expect(callback).not.toHaveBeenCalled();
});
test("calls the callback with the key and inserts its result", () => {
const map = new Map<string, string>();
expect(getOrInsertComputed.call(map, "a", (key) => `${String(key)}!`)).toBe("a!");
expect(map.get("a")).toBe("a!");
});
test("stores what the callback returns even if the callback set the key", () => {
const map = new Map<string, number>();
const result = getOrInsertComputed.call(map, "a", () => {
map.set("a", 1);
return 2;
});
expect(result).toBe(2);
expect(map.get("a")).toBe(2);
});
test("canonicalizes -0 to +0", () => {
const map = new Map<number, number>();
getOrInsertComputed.call(map, -0, (key) => {
expect(Object.is(key, 0)).toBe(true);
return 1;
});
expect(Object.is([...map.keys()][0], 0)).toBe(true);
});
test("throws when the callback is not a function, even for an existing key", () => {
const map = new Map([["a", 1]]);
// @ts-expect-error testing a non-callable callback
expect(() => getOrInsertComputed.call(map, "a", 1)).toThrow(TypeError);
});
test("is installed on Map and WeakMap", () => {
const map = new Map<string, number[]>();
map.getOrInsertComputed("a", () => []).push(1);
map.getOrInsertComputed("a", () => []).push(2);
expect(map.get("a")).toEqual([1, 2]);
const key = {};
const weakMap = new WeakMap<object, number>();
expect(weakMap.getOrInsertComputed(key, () => 1)).toBe(1);
expect(weakMap.getOrInsert(key, 2)).toBe(1);
});
});
describe("sumPrecise", () => {
test("sums without intermediate rounding", () => {
expect(sumPrecise([1e20, 0.1, -1e20])).toBe(0.1);
expect(sumPrecise([0.1, 0.2, 0.3])).toBe(0.6);
expect(sumPrecise(Array.from({ length: 10 }, () => 0.1))).toBe(1);
});
test("rounds half to even on the final step", () => {
expect(sumPrecise([1, 1e-16, 1e-16])).toBe(1.0000000000000002);
expect(sumPrecise([1, 2 ** -53])).toBe(1);
});
test("handles zeroes, infinities and NaN like the spec", () => {
expect(Object.is(sumPrecise([]), -0)).toBe(true);
expect(Object.is(sumPrecise([-0, -0]), -0)).toBe(true);
expect(Object.is(sumPrecise([-0, 0]), 0)).toBe(true);
expect(Object.is(sumPrecise([1, -1]), 0)).toBe(true);
expect(sumPrecise([1, Infinity])).toBe(Infinity);
expect(sumPrecise([1, -Infinity])).toBe(-Infinity);
expect(sumPrecise([Infinity, -Infinity])).toBeNaN();
expect(sumPrecise([1, NaN])).toBeNaN();
});
test("accepts any iterable and rejects non-numbers", () => {
expect(sumPrecise(new Set([1, 2, 3]))).toBe(6);
// @ts-expect-error testing a non-number item
expect(() => sumPrecise([1, "2"])).toThrow(TypeError);
});
test("is installed on Math", () => {
expect(Math.sumPrecise([1, 2, 3])).toBe(6);
});
});
describe("readableStreamValues", () => {
const values = readableStreamValues<number>;
function streamOf(chunks: number[], onCancel?: () => void) {
return new ReadableStream<number>({
start(controller) {
for (const chunk of chunks) controller.enqueue(chunk);
controller.close();
},
cancel: onCancel,
});
}
test("yields every chunk and releases the lock", async () => {
const stream = streamOf([1, 2, 3]);
const seen: number[] = [];
for await (const chunk of values.call(stream)) seen.push(chunk);
expect(seen).toEqual([1, 2, 3]);
expect(stream.locked).toBe(false);
});
test("cancels the stream when the loop exits early", async () => {
const onCancel = vi.fn();
const stream = new ReadableStream<number>({
pull(controller) {
controller.enqueue(1);
},
cancel: onCancel,
});
for await (const _ of values.call(stream)) break;
expect(onCancel).toHaveBeenCalled();
expect(stream.locked).toBe(false);
});
test("leaves the stream open with preventCancel", async () => {
const onCancel = vi.fn();
const stream = streamOf([1, 2], onCancel);
for await (const _ of values.call(stream, { preventCancel: true })) break;
expect(onCancel).not.toHaveBeenCalled();
expect(stream.locked).toBe(false);
});
test("releases the lock and rethrows when cancelling rejects", async () => {
const stream = new ReadableStream<number>({
pull(controller) {
controller.enqueue(1);
},
cancel() {
throw new Error("cancel failed");
},
});
await expect(async () => {
for await (const _ of values.call(stream)) break;
}).rejects.toThrow("cancel failed");
expect(stream.locked).toBe(false);
});
});
-172
View File
@@ -1,172 +0,0 @@
/**
* Built-ins that PDF.js's modern build calls unconditionally but the system WebKit on
* older macOS lacks: the upsert methods and `Math.sumPrecise` arrived in Safari 26.2,
* and async iteration of `ReadableStream` in Safari 27. Without them the PDF viewer
* throws as soon as it renders. Loaded first by `index.html`, and by `pdfWorker.ts`
* because the worker has its own globals.
*
* Each is defined only when missing, so newer engines keep their native versions.
*/
type KeyedCollection<K, V> = {
has(key: K): boolean;
get(key: K): V | undefined;
set(key: K, value: V): unknown;
};
/** https://tc39.es/proposal-upsert/#sec-map.prototype.getOrInsert */
export function getOrInsert<K, V>(this: KeyedCollection<K, V>, key: K, value: V): V {
if (this.has(key)) return this.get(key) as V;
this.set(key, value);
return value;
}
/** https://tc39.es/proposal-upsert/#sec-map.prototype.getOrInsertComputed */
export function getOrInsertComputed<K, V>(
this: KeyedCollection<K, V>,
key: K,
callbackfn: (key: K) => V,
): V {
if (typeof callbackfn !== "function") {
throw new TypeError("getOrInsertComputed callback is not a function");
}
if (this.has(key)) return this.get(key) as V;
// Map keys are canonicalized, so -0 is stored and passed on as +0
const k = (Object.is(key, -0) ? 0 : key) as K;
const value = callbackfn(k);
// The spec sets after the callback even if the callback inserted the key itself
this.set(k, value);
return value;
}
/**
* https://tc39.es/proposal-math-sum/
*
* Shewchuk's exact summation with round-half-even on the final step (the algorithm
* behind Python's `math.fsum`). A finite input set whose running sum overflows
* partway through is not handled exactly, which PDF.js's byte lengths and widths
* never come near.
*/
export function sumPrecise(items: Iterable<number>): number {
const partials: number[] = [];
let allNegativeZero = true;
let sawNaN = false;
let sawPositiveInfinity = false;
let sawNegativeInfinity = false;
for (const item of items) {
if (typeof item !== "number") {
throw new TypeError("Math.sumPrecise requires an iterable of numbers");
}
if (!Object.is(item, -0)) allNegativeZero = false;
if (Number.isNaN(item)) {
sawNaN = true;
} else if (item === Infinity) {
sawPositiveInfinity = true;
} else if (item === -Infinity) {
sawNegativeInfinity = true;
} else {
let x = item;
let i = 0;
for (let y of partials) {
if (Math.abs(x) < Math.abs(y)) [x, y] = [y, x];
const hi = x + y;
const lo = y - (hi - x);
if (lo !== 0) partials[i++] = lo;
x = hi;
}
partials.length = i;
partials.push(x);
}
}
if (sawNaN || (sawPositiveInfinity && sawNegativeInfinity)) return NaN;
if (sawPositiveInfinity) return Infinity;
if (sawNegativeInfinity) return -Infinity;
if (allNegativeZero) return -0;
let n = partials.length;
let hi = 0;
let lo = 0;
if (n > 0) {
hi = partials[--n] as number;
while (n > 0) {
const x = hi;
const y = partials[--n] as number;
hi = x + y;
lo = y - (hi - x);
if (lo !== 0) break;
}
const next = partials[n - 1];
if (n > 0 && next != null && ((lo < 0 && next < 0) || (lo > 0 && next > 0))) {
const y = lo * 2;
const x = hi + y;
if (y === x - hi) hi = x;
}
}
return hi === 0 ? 0 : hi;
}
/** https://streams.spec.whatwg.org/#rs-asynciterator */
export async function* readableStreamValues<R>(
this: ReadableStream<R>,
{ preventCancel = false }: { preventCancel?: boolean } = {},
): AsyncGenerator<R, undefined, undefined> {
const reader = this.getReader();
let finished = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
finished = true;
return;
}
yield value;
}
} catch (err) {
finished = true;
throw err;
} finally {
// Leaving the loop early (break, return, throw in the body) cancels the stream. The
// lock is released even if cancelling rejects, which then propagates as in the spec.
try {
if (!finished && !preventCancel) await reader.cancel();
} finally {
reader.releaseLock();
}
}
}
function define(target: object, key: PropertyKey, value: unknown) {
if (key in target) return;
Object.defineProperty(target, key, { value, writable: true, configurable: true });
}
define(Map.prototype, "getOrInsert", getOrInsert);
define(Map.prototype, "getOrInsertComputed", getOrInsertComputed);
define(WeakMap.prototype, "getOrInsert", getOrInsert);
define(WeakMap.prototype, "getOrInsertComputed", getOrInsertComputed);
define(Math, "sumPrecise", sumPrecise);
if (typeof ReadableStream !== "undefined") {
define(ReadableStream.prototype, "values", readableStreamValues);
define(
ReadableStream.prototype,
Symbol.asyncIterator,
Reflect.get(ReadableStream.prototype, "values"),
);
}
// TypeScript's lib does not describe these yet
declare global {
interface Map<K, V> {
getOrInsert(key: K, value: V): V;
getOrInsertComputed(key: K, callbackfn: (key: K) => V): V;
}
interface WeakMap<K extends WeakKey, V> {
getOrInsert(key: K, value: V): V;
getOrInsertComputed(key: K, callbackfn: (key: K) => V): V;
}
interface Math {
sumPrecise(items: Iterable<number>): number;
}
}
+1 -10
View File
@@ -1,19 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { Onboarding } from "../components/Onboarding";
import { RedirectToLatestWorkspace } from "../components/RedirectToLatestWorkspace";
type IndexSearchSchema = {
/** Show the home screen even when workspaces exist. Set by the dev menu. */
home?: true;
};
export const Route = createFileRoute("/")({
component: RouteComponent,
validateSearch: (search: Record<string, unknown>): IndexSearchSchema =>
search.home === true ? { home: true } : {},
});
function RouteComponent() {
const { home } = Route.useSearch();
return home ? <Onboarding /> : <RedirectToLatestWorkspace />;
return <RedirectToLatestWorkspace />;
}
+4 -138
View File
@@ -1,11 +1,11 @@
// @ts-ignore
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import react from "@vitejs/plugin-react";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { defineConfig, normalizePath } from "vite-plus";
import { viteStaticCopy } from "vite-plugin-static-copy";
import svgr from "vite-plugin-svgr";
import wasm from "vite-plugin-wasm";
const require = createRequire(import.meta.url);
@@ -16,12 +16,6 @@ const standardFontsDir = normalizePath(
path.join(path.dirname(require.resolve("pdfjs-dist/package.json")), "standard_fonts"),
);
// The app's own icons, served as the page icon rather than copied into the client:
// the bundled app and the tab should not be able to disagree about what Yaak looks like.
const iconsDir = normalizePath(
path.join(import.meta.dirname, "../../crates-tauri/yaak-app-client/icons/release"),
);
/**
* Which host the platform package installs. `web` builds Yaak to run in a plain
* browser tab, with its own IndexedDB store instead of the Rust engine; anything
@@ -29,84 +23,6 @@ const iconsDir = normalizePath(
*/
const yaakTarget = process.env.YAAK_TARGET === "web" ? "web" : "desktop";
const webUrl = "https://web.yaak.app";
const webDir = normalizePath(path.join(import.meta.dirname, "web"));
/** Link previews for the hosted web app. Meaningless inside the desktop window. */
function webHead() {
return {
name: "web-head",
transformIndexHtml() {
const title = "Yaak in your browser";
const description = "No download, no setup. Open a tab and send your first request.";
return [
{ tag: "meta", attrs: { name: "description", content: description } },
{ tag: "meta", attrs: { property: "og:type", content: "website" } },
{ tag: "meta", attrs: { property: "og:url", content: webUrl } },
{ tag: "meta", attrs: { property: "og:title", content: title } },
{ tag: "meta", attrs: { property: "og:description", content: description } },
{ tag: "meta", attrs: { property: "og:image", content: `${webUrl}/og.png` } },
{ tag: "meta", attrs: { property: "og:image:width", content: "1200" } },
{ tag: "meta", attrs: { property: "og:image:height", content: "630" } },
{ tag: "meta", attrs: { name: "twitter:card", content: "summary_large_image" } },
{ tag: "meta", attrs: { name: "twitter:site", content: "@yaakapp" } },
{ tag: "meta", attrs: { name: "twitter:title", content: title } },
{ tag: "meta", attrs: { name: "twitter:description", content: description } },
{ tag: "meta", attrs: { name: "twitter:image", content: `${webUrl}/og.png` } },
];
},
};
}
/**
* Where `yaak-web` is listening, taken from the same variables that put it there.
*
* `HOST` is an instruction about what the server accepts rather than an address to
* dial, so a wildcard becomes loopback: the dev server and the send server share a
* machine.
*/
function sendServerUrl(): string {
const host = process.env.HOST?.trim();
const port = process.env.PORT?.trim() || "9227";
const wildcard = !host || host === "0.0.0.0" || host === "::" || host === "[::]";
return `http://${wildcard ? "127.0.0.1" : host}:${port}`;
}
/**
* Fails the build when a copied asset is not where the app fetches it from.
*
* `PdfViewer` and the page head address these by URL, but nothing ties those
* URLs to the copy targets below, and a target that mirrors its source path
* instead of landing at the root still builds cleanly — the miss only shows up
* as a 404 once the app runs.
*/
function verifyServedAssets(expected: string[]) {
let outDir = "";
return {
name: "verify-served-assets",
apply: "build" as const,
configResolved(config: { root: string; build: { outDir: string } }) {
outDir = path.resolve(config.root, config.build.outDir);
},
// After viteStaticCopy, which writes on `writeBundle`.
closeBundle() {
const missing = expected.filter((asset) => {
const full = path.join(outDir, asset);
if (!fs.existsSync(full)) return true;
const stat = fs.statSync(full);
return stat.isDirectory() && fs.readdirSync(full).length === 0;
});
if (missing.length > 0) {
throw new Error(
`Copied assets missing from ${outDir}: ${missing.join(", ")}. ` +
`Every viteStaticCopy target needs to strip its base path, or it lands ` +
`under a copy of the directories it came from instead.`,
);
}
},
};
}
// https://vitejs.dev/config/
export default defineConfig(async () => {
return {
@@ -140,44 +56,14 @@ export default defineConfig(async () => {
generatedRouteTree: "./routeTree.gen.ts",
autoCodeSplitting: true,
}),
svgr(),
react(),
viteStaticCopy({
// v4 matches only files and always mirrors the source tree into the
// output, so every target here needs stripBase to land where it is
// actually served from — without it these end up under a copy of the
// path they came from, and nothing fails until the request 404s.
targets: [
{ src: `${cMapsDir}/*`, dest: "cmaps", rename: { stripBase: true } },
{
src: `${standardFontsDir}/*`,
dest: "standard_fonts",
rename: { stripBase: true },
},
// `/favicon.ico` is requested by browsers whether or not anything links to it,
// so it is served under that name to keep a 404 out of every console.
{
src: `${iconsDir}/icon.ico`,
dest: "",
rename: { name: "favicon.ico", stripBase: true },
},
{
src: `${iconsDir}/128x128.png`,
dest: "",
rename: { name: "icon-128.png", stripBase: true },
},
...(yaakTarget === "web"
? [{ src: `${webDir}/og.png`, dest: "", rename: { stripBase: true } }]
: []),
{ src: cMapsDir, dest: "" },
{ src: standardFontsDir, dest: "" },
],
}),
...(yaakTarget === "web" ? [webHead()] : []),
verifyServedAssets([
"cmaps",
"standard_fonts",
"favicon.ico",
"icon-128.png",
...(yaakTarget === "web" ? ["og.png"] : []),
]),
],
build: {
target: "esnext",
@@ -198,28 +84,8 @@ export default defineConfig(async () => {
},
clearScreen: false,
server: {
// `HOST` names the interface, as it does most places: unset leaves Vite on
// loopback, `0.0.0.0` exposes it for reaching the dev server from another
// device.
host: process.env.HOST,
// Vite refuses a `Host` it does not recognise, which stops a page on a name the
// attacker controls from rebinding that name here and driving `/v1` as its own
// origin. Addresses are allowed already; only names need listing, so reaching
// this as `dev-box.example` means naming it. Deliberately not widened to "any
// host when exposed": the proxy below leads to an unauthenticated sender.
allowedHosts: process.env.ALLOWED_HOSTS?.split(",")
.map((h) => h.trim())
.filter(Boolean),
port: parseInt(process.env.YAAK_CLIENT_DEV_PORT ?? process.env.YAAK_DEV_PORT ?? "1420", 10),
strictPort: true,
// A web dev server is one origin, the way the built app is: `/v1` is passed
// through to `yaak-web` rather than the tab being told to call it directly.
// That is one address to open instead of two, no CORS in the loop, and a
// dev build that sends exactly the way a production build does.
proxy:
yaakTarget === "web"
? { "/v1": { target: sendServerUrl(), changeOrigin: true } }
: undefined,
},
envPrefix: ["VITE_", "TAURI_"],
};
-129
View File
@@ -1,129 +0,0 @@
<!doctype html>
<!--
Source for og.png (1200x630). Re-render after editing:
cd apps/yaak-client/web && "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --disable-gpu --hide-scrollbars --window-size=1200,630 --force-device-scale-factor=1 --virtual-time-budget=10000 --screenshot=og.png "file://$PWD/og.html"
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600;800&family=JetBrains+Mono:wght@400;500&display=block"
rel="stylesheet"
/>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html,
body {
width: 1200px;
height: 630px;
overflow: hidden;
}
body {
font-family: Inter, sans-serif;
color: hsl(245, 23%, 85%);
background:
radial-gradient(900px 600px at 0% 100%, hsl(160, 40%, 16%) 0%, transparent 60%),
radial-gradient(900px 600px at 100% 0%, hsl(275, 40%, 20%) 0%, transparent 60%),
linear-gradient(135deg, hsl(244, 23%, 12%) 0%, hsl(244, 23%, 15%) 100%);
-webkit-font-smoothing: antialiased;
}
.dots {
position: absolute;
inset: 0;
background-image: radial-gradient(hsla(245, 23%, 85%, 0.07) 1.2px, transparent 1.2px);
background-size: 28px 28px;
mask-image: radial-gradient(ellipse at 50% 40%, black 30%, transparent 75%);
}
.page {
position: relative;
height: 100%;
padding: 56px 64px;
display: flex;
flex-direction: column;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
font-weight: 600;
font-size: 26px;
}
.brand img {
width: 48px;
height: 48px;
border-radius: 12px;
}
h1 {
margin-top: 52px;
font-size: 84px;
line-height: 1.05;
font-weight: 800;
letter-spacing: -0.03em;
color: hsl(245, 23%, 92%);
}
h1 em {
font-style: normal;
color: hsl(48, 80%, 63%);
}
p {
margin-top: 28px;
font-size: 34px;
line-height: 1.4;
font-weight: 500;
color: hsl(245, 18%, 68%);
max-width: 1040px;
}
.bar {
margin-top: auto;
display: flex;
align-items: center;
gap: 22px;
height: 72px;
padding: 0 28px;
border-radius: 12px;
background: hsla(244, 23%, 22%, 0.6);
border: 1px solid hsl(244, 23%, 28%);
font-family: "JetBrains Mono", monospace;
font-size: 28px;
box-shadow: 0 10px 40px hsla(244, 40%, 5%, 0.4);
}
.method {
color: hsl(245, 18%, 62%);
font-weight: 500;
}
.url .scheme {
color: hsl(245, 18%, 55%);
}
.url .host {
color: hsl(245, 23%, 92%);
}
.status {
margin-left: auto;
color: hsl(150, 99%, 44%);
font-weight: 500;
}
</style>
</head>
<body>
<div class="dots"></div>
<div class="page">
<div class="brand">
<img src="../../../crates-tauri/yaak-app-client/icons/release/128x128@2x.png" alt="" />
<span>Yaak</span>
</div>
<h1>Your favorite API client<br /><em>in your browser</em></h1>
<p>No download, no setup. Open a tab and send your first request.</p>
<div class="bar">
<span class="method">GET</span>
<span class="url"><span class="scheme">https://</span><span class="host">web.yaak.app</span></span>
<span class="status">200 OK</span>
</div>
</div>
</body>
</html>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 295 KiB

+4 -4
View File
@@ -9,7 +9,7 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@tanstack/react-query": "^5.103.2",
"@tanstack/react-query": "^5.90.5",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-os": "^2.3.2",
"@yaakapp-internal/model-store": "^1.0.0",
@@ -19,7 +19,7 @@
"classnames": "^2.5.1",
"jotai": "^2.18.0",
"jotai-family": "^1.0.1",
"motion": "^13.4.0",
"motion": "^12.4.7",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
@@ -31,7 +31,7 @@
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"typescript": "^5.8.3",
"vite": "npm:@voidzero-dev/vite-plus-core@^0.3.3",
"vite-plus": "^0.3.3"
"vite": "npm:@voidzero-dev/vite-plus-core@^0.2.9",
"vite-plus": "^0.3.0"
}
}
+1 -4
View File
@@ -22,11 +22,8 @@ include_dir = "0.7"
keyring = { workspace = true, features = ["apple-native", "windows-native", "sync-secret-service"] }
log = { workspace = true }
rand = "0.8"
reqwest = { workspace = true, features = ["form"] }
reqwest = { workspace = true }
rolldown = "0.1.0"
# Pinned exactly: rolldown_resolver takes `oxc_resolver = "^11"`, so cargo
# drifts it forward and breaks on 11.11 (TsconfigOptions became
# TsconfigDiscovery). This dep exists only to hold that resolution.
oxc_resolver = "=11.10.0"
schemars = { workspace = true }
serde = { workspace = true }
+4 -4
View File
@@ -4,7 +4,7 @@ description: >
Use when the user mentions Yaak, a Yaak workspace, or the `yaak` command, or
asks to call, hit, or smoke test HTTP/REST endpoints, save or organize API
requests for reuse or manual testing, configure auth on saved requests, import
an OpenAPI spec, a Postman, Insomnia, or Bruno collection, or a cURL command, or run
an OpenAPI spec, a Postman or Insomnia collection, or a cURL command, or run
saved requests across environments. Prefer over one-off `curl` when requests
should be saved, reused, shared, or run as a set.
allowed-tools: Bash(yaak:*), Bash(which:*), Bash(command:*), Bash(npm:*), Bash(npx:*)
@@ -98,9 +98,9 @@ re-run it after acting.
## Core workflows
**Start from a spec when one exists.** `yaak import <path>` auto-detects OpenAPI,
Swagger, Postman, Insomnia, Bruno, cURL, and Yaak exports, and beats authoring requests
by hand every time. Bruno collections can also be imported from a ZIP or directory.
**Start from a spec when one exists.** `yaak import <file>` auto-detects OpenAPI,
Swagger, Postman, Insomnia, cURL, and Yaak exports, and beats authoring requests
by hand every time.
**Make the host swappable.** Put the base URL in a base-environment variable,
reference it as `${[ base_url ]}`, then add a sub-environment per deployment
Loaded 100 of 318 files, more files were not shown because too many files have changed in this diff. Show more