mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-13 12:13:42 +01:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11e71b4b96 |
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -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("${[");
|
||||
}
|
||||
@@ -57,6 +57,7 @@
|
||||
--color-warning: var(--warning);
|
||||
|
||||
--animate-blinkRing: blinkRing 150ms step-start 400ms infinite;
|
||||
--animate-settingHighlight: settingHighlight 2.4s ease-out 1 both;
|
||||
|
||||
--opacity-disabled: 30%;
|
||||
|
||||
@@ -90,6 +91,18 @@
|
||||
|
||||
--transition-property-grid: grid;
|
||||
|
||||
@keyframes settingHighlight {
|
||||
0%,
|
||||
50% {
|
||||
background-color: color-mix(in srgb, var(--primary) 9%, transparent);
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 35%, transparent);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
box-shadow: inset 0 0 0 1px transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes blinkRing {
|
||||
0%,
|
||||
49% {
|
||||
|
||||
@@ -175,8 +175,11 @@ function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
||||
textSubtle: color.desaturate(0.58).lift(0.04).translucify(0.04).css(),
|
||||
textSubtlest: color.desaturate(0.65).translucify(0.18).css(),
|
||||
surface: color.translucify(0.95).css(),
|
||||
surfaceHighlight: color.translucify(0.85).css(),
|
||||
surfaceHighlight: color.translucify(0.9).css(),
|
||||
border: color.lift(0.3).translucify(0.8).css(),
|
||||
// Left unset, this is derived from the surface at full opacity, which outlines inline code
|
||||
// in a saturated color that belongs to nothing else in the banner
|
||||
borderSubtle: color.lift(0.3).translucify(0.86).css(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user