mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-06-27 20:36:17 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 693768ffc6 | |||
| 98794fa031 | |||
| 7db3e9b879 | |||
| 8109a28967 | |||
| 4092511f22 | |||
| 3de9a1edd4 | |||
| 1b28dfd9d1 | |||
| 9f51c61447 | |||
| b17ccbeebe | |||
| 463cc6f5a3 | |||
| 1307ea4e67 | |||
| 710b8e34ac | |||
| f251772a4a |
@@ -4,6 +4,7 @@ import { Banner, VStack } from "@yaakapp-internal/ui";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
import { openWorkspaceFromSyncDir } from "../commands/openWorkspaceFromSyncDir";
|
||||||
import { appInfo } from "../lib/appInfo";
|
import { appInfo } from "../lib/appInfo";
|
||||||
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { showErrorToast } from "../lib/toast";
|
import { showErrorToast } from "../lib/toast";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
@@ -89,6 +90,10 @@ export function CloneGitRepositoryDialog({ hide }: Props) {
|
|||||||
</Banner>
|
</Banner>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<CommercialUseBanner source="git-clone" title="Using Git for work?">
|
||||||
|
A Yaak license is required for commercial use and helps support features like this.
|
||||||
|
</CommercialUseBanner>
|
||||||
|
|
||||||
<PlainInput
|
<PlainInput
|
||||||
required
|
required
|
||||||
label="Repository URL"
|
label="Repository URL"
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
|
import type { LicenseCheckStatus } from "@yaakapp-internal/license";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useKeyValue } from "../hooks/useKeyValue";
|
||||||
|
import { appInfo } from "../lib/appInfo";
|
||||||
|
import { pricingUrl } from "../lib/pricingUrl";
|
||||||
|
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||||
|
|
||||||
|
const COMMERCIAL_USE_SNOOZE_DAYS = 7;
|
||||||
|
|
||||||
|
export function CommercialUseBanner({
|
||||||
|
children,
|
||||||
|
source,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
children: string;
|
||||||
|
source: string;
|
||||||
|
title: string;
|
||||||
|
}) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const {
|
||||||
|
isLoading: isSnoozeLoading,
|
||||||
|
set: setSnoozedAt,
|
||||||
|
value: snoozedAt,
|
||||||
|
} = useKeyValue<string | null>({
|
||||||
|
namespace: "global",
|
||||||
|
key: "commercial-use-banner-snoozed-at",
|
||||||
|
fallback: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let canceled = false;
|
||||||
|
|
||||||
|
shouldShowCommercialUsePrompt()
|
||||||
|
.then((shouldShow) => {
|
||||||
|
if (!canceled) setVisible(shouldShow);
|
||||||
|
})
|
||||||
|
.catch(console.error);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
canceled = true;
|
||||||
|
};
|
||||||
|
}, [source]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!visible ||
|
||||||
|
isSnoozeLoading ||
|
||||||
|
isWithinDays(snoozedAt, COMMERCIAL_USE_SNOOZE_DAYS)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
<DismissibleBanner
|
||||||
|
id={`commercial-use:${source}`}
|
||||||
|
color="info"
|
||||||
|
className="w-full"
|
||||||
|
onDismiss={() => setSnoozedAt(new Date().toISOString())}
|
||||||
|
actions={[
|
||||||
|
{
|
||||||
|
label: "View plans",
|
||||||
|
color: "info",
|
||||||
|
variant: "solid",
|
||||||
|
onClick: () => {
|
||||||
|
openCommercialUsePricing(source).catch(console.error);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-semibold text-text">{title}</p>
|
||||||
|
<p className="mt-0.5 text-text-subtle">{children}</p>
|
||||||
|
</div>
|
||||||
|
</DismissibleBanner>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shouldShowCommercialUsePrompt(): Promise<boolean> {
|
||||||
|
// Open-source builds omit the Rust license plugin, so never show commercial-use prompts there.
|
||||||
|
if (appInfo.featureLicense !== true) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const license = await invoke<LicenseCheckStatus>("plugin:yaak-license|check");
|
||||||
|
return license.status !== "active" && license.status !== "trialing";
|
||||||
|
} catch (err) {
|
||||||
|
console.log("Failed to check license before commercial-use prompt", err);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCommercialUsePricing(source: string): Promise<void> {
|
||||||
|
await openUrl(pricingUrl(`app.commercial-use.${source}`)).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinDays(date: string | null, days: number): boolean {
|
||||||
|
if (date == null) return false;
|
||||||
|
|
||||||
|
const time = new Date(date).getTime();
|
||||||
|
if (Number.isNaN(time)) return false;
|
||||||
|
|
||||||
|
return Date.now() - time < days * 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
@@ -4,11 +4,12 @@ import type { ReactNode } from "react";
|
|||||||
interface Props {
|
interface Props {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
wrapperClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EmptyStateText({ children, className }: Props) {
|
export function EmptyStateText({ children, className, wrapperClassName }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full pb-2">
|
<div className={classNames("w-full h-full pb-2", wrapperClassName)}>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import slugify from "slugify";
|
|||||||
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { pluralizeCount } from "../lib/pluralize";
|
import { pluralizeCount } from "../lib/pluralize";
|
||||||
import { invokeCmd } from "../lib/tauri";
|
import { invokeCmd } from "../lib/tauri";
|
||||||
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { Checkbox } from "./core/Checkbox";
|
import { Checkbox } from "./core/Checkbox";
|
||||||
import { DetailsBanner } from "./core/DetailsBanner";
|
import { DetailsBanner } from "./core/DetailsBanner";
|
||||||
@@ -85,8 +86,12 @@ function ExportDataDialogContent({
|
|||||||
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
||||||
const noneSelected = numSelected === 0;
|
const noneSelected = numSelected === 0;
|
||||||
return (
|
return (
|
||||||
<div className="w-full grid grid-rows-[minmax(0,1fr)_auto]">
|
<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">
|
<VStack space={3} className="overflow-auto px-5 pb-6">
|
||||||
|
<CommercialUseBanner source="data-export" title="Exporting work data?">
|
||||||
|
A Yaak license is required for commercial use and helps support features like this.
|
||||||
|
</CommercialUseBanner>
|
||||||
|
|
||||||
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -137,9 +142,9 @@ function ExportDataDialogContent({
|
|||||||
/>
|
/>
|
||||||
</DetailsBanner>
|
</DetailsBanner>
|
||||||
</VStack>
|
</VStack>
|
||||||
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle">
|
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface py-3 border-t border-border-subtle">
|
||||||
<div>
|
<div>
|
||||||
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle">
|
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtlest">
|
||||||
Create Run Button
|
Create Run Button
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { VStack } from "@yaakapp-internal/ui";
|
import { VStack } from "@yaakapp-internal/ui";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useLocalStorage } from "react-use";
|
import { useLocalStorage } from "react-use";
|
||||||
|
import { CommercialUseBanner } from "./CommercialUseBanner";
|
||||||
import { Button } from "./core/Button";
|
import { Button } from "./core/Button";
|
||||||
import { SelectFile } from "./SelectFile";
|
import { SelectFile } from "./SelectFile";
|
||||||
|
|
||||||
@@ -14,6 +15,10 @@ export function ImportDataDialog({ importData }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={5} className="pb-4">
|
<VStack space={5} className="pb-4">
|
||||||
|
<CommercialUseBanner source="data-import" title="Importing work data?">
|
||||||
|
A Yaak license is required for commercial use and helps support features like this.
|
||||||
|
</CommercialUseBanner>
|
||||||
|
|
||||||
<VStack space={1}>
|
<VStack space={1}>
|
||||||
<ul className="list-disc pl-5">
|
<ul className="list-disc pl-5">
|
||||||
<li>OpenAPI 3.0, 3.1</li>
|
<li>OpenAPI 3.0, 3.1</li>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Heading, HStack, InlineCode, VStack } from "@yaakapp-internal/ui";
|
|||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import { showConfirmDelete } from "../../lib/confirm";
|
import { showConfirmDelete } from "../../lib/confirm";
|
||||||
|
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
import { DetailsBanner } from "../core/DetailsBanner";
|
import { DetailsBanner } from "../core/DetailsBanner";
|
||||||
@@ -232,6 +233,10 @@ export function SettingsCertificates() {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CommercialUseBanner source="client-certificates" title="Using certificates for work?">
|
||||||
|
A Yaak license is required for commercial use and helps support features like this.
|
||||||
|
</CommercialUseBanner>
|
||||||
|
|
||||||
{certificates.length > 0 && (
|
{certificates.length > 0 && (
|
||||||
<VStack space={3}>
|
<VStack space={3}>
|
||||||
{certificates.map((cert, index) => (
|
{certificates.map((cert, index) => (
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "../../lib/requestSettings";
|
} from "../../lib/requestSettings";
|
||||||
import { revealInFinderText } from "../../lib/reveal";
|
import { revealInFinderText } from "../../lib/reveal";
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
|
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||||
import { IconButton } from "../core/IconButton";
|
import { IconButton } from "../core/IconButton";
|
||||||
import {
|
import {
|
||||||
ModelSettingRowBoolean,
|
ModelSettingRowBoolean,
|
||||||
@@ -38,10 +39,15 @@ export function SettingsGeneral() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VStack space={1.5} className="mb-4">
|
<VStack space={1.5} className="mb-4">
|
||||||
<div className="mb-4">
|
<div>
|
||||||
<Heading>General</Heading>
|
<Heading>General</Heading>
|
||||||
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
|
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-3 mb-5">
|
||||||
|
<CommercialUseBanner source="settings-general" title="Using Yaak for work?">
|
||||||
|
A Yaak license is required for commercial use and helps support future development.
|
||||||
|
</CommercialUseBanner>
|
||||||
|
</div>
|
||||||
<SettingsList className="space-y-8">
|
<SettingsList className="space-y-8">
|
||||||
<CargoFeature feature="updater">
|
<CargoFeature feature="updater">
|
||||||
<SettingsSection title="Updates">
|
<SettingsSection title="Updates">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useAtomValue } from "jotai";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom } from "../../hooks/useActiveWorkspace";
|
||||||
import { showConfirm } from "../../lib/confirm";
|
import { showConfirm } from "../../lib/confirm";
|
||||||
|
import { pricingUrl } from "../../lib/pricingUrl";
|
||||||
import { invokeCmd } from "../../lib/tauri";
|
import { invokeCmd } from "../../lib/tauri";
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
@@ -252,7 +253,9 @@ function LicenseSettings({ settings }: { settings: Settings }) {
|
|||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Licenses help keep Yaak independent and sustainable.{" "}
|
Licenses help keep Yaak independent and sustainable.{" "}
|
||||||
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
<Link href={pricingUrl("app.license.badge-hide-confirm")}>
|
||||||
|
Purchase a License →
|
||||||
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</VStack>
|
</VStack>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { formatDate } from "date-fns/format";
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useToggle } from "../../hooks/useToggle";
|
import { useToggle } from "../../hooks/useToggle";
|
||||||
import { pluralizeCount } from "../../lib/pluralize";
|
import { pluralizeCount } from "../../lib/pluralize";
|
||||||
|
import { pricingUrl } from "../../lib/pricingUrl";
|
||||||
import { CargoFeature } from "../CargoFeature";
|
import { CargoFeature } from "../CargoFeature";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import { Link } from "../core/Link";
|
import { Link } from "../core/Link";
|
||||||
@@ -48,7 +49,7 @@ function SettingsLicenseCmp() {
|
|||||||
<span className="opacity-50">Personal use is always free, forever.</span>
|
<span className="opacity-50">Personal use is always free, forever.</span>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||||
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,7 +69,7 @@ function SettingsLicenseCmp() {
|
|||||||
</span>
|
</span>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||||
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
<Link noUnderline href={pricingUrl(`app.license.learn.${check.data.status}`)}>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -134,7 +135,7 @@ function SettingsLicenseCmp() {
|
|||||||
<Button
|
<Button
|
||||||
color="secondary"
|
color="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => openUrl("https://yaak.app/dashboard?s=support&ref=app.yaak.desktop")}
|
onClick={() => openUrl("https://yaak.app/dashboard?intent=app.license.support")}
|
||||||
rightSlot={<Icon icon="external_link" />}
|
rightSlot={<Icon icon="external_link" />}
|
||||||
>
|
>
|
||||||
Direct Support
|
Direct Support
|
||||||
@@ -150,9 +151,7 @@ function SettingsLicenseCmp() {
|
|||||||
color="primary"
|
color="primary"
|
||||||
rightSlot={<Icon icon="external_link" />}
|
rightSlot={<Icon icon="external_link" />}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
openUrl(
|
openUrl(pricingUrl(`app.license.purchase.${check.data?.status ?? "unknown"}`))
|
||||||
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ""}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Purchase License
|
Purchase License
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { patchModel, settingsAtom } from "@yaakapp-internal/models";
|
|||||||
import type { ProxySetting } from "@yaakapp-internal/models";
|
import type { ProxySetting } from "@yaakapp-internal/models";
|
||||||
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
import { Heading, InlineCode, VStack } from "@yaakapp-internal/ui";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
|
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||||
import {
|
import {
|
||||||
SettingRowBoolean,
|
SettingRowBoolean,
|
||||||
SettingRowSelect,
|
SettingRowSelect,
|
||||||
@@ -33,6 +34,9 @@ export function SettingsProxy() {
|
|||||||
traffic, or routing through specific infrastructure.
|
traffic, or routing through specific infrastructure.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<CommercialUseBanner source="proxy-settings" title="Using a proxy for work?">
|
||||||
|
A Yaak license is required for commercial use and helps support features like this.
|
||||||
|
</CommercialUseBanner>
|
||||||
<SettingsList className="space-y-8">
|
<SettingsList className="space-y-8">
|
||||||
<SettingsSection title="Proxy">
|
<SettingsSection title="Proxy">
|
||||||
<SettingRowSelect
|
<SettingRowSelect
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useExportData } from "../hooks/useExportData";
|
|||||||
import { appInfo } from "../lib/appInfo";
|
import { appInfo } from "../lib/appInfo";
|
||||||
import { showDialog } from "../lib/dialog";
|
import { showDialog } from "../lib/dialog";
|
||||||
import { importData } from "../lib/importData";
|
import { importData } from "../lib/importData";
|
||||||
|
import { pricingUrl } from "../lib/pricingUrl";
|
||||||
import type { DropdownRef } from "./core/Dropdown";
|
import type { DropdownRef } from "./core/Dropdown";
|
||||||
import { Dropdown } from "./core/Dropdown";
|
import { Dropdown } from "./core/Dropdown";
|
||||||
import { Icon } from "@yaakapp-internal/ui";
|
import { Icon } from "@yaakapp-internal/ui";
|
||||||
@@ -76,7 +77,8 @@ export function SettingsDropdown() {
|
|||||||
hidden: check.data == null || check.data.status === "active",
|
hidden: check.data == null || check.data.status === "active",
|
||||||
leftSlot: <Icon icon="circle_dollar_sign" />,
|
leftSlot: <Icon icon="circle_dollar_sign" />,
|
||||||
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
||||||
onSelect: () => openUrl("https://yaak.app/pricing"),
|
onSelect: () =>
|
||||||
|
openUrl(pricingUrl(`app.menu.purchase.${check.data?.status ?? "unknown"}`)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Install CLI",
|
label: "Install CLI",
|
||||||
|
|||||||
@@ -64,7 +64,9 @@ import type { ContextMenuProps, DropdownItem } from "./core/Dropdown";
|
|||||||
import { ContextMenu, Dropdown } from "./core/Dropdown";
|
import { ContextMenu, Dropdown } from "./core/Dropdown";
|
||||||
import type { FieldDef } from "./core/Editor/filter/extension";
|
import type { FieldDef } from "./core/Editor/filter/extension";
|
||||||
import { filter } from "./core/Editor/filter/extension";
|
import { filter } from "./core/Editor/filter/extension";
|
||||||
|
import type { Ast } from "./core/Editor/filter/query";
|
||||||
import { evaluate, parseQuery } from "./core/Editor/filter/query";
|
import { evaluate, parseQuery } from "./core/Editor/filter/query";
|
||||||
|
import { formatFieldFilter } from "./core/Editor/filter/format";
|
||||||
import { HttpMethodTag } from "./core/HttpMethodTag";
|
import { HttpMethodTag } from "./core/HttpMethodTag";
|
||||||
import { HttpStatusTag } from "./core/HttpStatusTag";
|
import { HttpStatusTag } from "./core/HttpStatusTag";
|
||||||
import {
|
import {
|
||||||
@@ -79,6 +81,7 @@ import type { TreeNode, TreeHandle, TreeProps, TreeItemProps } from "@yaakapp-in
|
|||||||
import { IconButton } from "./core/IconButton";
|
import { IconButton } from "./core/IconButton";
|
||||||
import type { InputHandle } from "./core/Input";
|
import type { InputHandle } from "./core/Input";
|
||||||
import { Input } from "./core/Input";
|
import { Input } from "./core/Input";
|
||||||
|
import { EmptyStateText } from "./EmptyStateText";
|
||||||
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
import { atomWithKVStorage } from "../lib/atoms/atomWithKVStorage";
|
||||||
import { GitDropdown } from "./git/GitDropdown";
|
import { GitDropdown } from "./git/GitDropdown";
|
||||||
import { gitCallbacks } from "./git/callbacks";
|
import { gitCallbacks } from "./git/callbacks";
|
||||||
@@ -108,7 +111,7 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
||||||
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
const treeId = `tree.${activeWorkspaceId ?? "unknown"}`;
|
||||||
const filterText = useAtomValue(sidebarFilterAtom);
|
const filterText = useAtomValue(sidebarFilterAtom);
|
||||||
const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? [];
|
const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||||
const wrapperRef = useRef<HTMLElement>(null);
|
const wrapperRef = useRef<HTMLElement>(null);
|
||||||
const treeRef = useRef<TreeHandle>(null);
|
const treeRef = useRef<TreeHandle>(null);
|
||||||
const filterRef = useRef<InputHandle>(null);
|
const filterRef = useRef<InputHandle>(null);
|
||||||
@@ -227,7 +230,7 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const clearFilterText = useCallback(() => {
|
const clearFilterText = useCallback(() => {
|
||||||
jotaiStore.set(sidebarFilterAtom, { text: "", key: `${Math.random()}` });
|
setSidebarFilterText("");
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
filterRef.current?.focus();
|
filterRef.current?.focus();
|
||||||
});
|
});
|
||||||
@@ -252,6 +255,13 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const applyFilterExample = useCallback((text: string) => {
|
||||||
|
setSidebarFilterText(text);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
filterRef.current?.focus();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
|
const treeHasFocus = useCallback(() => treeRef.current?.hasFocus() ?? false, []);
|
||||||
|
|
||||||
const getSelectedTreeModels = useCallback(
|
const getSelectedTreeModels = useCallback(
|
||||||
@@ -654,8 +664,43 @@ function Sidebar({ className }: { className?: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{allHidden ? (
|
{allHidden ? (
|
||||||
<div className="italic text-text-subtle p-3 text-sm text-center">
|
<div className="p-3 text-sm text-center">
|
||||||
No results for <InlineCode>{filterText.text}</InlineCode>
|
{(emptyFilterSuggestions?.length ?? 0) > 0 ? (
|
||||||
|
<EmptyStateText
|
||||||
|
wrapperClassName="!h-auto mb-auto"
|
||||||
|
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
No results, but found matches for{" "}
|
||||||
|
{emptyFilterSuggestions?.map((suggestion, i) => (
|
||||||
|
<span key={suggestion.field}>
|
||||||
|
{i > 0 && " or "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="max-w-full rounded align-middle focus-visible:outline focus-visible:outline-2 focus-visible:outline-info"
|
||||||
|
onClick={() => applyFilterExample(suggestion.filterText)}
|
||||||
|
>
|
||||||
|
<InlineCode className="inline-block max-w-36 truncate align-middle whitespace-nowrap transition-colors hover:border-border hover:bg-surface-active hover:text-text">
|
||||||
|
{suggestion.filterText}
|
||||||
|
</InlineCode>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</EmptyStateText>
|
||||||
|
) : (
|
||||||
|
<EmptyStateText
|
||||||
|
wrapperClassName="!h-auto mb-auto"
|
||||||
|
className="!h-auto py-3 px-3 !text-text-subtle text-sm leading-relaxed text-center"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
No results for{" "}
|
||||||
|
<InlineCode className="inline-block max-w-36 truncate align-middle">
|
||||||
|
{filterText.text}
|
||||||
|
</InlineCode>
|
||||||
|
</div>
|
||||||
|
</EmptyStateText>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Tree
|
<Tree
|
||||||
@@ -786,7 +831,48 @@ const sidebarFilterAtom = atom<{ text: string; key: string }>({
|
|||||||
key: "",
|
key: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
|
type SidebarFilterSuggestion = {
|
||||||
|
field: string;
|
||||||
|
filterText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function setSidebarFilterText(text: string) {
|
||||||
|
jotaiStore.set(sidebarFilterAtom, { text, key: `${Math.random()}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSidebarSuggestionValue(ast: Ast | null) {
|
||||||
|
if (ast == null) return null;
|
||||||
|
|
||||||
|
if (ast.type === "Term" || ast.type === "Phrase") {
|
||||||
|
const value = ast.value.trim();
|
||||||
|
return value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ast.type === "Field") {
|
||||||
|
const value = ast.value.trim();
|
||||||
|
return value.length > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sidebarFieldMatchesValue(fieldValue: string, filterValue: string) {
|
||||||
|
return fieldValue.toLowerCase().includes(filterValue.toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidebarSuggestionFieldOrder = [
|
||||||
|
"url",
|
||||||
|
"folder",
|
||||||
|
"method",
|
||||||
|
"type",
|
||||||
|
"grpc_service",
|
||||||
|
"grpc_method",
|
||||||
|
"name",
|
||||||
|
];
|
||||||
|
|
||||||
|
const sidebarTreeAtom = atom<
|
||||||
|
[TreeNode<SidebarModel>, FieldDef[], SidebarFilterSuggestion[]] | null
|
||||||
|
>((get) => {
|
||||||
const allModels = get(memoAllPotentialChildrenAtom);
|
const allModels = get(memoAllPotentialChildrenAtom);
|
||||||
const activeWorkspace = get(activeWorkspaceAtom);
|
const activeWorkspace = get(activeWorkspaceAtom);
|
||||||
const filter = get(sidebarFilterAtom);
|
const filter = get(sidebarFilterAtom);
|
||||||
@@ -807,9 +893,11 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const queryAst = parseQuery(filter.text);
|
const queryAst = parseQuery(filter.text);
|
||||||
|
const suggestionValue = getSidebarSuggestionValue(queryAst);
|
||||||
|
|
||||||
// returns true if this node OR any child matches the filter
|
// returns true if this node OR any child matches the filter
|
||||||
const allFields: Record<string, Set<string>> = {};
|
const allFields: Record<string, Set<string>> = {};
|
||||||
|
const suggestionFields = new Set<string>();
|
||||||
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
|
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
|
||||||
const childItems = childrenMap[node.item.id] ?? [];
|
const childItems = childrenMap[node.item.id] ?? [];
|
||||||
let matchesSelf = true;
|
let matchesSelf = true;
|
||||||
@@ -821,6 +909,13 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
|||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
allFields[field] = allFields[field] ?? new Set();
|
allFields[field] = allFields[field] ?? new Set();
|
||||||
allFields[field].add(value);
|
allFields[field].add(value);
|
||||||
|
if (
|
||||||
|
isLeafNode &&
|
||||||
|
suggestionValue != null &&
|
||||||
|
sidebarFieldMatchesValue(value, suggestionValue)
|
||||||
|
) {
|
||||||
|
suggestionFields.add(field);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (queryAst != null) {
|
if (queryAst != null) {
|
||||||
@@ -874,7 +969,18 @@ const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get)
|
|||||||
values: Array.from(values).filter((v) => v.length < 20),
|
values: Array.from(values).filter((v) => v.length < 20),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return [root, fields] as const;
|
const suggestions = Array.from(suggestionFields)
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIndex = sidebarSuggestionFieldOrder.indexOf(a);
|
||||||
|
const bIndex = sidebarSuggestionFieldOrder.indexOf(b);
|
||||||
|
if (aIndex === -1 && bIndex === -1) return a.localeCompare(b);
|
||||||
|
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex);
|
||||||
|
})
|
||||||
|
.map((field) => ({
|
||||||
|
field,
|
||||||
|
filterText: formatFieldFilter(field, suggestionValue ?? ""),
|
||||||
|
}));
|
||||||
|
return [root, fields, suggestions] as const;
|
||||||
});
|
});
|
||||||
|
|
||||||
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
|
const sidebarGitStatusByModelIdAtom = atom<Record<string, GitStatus>>((get) => {
|
||||||
|
|||||||
@@ -1,57 +1,73 @@
|
|||||||
import type { Color } from "@yaakapp-internal/plugins";
|
import type { Color } from "@yaakapp-internal/plugins";
|
||||||
import type { BannerProps } from "@yaakapp-internal/ui";
|
import type { BannerProps } from "@yaakapp-internal/ui";
|
||||||
import { Banner, HStack } from "@yaakapp-internal/ui";
|
import { Banner } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||||
|
import type { ButtonProps } from "./Button";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
|
||||||
export function DismissibleBanner({
|
export function DismissibleBanner({
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
id,
|
id,
|
||||||
|
onDismiss,
|
||||||
actions,
|
actions,
|
||||||
...props
|
...props
|
||||||
}: BannerProps & {
|
}: BannerProps & {
|
||||||
id: string;
|
id: string;
|
||||||
actions?: { label: string; onClick: () => void; color?: Color }[];
|
onDismiss?: () => void | Promise<void>;
|
||||||
|
actions?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
color?: Color;
|
||||||
|
variant?: ButtonProps["variant"];
|
||||||
|
}[];
|
||||||
}) {
|
}) {
|
||||||
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
|
const {
|
||||||
|
isLoading,
|
||||||
|
set: setDismissed,
|
||||||
|
value: dismissed,
|
||||||
|
} = useKeyValue<boolean>({
|
||||||
namespace: "global",
|
namespace: "global",
|
||||||
key: ["dismiss-banner", id],
|
key: ["dismiss-banner", id],
|
||||||
fallback: false,
|
fallback: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dismissed) return null;
|
if (isLoading || dismissed) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Banner
|
<Banner className={classNames(className, "relative")} {...props}>
|
||||||
className={classNames(className, "relative grid grid-cols-[1fr_auto] gap-3")}
|
<div className="@container">
|
||||||
{...props}
|
<div className="grid gap-2 @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center @[34rem]:gap-3">
|
||||||
>
|
{children}
|
||||||
{children}
|
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
|
||||||
<HStack space={1.5}>
|
<Button
|
||||||
{actions?.map((a) => (
|
variant="border"
|
||||||
<Button
|
color={props.color}
|
||||||
key={a.label}
|
size="xs"
|
||||||
variant="border"
|
onClick={() => {
|
||||||
color={a.color ?? props.color}
|
setDismissed(true).catch(console.error);
|
||||||
size="xs"
|
Promise.resolve(onDismiss?.()).catch(console.error);
|
||||||
onClick={a.onClick}
|
}}
|
||||||
title={a.label}
|
title="Dismiss message"
|
||||||
>
|
>
|
||||||
{a.label}
|
Dismiss
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
{actions?.map((a) => (
|
||||||
<Button
|
<Button
|
||||||
variant="border"
|
key={a.label}
|
||||||
color={props.color}
|
variant={a.variant ?? "border"}
|
||||||
size="xs"
|
color={a.color ?? props.color}
|
||||||
onClick={() => setDismissed((d) => !d)}
|
size="xs"
|
||||||
title="Dismiss message"
|
onClick={a.onClick}
|
||||||
>
|
title={a.label}
|
||||||
Dismiss
|
>
|
||||||
</Button>
|
{a.label}
|
||||||
</HStack>
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</Banner>
|
</Banner>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ export interface FilterOptions {
|
|||||||
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
|
fields: FieldDef[] | null; // e.g., ['method','status','path'] or [{name:'tag', values:()=>cachedTags}]
|
||||||
}
|
}
|
||||||
|
|
||||||
const IDENT = /[A-Za-z0-9_/]+$/;
|
const FIELD_IDENT = /[A-Za-z0-9_/]+$/;
|
||||||
const IDENT_ONLY = /^[A-Za-z0-9_/]+$/;
|
const VALUE_IDENT = /\S+$/;
|
||||||
|
const VALUE_IDENT_ONLY = /^\S+$/;
|
||||||
|
|
||||||
function normalizeFields(fields: FieldDef[]): {
|
function normalizeFields(fields: FieldDef[]): {
|
||||||
fieldNames: string[];
|
fieldNames: string[];
|
||||||
@@ -31,14 +32,37 @@ function normalizeFields(fields: FieldDef[]): {
|
|||||||
return { fieldNames, fieldMap };
|
return { fieldNames, fieldMap };
|
||||||
}
|
}
|
||||||
|
|
||||||
function wordBefore(doc: string, pos: number): { from: number; to: number; text: string } | null {
|
function wordBefore(
|
||||||
|
doc: string,
|
||||||
|
pos: number,
|
||||||
|
pattern: RegExp,
|
||||||
|
): { from: number; to: number; text: string } | null {
|
||||||
const upto = doc.slice(0, pos);
|
const upto = doc.slice(0, pos);
|
||||||
const m = upto.match(IDENT);
|
const m = upto.match(pattern);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const from = pos - m[0].length;
|
const from = pos - m[0].length;
|
||||||
return { from, to: pos, text: m[0] };
|
return { from, to: pos, text: m[0] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fieldCompletionFrom(doc: string, pos: number): { from: number; includeAt: boolean } | null {
|
||||||
|
const w = wordBefore(doc, pos, FIELD_IDENT);
|
||||||
|
const from = w?.from ?? pos;
|
||||||
|
const beforeToken = doc[from - 1];
|
||||||
|
|
||||||
|
if (from === 0 || (beforeToken != null && /\s/.test(beforeToken))) {
|
||||||
|
return { from, includeAt: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (beforeToken === "@") {
|
||||||
|
const beforeAt = doc[from - 2];
|
||||||
|
if (from === 1 || (beforeAt != null && /\s/.test(beforeAt))) {
|
||||||
|
return { from, includeAt: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function inPhrase(ctx: CompletionContext): boolean {
|
function inPhrase(ctx: CompletionContext): boolean {
|
||||||
// Lezer node names from your grammar: Phrase is the quoted token
|
// Lezer node names from your grammar: Phrase is the quoted token
|
||||||
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
|
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
|
||||||
@@ -81,7 +105,7 @@ function contextInfo(stateDoc: string, pos: number) {
|
|||||||
if (inValue) {
|
if (inValue) {
|
||||||
// word before the colon = field name
|
// word before the colon = field name
|
||||||
const beforeColon = stateDoc.slice(0, lastColon);
|
const beforeColon = stateDoc.slice(0, lastColon);
|
||||||
const m = beforeColon.match(IDENT);
|
const m = beforeColon.match(FIELD_IDENT);
|
||||||
fieldName = m ? m[0] : null;
|
fieldName = m ? m[0] : null;
|
||||||
|
|
||||||
// nothing (or only spaces) typed after the colon?
|
// nothing (or only spaces) typed after the colon?
|
||||||
@@ -93,15 +117,16 @@ function contextInfo(stateDoc: string, pos: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Build a completion list for field names */
|
/** Build a completion list for field names */
|
||||||
function fieldNameCompletions(fieldNames: string[]): Completion[] {
|
function fieldNameCompletions(fieldNames: string[], includeAt: boolean): Completion[] {
|
||||||
return fieldNames.map((name) => ({
|
return fieldNames.map((name) => ({
|
||||||
label: name,
|
label: name,
|
||||||
type: "property",
|
type: "property",
|
||||||
apply: (view, _completion, from, to) => {
|
apply: (view, _completion, from, to) => {
|
||||||
// Insert "name:" (leave cursor right after colon)
|
// Leave cursor right after the field filter colon.
|
||||||
|
const insert = `${includeAt ? "@" : ""}${name}:`;
|
||||||
view.dispatch({
|
view.dispatch({
|
||||||
changes: { from, to, insert: `${name}:` },
|
changes: { from, to, insert },
|
||||||
selection: { anchor: from + name.length + 1 },
|
selection: { anchor: from + insert.length },
|
||||||
});
|
});
|
||||||
startCompletion(view);
|
startCompletion(view);
|
||||||
},
|
},
|
||||||
@@ -115,7 +140,7 @@ function fieldValueCompletions(
|
|||||||
if (!def || !def.values) return null;
|
if (!def || !def.values) return null;
|
||||||
const vals = Array.isArray(def.values) ? def.values : def.values();
|
const vals = Array.isArray(def.values) ? def.values : def.values();
|
||||||
return vals.map((v) => ({
|
return vals.map((v) => ({
|
||||||
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
|
label: v.match(VALUE_IDENT_ONLY) ? v : `"${v}"`,
|
||||||
displayLabel: v,
|
displayLabel: v,
|
||||||
type: "constant",
|
type: "constant",
|
||||||
}));
|
}));
|
||||||
@@ -132,14 +157,13 @@ function makeCompletionSource(opts: FilterOptions) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const w = wordBefore(doc, pos);
|
|
||||||
const from = w?.from ?? pos;
|
|
||||||
const to = pos;
|
|
||||||
|
|
||||||
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
|
const { inValue, fieldName, emptyAfterColon } = contextInfo(doc, pos);
|
||||||
|
|
||||||
// In field value position
|
// In field value position
|
||||||
if (inValue && fieldName) {
|
if (inValue && fieldName) {
|
||||||
|
const w = wordBefore(doc, pos, VALUE_IDENT);
|
||||||
|
const from = w?.from ?? pos;
|
||||||
|
const to = pos;
|
||||||
const valDefs = fieldMap[fieldName];
|
const valDefs = fieldMap[fieldName];
|
||||||
const vals = fieldValueCompletions(valDefs);
|
const vals = fieldValueCompletions(valDefs);
|
||||||
|
|
||||||
@@ -162,7 +186,11 @@ function makeCompletionSource(opts: FilterOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Not in a value: suggest field names (and maybe boolean ops)
|
// Not in a value: suggest field names (and maybe boolean ops)
|
||||||
const options: Completion[] = fieldNameCompletions(fieldNames);
|
const completion = fieldCompletionFrom(doc, pos);
|
||||||
|
if (completion == null) return null;
|
||||||
|
const { from, includeAt } = completion;
|
||||||
|
const to = pos;
|
||||||
|
const options: Completion[] = fieldNameCompletions(fieldNames, includeAt);
|
||||||
|
|
||||||
return { from, to, options, filter: true };
|
return { from, to, options, filter: true };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
@skip { space+ }
|
@skip { space+ }
|
||||||
@tokens {
|
@tokens {
|
||||||
space { std.whitespace+ }
|
space { $[ \t\r\n]+ }
|
||||||
|
|
||||||
LParen { "(" }
|
LParen { "(" }
|
||||||
RParen { ")" }
|
RParen { ")" }
|
||||||
|
At { "@" }
|
||||||
Colon { ":" }
|
Colon { ":" }
|
||||||
Not { "-" | "NOT" }
|
Not { "-" | "NOT" }
|
||||||
|
|
||||||
@@ -16,8 +17,10 @@
|
|||||||
// "quoted phrase" with simple escapes: \" and \\
|
// "quoted phrase" with simple escapes: \" and \\
|
||||||
Phrase { '"' (!["\\] | "\\" _)* '"' }
|
Phrase { '"' (!["\\] | "\\" _)* '"' }
|
||||||
|
|
||||||
// field/word characters (keep generous for URLs/paths)
|
// Bare words run until filter syntax or whitespace. Leading '-' remains unary
|
||||||
Word { $[A-Za-z0-9_]+ }
|
// negation, but '-' may appear after the first character.
|
||||||
|
Word { ![ \t\r\n():"@-] ![ \t\r\n():"@]* }
|
||||||
|
FieldValueWord { ![ \t\r\n"] ![ \t\r\n]* }
|
||||||
|
|
||||||
@precedence { Not, And, Or, Word }
|
@precedence { Not, And, Or, Word }
|
||||||
}
|
}
|
||||||
@@ -60,12 +63,12 @@ Field {
|
|||||||
}
|
}
|
||||||
|
|
||||||
FieldName {
|
FieldName {
|
||||||
Word
|
At? Word
|
||||||
}
|
}
|
||||||
|
|
||||||
FieldValue {
|
FieldValue {
|
||||||
Phrase
|
Phrase
|
||||||
| Term
|
| FieldValueWord
|
||||||
}
|
}
|
||||||
|
|
||||||
Term {
|
Term {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { parser } from "./filter";
|
||||||
|
|
||||||
|
function getNodeNames(input: string): string[] {
|
||||||
|
const tree = parser.parse(input);
|
||||||
|
const nodes: string[] = [];
|
||||||
|
const cursor = tree.cursor();
|
||||||
|
do {
|
||||||
|
if (cursor.name !== "Query") {
|
||||||
|
nodes.push(cursor.name);
|
||||||
|
}
|
||||||
|
} while (cursor.next());
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("filter grammar", () => {
|
||||||
|
test("parses URL-like field values as one value", () => {
|
||||||
|
const nodes = getNodeNames("@url:yaak.app/foo-bar");
|
||||||
|
|
||||||
|
expect(nodes).not.toContain("⚠");
|
||||||
|
expect(nodes).toContain("FieldValue");
|
||||||
|
expect(nodes).toContain("FieldValueWord");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses punctuation-heavy field values as one value", () => {
|
||||||
|
const nodes = getNodeNames("@url:yaa$&#*@tsrna(*)");
|
||||||
|
|
||||||
|
expect(nodes).not.toContain("⚠");
|
||||||
|
expect(nodes).toContain("FieldValue");
|
||||||
|
expect(nodes).toContain("FieldValueWord");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parses operator-looking field values as one value", () => {
|
||||||
|
const negativeValueNodes = getNodeNames("@url:-foo");
|
||||||
|
const operatorWordNodes = getNodeNames("@url:AND");
|
||||||
|
|
||||||
|
expect(negativeValueNodes).not.toContain("⚠");
|
||||||
|
expect(negativeValueNodes).toContain("FieldValueWord");
|
||||||
|
expect(operatorWordNodes).not.toContain("⚠");
|
||||||
|
expect(operatorWordNodes).toContain("FieldValueWord");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,27 +1,22 @@
|
|||||||
/* oxlint-disable */
|
|
||||||
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
// This file was generated by lezer-generator. You probably shouldn't edit it.
|
||||||
import { LRParser } from "@lezer/lr";
|
import {LRParser} from "@lezer/lr"
|
||||||
import { highlight } from "./highlight";
|
import {highlight} from "./highlight"
|
||||||
export const parser = LRParser.deserialize({
|
export const parser = LRParser.deserialize({
|
||||||
version: 14,
|
version: 14,
|
||||||
states:
|
states: "%WOVQPOOPhOPOOOVQPO'#CfOmQPO'#ChO!_QPO'#ChO!dQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!iQPO'#C`O!yQPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cr'#CrP#UOPO)C>lO#]QPO,59QOOQO,59S,59SO#bQQO,59ROOQO,58{,58{OVQPO'#CsOOQO'#Cs'#CsO#jQPO,58zOVQPO'#CtO#zQPO,58yPOOO-E6p-E6pOOQO1G.l1G.lOOQO'#Cl'#ClOOQO1G.m1G.mOOQO,59_,59_OOQO-E6q-E6qOOQO,59`,59`OOQO-E6r-E6r",
|
||||||
"%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#mQPO,58zOVQPO'#CrO#zQPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
|
stateData: "$]~OkPQ~OUVOXQO]SO^ROaUO~Ok]O~OUcXXcX]cX^cX_[XacXdcXecXicXWcX~O^`O~O_aO~OdcOeSXiSXWSX~PVOefOiRXWRX~Ok]O~Qj]WiO~OajObjO~OdcOeSaiSaWSa~PVOefOiRaWRa~OUde^e~",
|
||||||
stateData:
|
goto: "#^iPPjpt{P![PP!e!e!nPPP!wPP!ePP!z#Q#WQ[OR_QTZOQSYOQRnfUXOQfQbVSdXeRlc_WOQVXcef_UOQVXcef_TOQVXcefRkaQ^PRh^QeXRmeQgYRog",
|
||||||
"$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~",
|
nodeNames: "⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName At Word Colon FieldValue Phrase FieldValueWord Term And Or",
|
||||||
goto: "#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne",
|
maxTerm: 27,
|
||||||
nodeNames:
|
|
||||||
"⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
|
|
||||||
maxTerm: 25,
|
|
||||||
nodeProps: [
|
nodeProps: [
|
||||||
["openedBy", 8, "LParen"],
|
["openedBy", 8,"LParen"],
|
||||||
["closedBy", 9, "RParen"],
|
["closedBy", 9,"RParen"]
|
||||||
],
|
],
|
||||||
propSources: [highlight],
|
propSources: [highlight],
|
||||||
skippedNodes: [0, 20],
|
skippedNodes: [0,22],
|
||||||
repeatNodeCount: 3,
|
repeatNodeCount: 3,
|
||||||
tokenData:
|
tokenData: "2h~RiOX!pXY$hYZ$hZ]!p]^$h^p!ppq$hqr!prs$ysx!pxy&gyz'Qz}!p}!O'k!OY!d!p!p!p!q,q!q!r0Y!r;'S!p;'S;=`$b<%lO!pR!w^bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pQ#xUbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sQ$_P;=`<%l#sR$eP;=`<%l!p~$mSk~XY$hYZ$h]^$hpq$h~$|VOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a<%lO$y~%hOa~~%kRO;'S$y;'S;=`%t;=`O$y~%wWOr$yrs%cs#O$y#O#P%h#P;'S$y;'S;=`&a;=`<%l$y<%lO$y~&dP;=`<%l$yR&nUbQXPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'XUbQWPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR'rUbQUPOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(]U_PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR(vU]PbQOX#sZ]#s^p#sq;'S#s;'S;=`$[<%lO#sR)a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!p!p!p!q*c!q;'S!p;'S;=`$b<%lO!pR*j`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!f!p!f!g+l!g;'S!p;'S;=`$b<%lO!pR+u^bQdP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR,x`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!q!p!q!r-z!r;'S!p;'S;=`$b<%lO!pR.R`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!v!p!v!w/T!w;'S!p;'S;=`$b<%lO!pR/^^bQUP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!pR0a`bQ^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c!t!p!t!u1c!u;'S!p;'S;=`$b<%lO!pR1l^bQeP^POX!pZ]!p^p!pqr!prs#ssx!pxz#sz![!p![!]#s!]!b!p!b!c#s!c;'S!p;'S;=`$b<%lO!p",
|
||||||
")f~RgX^!jpq!jrs#_xy${yz%Q}!O%V!Q![%[![!]%m!c!d%r!d!p%[!p!q'V!q!r(j!r!}%[#R#S%[#T#o%[#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~!oYi~X^!jpq!j#y#z!j$f$g!j#BY#BZ!j$IS$I_!j$I|$JO!j$JT$JU!j$KV$KW!j&FU&FV!j~#bVOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u<%lO#_~#|O`~~$PRO;'S#_;'S;=`$Y;=`O#_~$]WOr#_rs#ws#O#_#O#P#|#P;'S#_;'S;=`$u;=`<%l#_<%lO#_~$xP;=`<%l#_~%QOX~~%VOW~~%[OU~~%aS]~!Q![%[!c!}%[#R#S%[#T#o%[~%rO^~~%wU]~!Q![%[!c!p%[!p!q&Z!q!}%[#R#S%[#T#o%[~&`U]~!Q![%[!c!f%[!f!g&r!g!}%[#R#S%[#T#o%[~&ySb~]~!Q![%[!c!}%[#R#S%[#T#o%[~'[U]~!Q![%[!c!q%[!q!r'n!r!}%[#R#S%[#T#o%[~'sU]~!Q![%[!c!v%[!v!w(V!w!}%[#R#S%[#T#o%[~(^SU~]~!Q![%[!c!}%[#R#S%[#T#o%[~(oU]~!Q![%[!c!t%[!t!u)R!u!}%[#R#S%[#T#o%[~)YSc~]~!Q![%[!c!}%[#R#S%[#T#o%[",
|
tokenizers: [0, 1],
|
||||||
tokenizers: [0],
|
topRules: {"Query":[0,1]},
|
||||||
topRules: { Query: [0, 1] },
|
tokenPrec: 145
|
||||||
tokenPrec: 145,
|
})
|
||||||
});
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { formatFieldFilter } from "./format";
|
||||||
|
import { evaluate, parseQuery } from "./query";
|
||||||
|
|
||||||
|
function matchesFormattedUrl(value: string) {
|
||||||
|
return evaluate(parseQuery(formatFieldFilter("url", value)), {
|
||||||
|
fields: { url: value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("formatFieldFilter", () => {
|
||||||
|
test("keeps URL-like values bare", () => {
|
||||||
|
expect(formatFieldFilter("url", "yaak.app/foo-bar")).toBe("@url:yaak.app/foo-bar");
|
||||||
|
expect(matchesFormattedUrl("yaak.app/foo-bar")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps non-syntax punctuation bare", () => {
|
||||||
|
expect(formatFieldFilter("url", "yaa$&#*@tsrna(*)")).toBe("@url:yaa$&#*@tsrna(*)");
|
||||||
|
expect(matchesFormattedUrl("yaa$&#*@tsrna(*)")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps values that start with an operator token bare", () => {
|
||||||
|
expect(formatFieldFilter("url", "-foo")).toBe("@url:-foo");
|
||||||
|
expect(matchesFormattedUrl("-foo")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps boolean operator words bare", () => {
|
||||||
|
expect(formatFieldFilter("url", "AND")).toBe("@url:AND");
|
||||||
|
expect(formatFieldFilter("url", "or")).toBe("@url:or");
|
||||||
|
expect(formatFieldFilter("url", "Not")).toBe("@url:Not");
|
||||||
|
expect(matchesFormattedUrl("AND")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("escapes quoted values", () => {
|
||||||
|
expect(formatFieldFilter("url", 'say "hi"')).toBe('@url:"say \\"hi\\""');
|
||||||
|
expect(matchesFormattedUrl('say "hi"')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("quotes values that start with a quote", () => {
|
||||||
|
expect(formatFieldFilter("url", '"hi"')).toBe('@url:"\\"hi\\""');
|
||||||
|
expect(matchesFormattedUrl('"hi"')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const bareFieldValue = /^[^\s"]\S*$/;
|
||||||
|
|
||||||
|
export function formatFieldFilter(field: string, value: string) {
|
||||||
|
const escapedValue = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||||
|
const filterValue = bareFieldValue.test(value) ? value : `"${escapedValue}"`;
|
||||||
|
return `@${field}:${filterValue}`;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export const highlight = styleTags({
|
|||||||
Phrase: t.string, // "quoted string"
|
Phrase: t.string, // "quoted string"
|
||||||
|
|
||||||
// Fields
|
// Fields
|
||||||
|
"FieldName/At": t.attributeName,
|
||||||
"FieldName/Word": t.attributeName,
|
"FieldName/Word": t.attributeName,
|
||||||
"FieldValue/Term/Word": t.attributeValue,
|
"FieldValue/FieldValueWord": t.attributeValue,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ type Tok =
|
|||||||
| { kind: "EOF" };
|
| { kind: "EOF" };
|
||||||
|
|
||||||
const isSpace = (c: string) => /\s/.test(c);
|
const isSpace = (c: string) => /\s/.test(c);
|
||||||
const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c);
|
const isWordStart = (c: string) => c !== "" && !isSpace(c) && !/[():"@-]/.test(c);
|
||||||
|
const isWordChar = (c: string) => c !== "" && !isSpace(c) && !/[():"@]/.test(c);
|
||||||
|
|
||||||
export function tokenize(input: string): Tok[] {
|
export function tokenize(input: string): Tok[] {
|
||||||
const toks: Tok[] = [];
|
const toks: Tok[] = [];
|
||||||
@@ -42,7 +43,13 @@ export function tokenize(input: string): Tok[] {
|
|||||||
|
|
||||||
const readWord = () => {
|
const readWord = () => {
|
||||||
let s = "";
|
let s = "";
|
||||||
while (i < n && isIdent(peek())) s += advance();
|
while (i < n && isWordChar(peek())) s += advance();
|
||||||
|
return s;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFieldValue = () => {
|
||||||
|
let s = "";
|
||||||
|
while (i < n && !isSpace(peek())) s += advance();
|
||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,6 +92,9 @@ export function tokenize(input: string): Tok[] {
|
|||||||
if (c === ":") {
|
if (c === ":") {
|
||||||
toks.push({ kind: "COLON" });
|
toks.push({ kind: "COLON" });
|
||||||
i++;
|
i++;
|
||||||
|
if (peek() && !isSpace(peek()) && peek() !== `"`) {
|
||||||
|
toks.push({ kind: "WORD", text: readFieldValue() });
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (c === `"`) {
|
if (c === `"`) {
|
||||||
@@ -99,7 +109,7 @@ export function tokenize(input: string): Tok[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// WORD / AND / OR / NOT
|
// WORD / AND / OR / NOT
|
||||||
if (isIdent(c)) {
|
if (isWordStart(c)) {
|
||||||
const w = readWord();
|
const w = readWord();
|
||||||
const upper = w.toUpperCase();
|
const upper = w.toUpperCase();
|
||||||
if (upper === "AND") toks.push({ kind: "AND" });
|
if (upper === "AND") toks.push({ kind: "AND" });
|
||||||
|
|||||||
@@ -290,10 +290,10 @@ function BaseInput({
|
|||||||
<HStack
|
<HStack
|
||||||
className={classNames(
|
className={classNames(
|
||||||
inputWrapperClassName,
|
inputWrapperClassName,
|
||||||
"w-full min-w-0 px-2",
|
"flex-1 min-w-0 px-2",
|
||||||
fullHeight && "h-full",
|
fullHeight && "h-full",
|
||||||
leftSlot ? "pl-0.5 -ml-2" : null,
|
leftSlot ? "pl-0" : null,
|
||||||
rightSlot ? "pr-0.5 -mr-2" : null,
|
rightSlot ? "pr-0" : null,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Editor
|
<Editor
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { resolvedModelName } from "../../lib/resolvedModelName";
|
|||||||
import { showConfirm } from "../../lib/confirm";
|
import { showConfirm } from "../../lib/confirm";
|
||||||
import { showErrorToast } from "../../lib/toast";
|
import { showErrorToast } from "../../lib/toast";
|
||||||
import { sync } from "../../init/sync";
|
import { sync } from "../../init/sync";
|
||||||
|
import { CommercialUseBanner } from "../CommercialUseBanner";
|
||||||
import { Button } from "../core/Button";
|
import { Button } from "../core/Button";
|
||||||
import type { CheckboxProps } from "../core/Checkbox";
|
import type { CheckboxProps } from "../core/Checkbox";
|
||||||
import { Checkbox } from "../core/Checkbox";
|
import { Checkbox } from "../core/Checkbox";
|
||||||
@@ -205,7 +206,10 @@ export function GitCommitDialog({ syncDir, onDone, workspace }: Props) {
|
|||||||
layout="horizontal"
|
layout="horizontal"
|
||||||
defaultRatio={0.6}
|
defaultRatio={0.6}
|
||||||
firstSlot={({ style }) => (
|
firstSlot={({ style }) => (
|
||||||
<div style={style} className="h-full px-4">
|
<div style={style} className="h-full px-4 grid grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||||
|
<CommercialUseBanner source="git-commit" title="Using Git for work?">
|
||||||
|
A Yaak license is required for commercial use and helps support features like this.
|
||||||
|
</CommercialUseBanner>
|
||||||
<SplitLayout
|
<SplitLayout
|
||||||
storageKey="commit-vertical"
|
storageKey="commit-vertical"
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export async function addGitRemote(dir: string, defaultName?: string): Promise<G
|
|||||||
title: "Add Remote",
|
title: "Add Remote",
|
||||||
inputs: [
|
inputs: [
|
||||||
{ type: "text", label: "Name", name: "name", defaultValue: defaultName },
|
{ type: "text", label: "Name", name: "name", defaultValue: defaultName },
|
||||||
{ type: "text", label: "URL", name: "url" },
|
{ type: "text", label: "URL", name: "url", placeholder: "git@github.com:org/repo.git" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
if (r == null) throw new Error("Cancelled remote prompt");
|
if (r == null) throw new Error("Cancelled remote prompt");
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ function HttpTextViewer({ response, text, language, pretty, className }: HttpTex
|
|||||||
text={text}
|
text={text}
|
||||||
language={language}
|
language={language}
|
||||||
stateKey={`response.body.${response.id}`}
|
stateKey={`response.body.${response.id}`}
|
||||||
|
filterStateKey={`response.body.${response.requestId}`}
|
||||||
pretty={pretty}
|
pretty={pretty}
|
||||||
className={className}
|
className={className}
|
||||||
onFilter={filterCallback}
|
onFilter={filterCallback}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ interface Props {
|
|||||||
text: string;
|
text: string;
|
||||||
language: EditorProps["language"];
|
language: EditorProps["language"];
|
||||||
stateKey: string | null;
|
stateKey: string | null;
|
||||||
|
filterStateKey?: string | null;
|
||||||
pretty?: boolean;
|
pretty?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
onFilter?: (filter: string) => {
|
onFilter?: (filter: string) => {
|
||||||
@@ -27,16 +28,25 @@ interface Props {
|
|||||||
|
|
||||||
const useFilterText = createGlobalState<Record<string, string | null>>({});
|
const useFilterText = createGlobalState<Record<string, string | null>>({});
|
||||||
|
|
||||||
export function TextViewer({ language, text, stateKey, pretty, className, onFilter }: Props) {
|
export function TextViewer({
|
||||||
|
language,
|
||||||
|
text,
|
||||||
|
stateKey,
|
||||||
|
filterStateKey,
|
||||||
|
pretty,
|
||||||
|
className,
|
||||||
|
onFilter,
|
||||||
|
}: Props) {
|
||||||
|
const filterKey = filterStateKey ?? stateKey;
|
||||||
const [filterTextMap, setFilterTextMap] = useFilterText();
|
const [filterTextMap, setFilterTextMap] = useFilterText();
|
||||||
const filterText = stateKey ? (filterTextMap[stateKey] ?? null) : null;
|
const filterText = filterKey ? (filterTextMap[filterKey] ?? null) : null;
|
||||||
const debouncedFilterText = useDebouncedValue(filterText);
|
const debouncedFilterText = useDebouncedValue(filterText);
|
||||||
const setFilterText = useCallback(
|
const setFilterText = useCallback(
|
||||||
(v: string | null) => {
|
(v: string | null) => {
|
||||||
if (!stateKey) return;
|
if (!filterKey) return;
|
||||||
setFilterTextMap((m) => ({ ...m, [stateKey]: v }));
|
setFilterTextMap((m) => ({ ...m, [filterKey]: v }));
|
||||||
},
|
},
|
||||||
[setFilterTextMap, stateKey],
|
[filterKey, setFilterTextMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
const isSearching = filterText != null;
|
const isSearching = filterText != null;
|
||||||
@@ -64,7 +74,7 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
|
|||||||
nodes.push(
|
nodes.push(
|
||||||
<div key="input" className="w-full !opacity-100">
|
<div key="input" className="w-full !opacity-100">
|
||||||
<Input
|
<Input
|
||||||
key={stateKey ?? "filter"}
|
key={filterKey ?? "filter"}
|
||||||
validate={!filteredResponse.error}
|
validate={!filteredResponse.error}
|
||||||
hideLabel
|
hideLabel
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -76,7 +86,7 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
|
|||||||
defaultValue={filterText}
|
defaultValue={filterText}
|
||||||
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
|
onKeyDown={(e) => e.key === "Escape" && toggleSearch()}
|
||||||
onChange={setFilterText}
|
onChange={setFilterText}
|
||||||
stateKey={stateKey ? `filter.${stateKey}` : null}
|
stateKey={filterKey ? `filter.${filterKey}` : null}
|
||||||
/>
|
/>
|
||||||
</div>,
|
</div>,
|
||||||
);
|
);
|
||||||
@@ -97,12 +107,12 @@ export function TextViewer({ language, text, stateKey, pretty, className, onFilt
|
|||||||
return nodes;
|
return nodes;
|
||||||
}, [
|
}, [
|
||||||
canFilter,
|
canFilter,
|
||||||
|
filterKey,
|
||||||
filterText,
|
filterText,
|
||||||
filteredResponse.error,
|
filteredResponse.error,
|
||||||
filteredResponse.isPending,
|
filteredResponse.isPending,
|
||||||
isSearching,
|
isSearching,
|
||||||
language,
|
language,
|
||||||
stateKey,
|
|
||||||
setFilterText,
|
setFilterText,
|
||||||
toggleSearch,
|
toggleSearch,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -35,10 +35,15 @@ export async function deleteModelWithConfirm(
|
|||||||
<>
|
<>
|
||||||
the following?
|
the following?
|
||||||
<Prose className="mt-2">
|
<Prose className="mt-2">
|
||||||
<ul>
|
<ul className="space-y-1">
|
||||||
{models.map((m) => (
|
{models.map((m) => (
|
||||||
<li key={m.id}>
|
<li key={m.id}>
|
||||||
<InlineCode>{resolvedModelName(m)}</InlineCode>
|
<InlineCode
|
||||||
|
className="inline-block truncate align-bottom max-w-full"
|
||||||
|
title={resolvedModelName(m)}
|
||||||
|
>
|
||||||
|
{resolvedModelName(m)}
|
||||||
|
</InlineCode>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function pricingUrl(intent: string): string {
|
||||||
|
return `https://yaak.app/pricing?intent=${encodeURIComponent(intent)}`;
|
||||||
|
}
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"decompress": "^4.2.1",
|
"decompress": "^4.2.1",
|
||||||
"internal-ip": "^8.0.0",
|
"internal-ip": "^8.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.14",
|
||||||
"postcss-nesting": "^13.0.2",
|
"postcss-nesting": "^13.0.2",
|
||||||
"rollup": "^4.60.3",
|
"rollup": "^4.60.3",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
|
|||||||
Generated
+53
-5
@@ -186,7 +186,7 @@
|
|||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
"decompress": "^4.2.1",
|
"decompress": "^4.2.1",
|
||||||
"internal-ip": "^8.0.0",
|
"internal-ip": "^8.0.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.14",
|
||||||
"postcss-nesting": "^13.0.2",
|
"postcss-nesting": "^13.0.2",
|
||||||
"rollup": "^4.60.3",
|
"rollup": "^4.60.3",
|
||||||
"tailwindcss": "^3.4.17",
|
"tailwindcss": "^3.4.17",
|
||||||
@@ -223,6 +223,54 @@
|
|||||||
"node": "^18 || >=20"
|
"node": "^18 || >=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"apps/yaak-client/node_modules/postcss": {
|
||||||
|
"version": "8.5.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
|
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.11",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"apps/yaak-client/node_modules/postcss/node_modules/nanoid": {
|
||||||
|
"version": "3.3.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"apps/yaak-client/node_modules/uuid": {
|
"apps/yaak-client/node_modules/uuid": {
|
||||||
"version": "14.0.0",
|
"version": "14.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",
|
||||||
@@ -16805,9 +16853,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.19.0",
|
"version": "8.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
@@ -16919,7 +16967,7 @@
|
|||||||
"packages/plugin-runtime": {
|
"packages/plugin-runtime": {
|
||||||
"name": "@yaakapp-internal/plugin-runtime",
|
"name": "@yaakapp-internal/plugin-runtime",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ws": "^8.5.13"
|
"@types/ws": "^8.5.13"
|
||||||
|
|||||||
@@ -75,6 +75,7 @@
|
|||||||
"start": "npm run client:dev",
|
"start": "npm run client:dev",
|
||||||
"client:build": "node scripts/run-build.mjs client",
|
"client:build": "node scripts/run-build.mjs client",
|
||||||
"client:dev": "node scripts/run-dev.mjs client",
|
"client:dev": "node scripts/run-dev.mjs client",
|
||||||
|
"client:bundle": "node scripts/run-build.mjs client --config crates-tauri/yaak-app-client/tauri.release.conf.json --no-sign",
|
||||||
"proxy:build": "node scripts/run-build.mjs proxy",
|
"proxy:build": "node scripts/run-build.mjs proxy",
|
||||||
"proxy:dev": "node scripts/run-dev.mjs proxy",
|
"proxy:dev": "node scripts/run-dev.mjs proxy",
|
||||||
"migration": "node scripts/create-migration.cjs",
|
"migration": "node scripts/create-migration.cjs",
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
|
"build:main": "esbuild src/index.ts --bundle --platform=node --outfile=../../crates-tauri/yaak-app-client/vendored/plugin-runtime/index.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ws": "^8.18.0"
|
"ws": "^8.20.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ws": "^8.5.13"
|
"@types/ws": "^8.5.13"
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const defaultLightTheme: Theme = {
|
|||||||
dark: false,
|
dark: false,
|
||||||
base: {
|
base: {
|
||||||
surface: "hsl(0,0%,100%)",
|
surface: "hsl(0,0%,100%)",
|
||||||
surfaceHighlight: "hsl(218,24%,87%)",
|
surfaceHighlight: "hsl(218,24%,92%)",
|
||||||
text: "hsl(217,24%,10%)",
|
text: "hsl(217,24%,10%)",
|
||||||
textSubtle: "hsl(217,24%,40%)",
|
textSubtle: "hsl(217,24%,40%)",
|
||||||
textSubtlest: "hsl(217,24%,58%)",
|
textSubtlest: "hsl(217,24%,58%)",
|
||||||
@@ -70,7 +70,7 @@ export const defaultLightTheme: Theme = {
|
|||||||
sidebar: {
|
sidebar: {
|
||||||
surface: "hsl(220,20%,98%)",
|
surface: "hsl(220,20%,98%)",
|
||||||
border: "hsl(217,22%,88%)",
|
border: "hsl(217,22%,88%)",
|
||||||
surfaceHighlight: "hsl(217,25%,90%)",
|
surfaceHighlight: "hsl(217,25%,94%)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -125,10 +125,11 @@ function bannerColorVariables(color: YaakColor | null): Partial<CSSVariables> {
|
|||||||
if (color == null) return {};
|
if (color == null) return {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
text: color.lift(0.8).css(),
|
text: color.desaturate(0.5).lift(0.12).css(),
|
||||||
textSubtle: color.translucify(0.3).css(),
|
textSubtle: color.desaturate(0.58).lift(0.04).translucify(0.04).css(),
|
||||||
textSubtlest: color.translucify(0.6).css(),
|
textSubtlest: color.desaturate(0.65).translucify(0.18).css(),
|
||||||
surface: color.translucify(0.95).css(),
|
surface: color.translucify(0.95).css(),
|
||||||
|
surfaceHighlight: color.translucify(0.85).css(),
|
||||||
border: color.lift(0.3).translucify(0.8).css(),
|
border: color.lift(0.3).translucify(0.8).css(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -181,6 +181,78 @@ export function convertCurl(rawData: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ExtractedAuthentication {
|
||||||
|
authenticationType: string | null;
|
||||||
|
authentication: Record<string, string>;
|
||||||
|
filteredHeaders: HttpUrlParameter[]; // headers without authorization
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAuthenticationFromHeaders(headers: HttpUrlParameter[]): ExtractedAuthentication {
|
||||||
|
const authorizationHeaderIndex = headers.findIndex(
|
||||||
|
(h) => h.name.toLowerCase() === "authorization",
|
||||||
|
);
|
||||||
|
|
||||||
|
const authorizationHeader = headers[authorizationHeaderIndex];
|
||||||
|
if (authorizationHeader == null) {
|
||||||
|
return {
|
||||||
|
authenticationType: null,
|
||||||
|
authentication: {},
|
||||||
|
filteredHeaders: headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = authorizationHeader.value.trim();
|
||||||
|
const spaceIndex = value.indexOf(" ");
|
||||||
|
|
||||||
|
if (spaceIndex <= 0) {
|
||||||
|
return {
|
||||||
|
authenticationType: null,
|
||||||
|
authentication: {},
|
||||||
|
filteredHeaders: headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheme = value.slice(0, spaceIndex).toLowerCase();
|
||||||
|
const credentials = value.slice(spaceIndex + 1).trim();
|
||||||
|
|
||||||
|
// Bearer authentication (RFC 6750)
|
||||||
|
if (scheme === "bearer") {
|
||||||
|
const filteredHeaders = headers.filter((_, i) => i !== authorizationHeaderIndex);
|
||||||
|
return {
|
||||||
|
authenticationType: "bearer",
|
||||||
|
authentication: { token: credentials, prefix: "Bearer" },
|
||||||
|
filteredHeaders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Basic authentication (RFC 7617)
|
||||||
|
if (scheme === "basic") {
|
||||||
|
try {
|
||||||
|
const decoded = Buffer.from(credentials, "base64").toString();
|
||||||
|
const colonIndex = decoded.indexOf(":");
|
||||||
|
if (colonIndex > 0) {
|
||||||
|
const filteredHeaders = headers.filter((_, i) => i !== authorizationHeaderIndex);
|
||||||
|
return {
|
||||||
|
authenticationType: "basic",
|
||||||
|
authentication: {
|
||||||
|
username: decoded.slice(0, colonIndex),
|
||||||
|
password: decoded.slice(colonIndex + 1),
|
||||||
|
},
|
||||||
|
filteredHeaders,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid base64, keep header as-is
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
authenticationType: null,
|
||||||
|
authentication: {},
|
||||||
|
filteredHeaders: headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function importCommand(parseEntries: string[], workspaceId: string) {
|
function importCommand(parseEntries: string[], workspaceId: string) {
|
||||||
// ~~~~~~~~~~~~~~~~~~~~~ //
|
// ~~~~~~~~~~~~~~~~~~~~~ //
|
||||||
// Collect all the flags //
|
// Collect all the flags //
|
||||||
@@ -323,8 +395,23 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract authentication from Authorization headers (Bearer/Basic)
|
||||||
|
const {
|
||||||
|
authenticationType: extractedAuthenticationType,
|
||||||
|
authentication: extractedAuthentication,
|
||||||
|
filteredHeaders,
|
||||||
|
} = extractAuthenticationFromHeaders(headers);
|
||||||
|
|
||||||
|
// Use extracted authentication from header if found, otherwise fall back to -u/--user parsing
|
||||||
|
const finalAuthenticationType = extractedAuthenticationType || authenticationType;
|
||||||
|
const finalAuthentication = extractedAuthenticationType
|
||||||
|
? extractedAuthentication
|
||||||
|
: authentication;
|
||||||
|
|
||||||
// Body (Text or Blob)
|
// Body (Text or Blob)
|
||||||
const contentTypeHeader = headers.find((header) => header.name.toLowerCase() === "content-type");
|
const contentTypeHeader = filteredHeaders.find(
|
||||||
|
(header) => header.name.toLowerCase() === "content-type",
|
||||||
|
);
|
||||||
const mimeType = contentTypeHeader ? contentTypeHeader.value.split(";")[0]?.trim() : null;
|
const mimeType = contentTypeHeader ? contentTypeHeader.value.split(";")[0]?.trim() : null;
|
||||||
|
|
||||||
// Extract boundary from Content-Type header for multipart parsing
|
// Extract boundary from Content-Type header for multipart parsing
|
||||||
@@ -398,7 +485,7 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
|||||||
value: decodeURIComponent(parameter.value || ""),
|
value: decodeURIComponent(parameter.value || ""),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
headers.push({
|
filteredHeaders.push({
|
||||||
name: "Content-Type",
|
name: "Content-Type",
|
||||||
value: "application/x-www-form-urlencoded",
|
value: "application/x-www-form-urlencoded",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -419,7 +506,7 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
|||||||
form: formDataParams,
|
form: formDataParams,
|
||||||
};
|
};
|
||||||
if (mimeType == null) {
|
if (mimeType == null) {
|
||||||
headers.push({
|
filteredHeaders.push({
|
||||||
name: "Content-Type",
|
name: "Content-Type",
|
||||||
value: "multipart/form-data",
|
value: "multipart/form-data",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -442,9 +529,9 @@ function importCommand(parseEntries: string[], workspaceId: string) {
|
|||||||
urlParameters,
|
urlParameters,
|
||||||
url,
|
url,
|
||||||
method,
|
method,
|
||||||
headers,
|
headers: filteredHeaders,
|
||||||
authentication,
|
authentication: finalAuthentication,
|
||||||
authenticationType,
|
authenticationType: finalAuthenticationType,
|
||||||
body,
|
body,
|
||||||
bodyType,
|
bodyType,
|
||||||
folderId: null,
|
folderId: null,
|
||||||
|
|||||||
@@ -332,6 +332,142 @@ describe("importer-curl", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Imports Bearer token from Authorization header", () => {
|
||||||
|
expect(convertCurl('curl -H "Authorization: Bearer token123" https://yaak.app')).toEqual({
|
||||||
|
resources: {
|
||||||
|
workspaces: [baseWorkspace()],
|
||||||
|
httpRequests: [
|
||||||
|
baseRequest({
|
||||||
|
url: "https://yaak.app",
|
||||||
|
authenticationType: "bearer",
|
||||||
|
authentication: {
|
||||||
|
token: "token123",
|
||||||
|
prefix: "Bearer",
|
||||||
|
},
|
||||||
|
headers: [],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Trims whitespace before Bearer token from Authorization header", () => {
|
||||||
|
expect(convertCurl('curl -H "Authorization: Bearer token123" https://yaak.app')).toEqual({
|
||||||
|
resources: {
|
||||||
|
workspaces: [baseWorkspace()],
|
||||||
|
httpRequests: [
|
||||||
|
baseRequest({
|
||||||
|
url: "https://yaak.app",
|
||||||
|
authenticationType: "bearer",
|
||||||
|
authentication: {
|
||||||
|
token: "token123",
|
||||||
|
prefix: "Bearer",
|
||||||
|
},
|
||||||
|
headers: [],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Imports Basic auth from Authorization header (base64 decoded)", () => {
|
||||||
|
expect(
|
||||||
|
convertCurl('curl -H "Authorization: Basic dXNlcjpwYXNzd29yZA==" https://yaak.app'),
|
||||||
|
).toEqual({
|
||||||
|
resources: {
|
||||||
|
workspaces: [baseWorkspace()],
|
||||||
|
httpRequests: [
|
||||||
|
baseRequest({
|
||||||
|
url: "https://yaak.app",
|
||||||
|
authenticationType: "basic",
|
||||||
|
authentication: {
|
||||||
|
username: "user",
|
||||||
|
password: "password",
|
||||||
|
},
|
||||||
|
headers: [],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Authorization header takes precedence over -u flag", () => {
|
||||||
|
expect(
|
||||||
|
convertCurl('curl -u admin:secret -H "Authorization: Bearer token123" https://yaak.app'),
|
||||||
|
).toEqual({
|
||||||
|
resources: {
|
||||||
|
workspaces: [baseWorkspace()],
|
||||||
|
httpRequests: [
|
||||||
|
baseRequest({
|
||||||
|
url: "https://yaak.app",
|
||||||
|
authenticationType: "bearer",
|
||||||
|
authentication: {
|
||||||
|
token: "token123",
|
||||||
|
prefix: "Bearer",
|
||||||
|
},
|
||||||
|
headers: [],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Authorization header extraction is case-insensitive", () => {
|
||||||
|
expect(convertCurl('curl -H "authorization: bearer lowercaseToken" https://yaak.app')).toEqual({
|
||||||
|
resources: {
|
||||||
|
workspaces: [baseWorkspace()],
|
||||||
|
httpRequests: [
|
||||||
|
baseRequest({
|
||||||
|
url: "https://yaak.app",
|
||||||
|
authenticationType: "bearer",
|
||||||
|
authentication: {
|
||||||
|
token: "lowercaseToken",
|
||||||
|
prefix: "Bearer",
|
||||||
|
},
|
||||||
|
headers: [],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Preserves other headers when extracting Authorization", () => {
|
||||||
|
expect(
|
||||||
|
convertCurl('curl -H "Authorization: Bearer token123" -H "X-Custom: value" https://yaak.app'),
|
||||||
|
).toEqual({
|
||||||
|
resources: {
|
||||||
|
workspaces: [baseWorkspace()],
|
||||||
|
httpRequests: [
|
||||||
|
baseRequest({
|
||||||
|
url: "https://yaak.app",
|
||||||
|
authenticationType: "bearer",
|
||||||
|
authentication: {
|
||||||
|
token: "token123",
|
||||||
|
prefix: "Bearer",
|
||||||
|
},
|
||||||
|
headers: [{ name: "X-Custom", value: "value", enabled: true }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Invalid base64 in Basic auth keeps header in headers", () => {
|
||||||
|
expect(
|
||||||
|
convertCurl('curl -H "Authorization: Basic not-valid-base64!!!" https://yaak.app'),
|
||||||
|
).toEqual({
|
||||||
|
resources: {
|
||||||
|
workspaces: [baseWorkspace()],
|
||||||
|
httpRequests: [
|
||||||
|
baseRequest({
|
||||||
|
url: "https://yaak.app",
|
||||||
|
headers: [{ name: "Authorization", value: "Basic not-valid-base64!!!", enabled: true }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("Imports cookie as header", () => {
|
test("Imports cookie as header", () => {
|
||||||
expect(convertCurl('curl --cookie "foo=bar" https://yaak.app')).toEqual({
|
expect(convertCurl('curl --cookie "foo=bar" https://yaak.app')).toEqual({
|
||||||
resources: {
|
resources: {
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ const config = JSON.stringify({
|
|||||||
const normalizedAdditionalArgs = [];
|
const normalizedAdditionalArgs = [];
|
||||||
for (let i = 0; i < additionalArgs.length; i++) {
|
for (let i = 0; i < additionalArgs.length; i++) {
|
||||||
const arg = additionalArgs[i];
|
const arg = additionalArgs[i];
|
||||||
|
if (arg === "--") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (arg === "--config" && i + 1 < additionalArgs.length) {
|
if (arg === "--config" && i + 1 < additionalArgs.length) {
|
||||||
const value = additionalArgs[i + 1];
|
const value = additionalArgs[i + 1];
|
||||||
const isInlineJson = value.trimStart().startsWith("{");
|
const isInlineJson = value.trimStart().startsWith("{");
|
||||||
|
|||||||
Reference in New Issue
Block a user