mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-11-15 13:12:22 +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 { WorkspaceSettingsDialog } from "../components/WorkspaceSettingsDialog";
|
||||||
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
import { activeWorkspaceIdAtom } from "../hooks/useActiveWorkspace";
|
||||||
import { jotaiStore } from "../lib/jotai";
|
import { jotaiStore } from "../lib/jotai";
|
||||||
|
|
||||||
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
|
export function openWorkspaceSettings(
|
||||||
|
tab?: WorkspaceSettingsTab,
|
||||||
|
highlight?: WorkspaceSettingsHighlight,
|
||||||
|
) {
|
||||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||||
if (workspaceId == null) return;
|
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 { PairEditor } from "./core/PairEditor";
|
||||||
import { PlainInput } from "./core/PlainInput";
|
import { PlainInput } from "./core/PlainInput";
|
||||||
import { Select } from "./core/Select";
|
import { Select } from "./core/Select";
|
||||||
|
import { SecretVariableHint } from "./hints/SecretVariableHint";
|
||||||
import { Markdown } from "./Markdown";
|
import { Markdown } from "./Markdown";
|
||||||
import { SelectFile } from "./SelectFile";
|
import { SelectFile } from "./SelectFile";
|
||||||
|
|
||||||
@@ -46,6 +47,8 @@ interface Props<T> {
|
|||||||
stateKey: string;
|
stateKey: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Offer to move literal secrets typed into password fields into a variable */
|
||||||
|
suggestVariables?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
||||||
@@ -57,6 +60,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
|||||||
stateKey,
|
stateKey,
|
||||||
className,
|
className,
|
||||||
disabled,
|
disabled,
|
||||||
|
suggestVariables,
|
||||||
}: Props<T>) {
|
}: Props<T>) {
|
||||||
const setDataAttr = useCallback(
|
const setDataAttr = useCallback(
|
||||||
(name: string, value: JsonPrimitive) => {
|
(name: string, value: JsonPrimitive) => {
|
||||||
@@ -73,6 +77,7 @@ export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
|||||||
stateKey={stateKey}
|
stateKey={stateKey}
|
||||||
autocompleteFunctions={autocompleteFunctions}
|
autocompleteFunctions={autocompleteFunctions}
|
||||||
autocompleteVariables={autocompleteVariables}
|
autocompleteVariables={autocompleteVariables}
|
||||||
|
suggestVariables={suggestVariables}
|
||||||
data={data}
|
data={data}
|
||||||
className={classNames(className, "pb-4")} // Pad the bottom to look nice
|
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;
|
setDataAttr: (name: string, value: JsonPrimitive) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
suggestVariables?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function FormInputs<T extends Record<string, JsonPrimitive>>({
|
function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||||
@@ -113,6 +119,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
|||||||
setDataAttr,
|
setDataAttr,
|
||||||
data,
|
data,
|
||||||
disabled,
|
disabled,
|
||||||
|
suggestVariables,
|
||||||
}: FormInputsProps<T>) {
|
}: FormInputsProps<T>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -147,6 +154,7 @@ function FormInputs<T extends Record<string, JsonPrimitive>>({
|
|||||||
arg={input}
|
arg={input}
|
||||||
autocompleteFunctions={autocompleteFunctions || false}
|
autocompleteFunctions={autocompleteFunctions || false}
|
||||||
autocompleteVariables={autocompleteVariables || false}
|
autocompleteVariables={autocompleteVariables || false}
|
||||||
|
suggestVariables={suggestVariables || false}
|
||||||
onChange={(v) => setDataAttr(input.name, v)}
|
onChange={(v) => setDataAttr(input.name, v)}
|
||||||
value={
|
value={
|
||||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
|
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? "")
|
||||||
@@ -287,6 +295,7 @@ function TextArg({
|
|||||||
value,
|
value,
|
||||||
autocompleteFunctions,
|
autocompleteFunctions,
|
||||||
autocompleteVariables,
|
autocompleteVariables,
|
||||||
|
suggestVariables,
|
||||||
stateKey,
|
stateKey,
|
||||||
}: {
|
}: {
|
||||||
arg: FormInputText;
|
arg: FormInputText;
|
||||||
@@ -294,6 +303,7 @@ function TextArg({
|
|||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
autocompleteFunctions: boolean;
|
autocompleteFunctions: boolean;
|
||||||
autocompleteVariables: boolean;
|
autocompleteVariables: boolean;
|
||||||
|
suggestVariables: boolean;
|
||||||
stateKey: string;
|
stateKey: string;
|
||||||
}) {
|
}) {
|
||||||
const props: InputProps = {
|
const props: InputProps = {
|
||||||
@@ -316,10 +326,19 @@ function TextArg({
|
|||||||
autocompleteFunctions,
|
autocompleteFunctions,
|
||||||
autocompleteVariables,
|
autocompleteVariables,
|
||||||
};
|
};
|
||||||
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
|
const input =
|
||||||
return <Input {...props} />;
|
autocompleteVariables || autocompleteFunctions || arg.completionOptions ? (
|
||||||
}
|
<Input {...props} />
|
||||||
return <PlainInput {...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({
|
function EditorArg({
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
withEncryptionEnabled,
|
withEncryptionEnabled,
|
||||||
} from "../lib/setupOrConfigureEncryption";
|
} from "../lib/setupOrConfigureEncryption";
|
||||||
import { DismissibleBanner } from "./core/DismissibleBanner";
|
import { DismissibleBanner } from "./core/DismissibleBanner";
|
||||||
|
import { FeatureHint } from "./core/FeatureHint";
|
||||||
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
import type { GenericCompletionConfig } from "./core/Editor/genericCompletion";
|
||||||
import type { PairEditorHandle, PairWithId } from "./core/PairEditor";
|
import type { PairEditorHandle, PairWithId } from "./core/PairEditor";
|
||||||
import { ensurePairId } from "./core/PairEditor.util";
|
import { ensurePairId } from "./core/PairEditor.util";
|
||||||
@@ -152,6 +153,17 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
|||||||
This sharable environment contains plain-text secrets
|
This sharable environment contains plain-text secrets
|
||||||
</DismissibleBanner>
|
</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>
|
</div>
|
||||||
<PairOrBulkEditor
|
<PairOrBulkEditor
|
||||||
setRef={setRef}
|
setRef={setRef}
|
||||||
@@ -173,3 +185,9 @@ export function EnvironmentEditor({ environment, hideName, className, setRef }:
|
|||||||
</div>
|
</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}
|
disabled={model.authentication.disabled === true}
|
||||||
autocompleteVariables
|
autocompleteVariables
|
||||||
autocompleteFunctions
|
autocompleteFunctions
|
||||||
|
suggestVariables
|
||||||
stateKey={`auth.${model.id}.${model.authenticationType}`}
|
stateKey={`auth.${model.id}.${model.authenticationType}`}
|
||||||
inputs={authConfig.data?.args ?? []}
|
inputs={authConfig.data?.args ?? []}
|
||||||
data={model.authentication}
|
data={model.authentication}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import { JsonBodyEditor } from "./JsonBodyEditor";
|
|||||||
import { MarkdownEditor } from "./MarkdownEditor";
|
import { MarkdownEditor } from "./MarkdownEditor";
|
||||||
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
import { RequestMethodDropdown } from "./RequestMethodDropdown";
|
||||||
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
import { countOverriddenSettings, ModelSettingsEditor } from "./ModelSettingsEditor";
|
||||||
|
import { SharedOriginHint } from "./hints/SharedOriginHint";
|
||||||
import { UrlBar } from "./UrlBar";
|
import { UrlBar } from "./UrlBar";
|
||||||
import { UrlParametersEditor } from "./UrlParameterEditor";
|
import { UrlParametersEditor } from "./UrlParameterEditor";
|
||||||
|
|
||||||
@@ -344,24 +345,27 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }:
|
|||||||
>
|
>
|
||||||
{activeRequest && (
|
{activeRequest && (
|
||||||
<>
|
<>
|
||||||
<UrlBar
|
<div>
|
||||||
stateKey={`url.${activeRequest.id}`}
|
<UrlBar
|
||||||
key={forceUpdateKey + urlKey}
|
stateKey={`url.${activeRequest.id}`}
|
||||||
url={activeRequest.url}
|
key={forceUpdateKey + urlKey}
|
||||||
placeholder="https://example.com"
|
url={activeRequest.url}
|
||||||
onPasteOverwrite={handlePaste}
|
placeholder="https://example.com"
|
||||||
autocomplete={autocomplete}
|
onPasteOverwrite={handlePaste}
|
||||||
onSend={handleSend}
|
autocomplete={autocomplete}
|
||||||
onCancel={cancelResponse}
|
onSend={handleSend}
|
||||||
onUrlChange={handleUrlChange}
|
onCancel={cancelResponse}
|
||||||
leftSlot={
|
onUrlChange={handleUrlChange}
|
||||||
<div className="py-0.5">
|
leftSlot={
|
||||||
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
|
<div className="py-0.5">
|
||||||
</div>
|
<RequestMethodDropdown request={activeRequest} className="ml-0.5 h-full!" />
|
||||||
}
|
</div>
|
||||||
forceUpdateKey={updateKey}
|
}
|
||||||
isLoading={activeResponse != null && activeResponse.state !== "closed"}
|
forceUpdateKey={updateKey}
|
||||||
/>
|
isLoading={activeResponse != null && activeResponse.state !== "closed"}
|
||||||
|
/>
|
||||||
|
<SharedOriginHint />
|
||||||
|
</div>
|
||||||
<Tabs
|
<Tabs
|
||||||
ref={tabsRef}
|
ref={tabsRef}
|
||||||
label="Request"
|
label="Request"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { TabContent, Tabs } from "./core/Tabs/Tabs";
|
|||||||
import { Tooltip } from "./core/Tooltip";
|
import { Tooltip } from "./core/Tooltip";
|
||||||
import { EmptyStateText } from "./EmptyStateText";
|
import { EmptyStateText } from "./EmptyStateText";
|
||||||
import { ErrorBoundary } from "./ErrorBoundary";
|
import { ErrorBoundary } from "./ErrorBoundary";
|
||||||
|
import { ChainingHint } from "./hints/ChainingHint";
|
||||||
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
import { HttpResponseTimeline } from "./HttpResponseTimeline";
|
||||||
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
|
||||||
import { RequestBodyViewer } from "./RequestBodyViewer";
|
import { RequestBodyViewer } from "./RequestBodyViewer";
|
||||||
@@ -280,6 +281,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
|||||||
{activeResponse.error}
|
{activeResponse.error}
|
||||||
</Banner>
|
</Banner>
|
||||||
)}
|
)}
|
||||||
|
{activeResponse && <ChainingHint response={activeResponse} mimeType={mimeType} />}
|
||||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
||||||
<Tabs
|
<Tabs
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function SyncToFilesystemSetting({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<SettingRowDirectory
|
<SettingRowDirectory
|
||||||
|
highlightKey="sync_dir"
|
||||||
title="Local directory sync"
|
title="Local directory sync"
|
||||||
description="Sync data to a folder for backup and Git integration."
|
description="Sync data to a folder for backup and Git integration."
|
||||||
filePath={value.filePath}
|
filePath={value.filePath}
|
||||||
|
|||||||
@@ -25,8 +25,12 @@ interface Props {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
hide: () => void;
|
hide: () => void;
|
||||||
tab?: WorkspaceSettingsTab;
|
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_AUTH = "auth";
|
||||||
const TAB_DNS = "dns";
|
const TAB_DNS = "dns";
|
||||||
const TAB_HEADERS = "headers";
|
const TAB_HEADERS = "headers";
|
||||||
@@ -42,7 +46,7 @@ export type WorkspaceSettingsTab =
|
|||||||
|
|
||||||
const DEFAULT_TAB: WorkspaceSettingsTab = TAB_GENERAL;
|
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 workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);
|
||||||
const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
|
const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
|
||||||
const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
|
const authTab = useAuthTab(TAB_AUTH, workspace ?? null);
|
||||||
@@ -104,7 +108,7 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, tab }: Props) {
|
|||||||
/>
|
/>
|
||||||
</TabContent>
|
</TabContent>
|
||||||
<TabContent value={TAB_SETTINGS} className="overflow-y-auto h-full px-4">
|
<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}>
|
<SettingsSection title={null}>
|
||||||
<SyncToFilesystemSetting
|
<SyncToFilesystemSetting
|
||||||
layout="settings"
|
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({
|
showDialog({
|
||||||
id: "workspace-settings",
|
id: "workspace-settings",
|
||||||
size: "lg",
|
size: "lg",
|
||||||
className: "h-[calc(100vh-5rem)] max-h-200!",
|
className: "h-[calc(100vh-5rem)] max-h-200!",
|
||||||
noPadding: true,
|
noPadding: true,
|
||||||
render: ({ hide }) => (
|
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 type { BannerProps } from "@yaakapp-internal/ui";
|
||||||
import { Banner } from "@yaakapp-internal/ui";
|
import { Banner } from "@yaakapp-internal/ui";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { MouseEvent } from "react";
|
import type { MouseEvent, ReactNode } from "react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||||
import type { ButtonProps } from "./Button";
|
import type { ButtonProps } from "./Button";
|
||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
|
import { IconButton } from "./IconButton";
|
||||||
|
|
||||||
type DismissibleBannerSize = "sm" | "xs";
|
type DismissibleBannerSize = "sm" | "xs";
|
||||||
|
|
||||||
@@ -18,17 +19,21 @@ export function DismissibleBanner({
|
|||||||
onDismiss,
|
onDismiss,
|
||||||
onShow,
|
onShow,
|
||||||
actions,
|
actions,
|
||||||
|
variant = "banner",
|
||||||
...props
|
...props
|
||||||
}: BannerProps & {
|
}: BannerProps & {
|
||||||
id: string;
|
id: string;
|
||||||
size?: DismissibleBannerSize;
|
size?: DismissibleBannerSize;
|
||||||
onDismiss?: () => void | Promise<void>;
|
onDismiss?: () => void | Promise<void>;
|
||||||
onShow?: () => 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?: {
|
actions?: {
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
color?: Color;
|
color?: Color;
|
||||||
variant?: ButtonProps["variant"];
|
variant?: ButtonProps["variant"];
|
||||||
|
rightSlot?: ReactNode;
|
||||||
}[];
|
}[];
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
@@ -56,54 +61,69 @@ export function DismissibleBanner({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
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 (
|
return (
|
||||||
<Banner
|
<Banner
|
||||||
className={classNames(
|
className={classNames(className, "relative", size === "xs" && "!px-2 !py-2 text-xs")}
|
||||||
className,
|
|
||||||
"relative",
|
|
||||||
size === "xs" && "!px-2 !py-2 text-xs",
|
|
||||||
)}
|
|
||||||
{...props}
|
{...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
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"grid @[34rem]:grid-cols-[minmax(0,1fr)_auto] @[34rem]:items-center",
|
"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",
|
size === "xs" ? "gap-1.5 @[34rem]:gap-2" : "gap-2 @[34rem]:gap-3",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{children}
|
{isTip ? <div className="pr-5 @[34rem]:pr-0">{children}</div> : children}
|
||||||
<div className="flex flex-wrap gap-1.5 @[34rem]:justify-end">
|
{hasActionRow && (
|
||||||
<Button
|
<div className="flex flex-col gap-1.5 @[16rem]:flex-row @[16rem]:flex-wrap @[16rem]:justify-end">
|
||||||
variant="border"
|
{!isTip && (
|
||||||
color={props.color}
|
<Button
|
||||||
size={actionSize}
|
variant="border"
|
||||||
onClick={(event) => {
|
color={props.color}
|
||||||
stopParentClick(event);
|
size={actionSize}
|
||||||
setDismissed(true).catch(console.error);
|
onClick={dismiss}
|
||||||
Promise.resolve(onDismiss?.()).catch(console.error);
|
title="Dismiss message"
|
||||||
}}
|
>
|
||||||
title="Dismiss message"
|
Dismiss
|
||||||
>
|
</Button>
|
||||||
Dismiss
|
)}
|
||||||
</Button>
|
{actions?.map((a) => (
|
||||||
{actions?.map((a) => (
|
<Button
|
||||||
<Button
|
key={a.label}
|
||||||
key={a.label}
|
rightSlot={a.rightSlot}
|
||||||
variant={a.variant ?? "border"}
|
variant={a.variant ?? "border"}
|
||||||
color={a.color ?? props.color}
|
color={a.color ?? props.color}
|
||||||
size={actionSize}
|
size={actionSize}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
stopParentClick(event);
|
stopParentClick(event);
|
||||||
a.onClick();
|
a.onClick();
|
||||||
}}
|
}}
|
||||||
title={a.label}
|
title={a.label}
|
||||||
>
|
>
|
||||||
{a.label}
|
{a.label}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Banner>
|
</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 { patchModel } from "@yaakapp-internal/models";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
import { CopyIconButton } from "../CopyIconButton";
|
import { CopyIconButton } from "../CopyIconButton";
|
||||||
import { Checkbox } from "./Checkbox";
|
import { Checkbox } from "./Checkbox";
|
||||||
import { IconButton, type IconButtonProps } from "./IconButton";
|
import { IconButton, type IconButtonProps } from "./IconButton";
|
||||||
@@ -19,11 +20,31 @@ type SettingRowBaseProps = {
|
|||||||
controlClassName?: string;
|
controlClassName?: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Matched against the surrounding `SettingsList` highlight to point this row out when it opens */
|
||||||
|
highlightKey?: string;
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SettingsList({ children, className }: { children: ReactNode; className?: string }) {
|
const SettingsHighlightContext = createContext<string | null>(null);
|
||||||
return <div className={classNames("w-full", className)}>{children}</div>;
|
|
||||||
|
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({
|
export function SettingsSection({
|
||||||
@@ -58,17 +79,37 @@ export function SettingRow({
|
|||||||
controlClassName,
|
controlClassName,
|
||||||
description,
|
description,
|
||||||
disabled,
|
disabled,
|
||||||
|
highlightKey,
|
||||||
title,
|
title,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
} & SettingRowBaseProps) {
|
} & 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={rowRef}
|
||||||
aria-disabled={disabled || undefined}
|
aria-disabled={disabled || undefined}
|
||||||
|
onAnimationEnd={(event) => {
|
||||||
|
if (event.target === event.currentTarget) setFlashing(false);
|
||||||
|
}}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
className,
|
className,
|
||||||
"@container border-b border-border-subtle py-4",
|
"@container border-b border-border-subtle py-4",
|
||||||
disabled && "opacity-disabled",
|
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
|
<div
|
||||||
@@ -84,6 +125,7 @@ export function SettingRow({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
ref={controlRef}
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
|
"flex min-w-0 items-center justify-start @[40rem]:justify-end",
|
||||||
controlClassName,
|
controlClassName,
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
|
import { useGitBranchInfo, useGitMutations } from "@yaakapp-internal/git";
|
||||||
import type { WorkspaceMeta } from "@yaakapp-internal/models";
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useAtomValue } from "jotai";
|
import { useAtomValue } from "jotai";
|
||||||
import type { HTMLAttributes } from "react";
|
import type { HTMLAttributes } from "react";
|
||||||
import { forwardRef, useCallback, useMemo } from "react";
|
import { forwardRef, useCallback, useMemo } from "react";
|
||||||
import { openWorkspaceSettings } from "../../commands/openWorkspaceSettings";
|
|
||||||
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from "../../hooks/useActiveWorkspace";
|
||||||
import { useKeyValue } from "../../hooks/useKeyValue";
|
import { useKeyValue } from "../../hooks/useKeyValue";
|
||||||
import { useRandomKey } from "../../hooks/useRandomKey";
|
import { useRandomKey } from "../../hooks/useRandomKey";
|
||||||
@@ -18,6 +16,7 @@ import { showErrorToast, showToast } from "../../lib/toast";
|
|||||||
import type { DropdownItem } from "../core/Dropdown";
|
import type { DropdownItem } from "../core/Dropdown";
|
||||||
import { Dropdown } from "../core/Dropdown";
|
import { Dropdown } from "../core/Dropdown";
|
||||||
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
|
import { Banner, Icon, InlineCode } from "@yaakapp-internal/ui";
|
||||||
|
import { SyncWorkspaceHint } from "../hints/SyncWorkspaceHint";
|
||||||
import { useGitCallbacks } from "./callbacks";
|
import { useGitCallbacks } from "./callbacks";
|
||||||
import { GitCommitDialog } from "./GitCommitDialog";
|
import { GitCommitDialog } from "./GitCommitDialog";
|
||||||
import { GitRemotesDialog } from "./GitRemotesDialog";
|
import { GitRemotesDialog } from "./GitRemotesDialog";
|
||||||
@@ -31,7 +30,7 @@ export function GitDropdown() {
|
|||||||
if (workspaceMeta == null) return null;
|
if (workspaceMeta == null) return null;
|
||||||
|
|
||||||
if (workspaceMeta.settingSyncDir == null) {
|
if (workspaceMeta.settingSyncDir == null) {
|
||||||
return <SetupSyncDropdown workspaceMeta={workspaceMeta} />;
|
return <SyncWorkspaceHint workspaceId={workspaceMeta.workspaceId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <SyncDropdownWithSyncDir syncDir={workspaceMeta.settingSyncDir} />;
|
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({
|
function SetupGitDropdown({
|
||||||
workspaceId,
|
workspaceId,
|
||||||
initRepo,
|
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);
|
--color-warning: var(--warning);
|
||||||
|
|
||||||
--animate-blinkRing: blinkRing 150ms step-start 400ms infinite;
|
--animate-blinkRing: blinkRing 150ms step-start 400ms infinite;
|
||||||
|
--animate-settingHighlight: settingHighlight 2.4s ease-out 1 both;
|
||||||
|
|
||||||
--opacity-disabled: 30%;
|
--opacity-disabled: 30%;
|
||||||
|
|
||||||
@@ -90,6 +91,18 @@
|
|||||||
|
|
||||||
--transition-property-grid: grid;
|
--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 {
|
@keyframes blinkRing {
|
||||||
0%,
|
0%,
|
||||||
49% {
|
49% {
|
||||||
|
|||||||
@@ -175,8 +175,11 @@ function bannerColorVariables(theme: Theme, color: YaakColor): CSSVariables {
|
|||||||
textSubtle: color.desaturate(0.58).lift(0.04).translucify(0.04).css(),
|
textSubtle: color.desaturate(0.58).lift(0.04).translucify(0.04).css(),
|
||||||
textSubtlest: color.desaturate(0.65).translucify(0.18).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(),
|
surfaceHighlight: color.translucify(0.9).css(),
|
||||||
border: color.lift(0.3).translucify(0.8).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