[WIP] Encryption for secure values (#183)

This commit is contained in:
Gregory Schier
2025-04-15 07:18:26 -07:00
committed by GitHub
parent e114a85c39
commit 2e55a1bd6d
208 changed files with 4063 additions and 28698 deletions

View File

@@ -10,10 +10,9 @@ import { switchWorkspace } from '../commands/switchWorkspace';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
import { useAllRequests } from '../hooks/useAllRequests';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDebouncedState } from '../hooks/useDebouncedState';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
@@ -26,6 +25,7 @@ import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useScrollIntoView } from '../hooks/useScrollIntoView';
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { createRequestAndNavigate } from '../lib/createRequestAndNavigate';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { showDialog, toggleDialog } from '../lib/dialog';
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
@@ -61,6 +61,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const activeEnvironment = useActiveEnvironment();
const httpRequestActions = useHttpRequestActions();
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
const workspaces = useAtomValue(workspacesAtom);
const { baseEnvironment, subEnvironments } = useEnvironmentsBreakdown();
const createWorkspace = useCreateWorkspace();
@@ -71,12 +72,12 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const activeCookieJar = useActiveCookieJar();
const [recentRequests] = useRecentRequests();
const [, setSidebarHidden] = useSidebarHidden();
const { mutate: createHttpRequest } = useCreateHttpRequest();
const { mutate: createGrpcRequest } = useCreateGrpcRequest();
const { mutate: createEnvironment } = useCreateEnvironment();
const { mutate: sendRequest } = useSendAnyHttpRequest();
const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {
if (workspaceId == null) return [];
const commands: CommandPaletteItem[] = [
{
key: 'settings.open',
@@ -92,7 +93,17 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
{
key: 'http_request.create',
label: 'Create HTTP Request',
onSelect: () => createHttpRequest({}),
onSelect: () => createRequestAndNavigate({ model: 'http_request', workspaceId }),
},
{
key: 'grpc_request.create',
label: 'Create GRPC Request',
onSelect: () => createRequestAndNavigate({ model: 'grpc_request', workspaceId }),
},
{
key: 'websocket_request.create',
label: 'Create Websocket Request',
onSelect: () => createRequestAndNavigate({ model: 'websocket_request', workspaceId }),
},
{
key: 'folder.create',
@@ -111,11 +122,6 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
});
},
},
{
key: 'grpc_request.create',
label: 'Create GRPC Request',
onSelect: () => createGrpcRequest({}),
},
{
key: 'environment.edit',
label: 'Edit Environment',
@@ -185,12 +191,11 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
activeRequest,
baseEnvironment,
createEnvironment,
createGrpcRequest,
createHttpRequest,
createWorkspace,
httpRequestActions,
sendRequest,
setSidebarHidden,
workspaceId,
]);
const sortedRequests = useMemo(() => {
@@ -259,7 +264,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const requestGroup: CommandPaletteGroup = {
key: 'requests',
label: 'Requests',
label: 'Switch Request',
items: [],
};
@@ -285,7 +290,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const environmentGroup: CommandPaletteGroup = {
key: 'environments',
label: 'Environments',
label: 'Switch Environment',
items: [],
};
@@ -302,7 +307,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const workspaceGroup: CommandPaletteGroup = {
key: 'workspaces',
label: 'Workspaces',
label: 'Switch Workspace',
items: [],
};

View File

@@ -0,0 +1,34 @@
import { useCopy } from '../hooks/useCopy';
import { useTimedBoolean } from '../hooks/useTimedBoolean';
import { showToast } from '../lib/toast';
import type { IconButtonProps } from './core/IconButton';
import { IconButton } from './core/IconButton';
interface Props extends Omit<IconButtonProps, 'onClick' | 'icon'> {
text: string | (() => Promise<string | null>);
}
export function CopyIconButton({ text, ...props }: Props) {
const copy = useCopy({ disableToast: true });
const [copied, setCopied] = useTimedBoolean();
return (
<IconButton
{...props}
icon={copied ? 'check' : 'copy'}
showConfirm
onClick={async () => {
const content = typeof text === 'function' ? await text() : text;
if (content == null) {
showToast({
id: 'failed-to-copy',
color: 'danger',
message: 'Failed to copy',
});
} else {
copy(content);
setCopied();
}
}}
/>
);
}

View File

@@ -1,13 +1,17 @@
import { useGitInit } from '@yaakapp-internal/git';
import type { WorkspaceMeta } from '@yaakapp-internal/models';
import { createGlobalModel, patchModel } from '@yaakapp-internal/models';
import { createGlobalModel, updateModel } from '@yaakapp-internal/models';
import { useState } from 'react';
import { router } from '../lib/router';
import { setupOrConfigureEncryption } from '../lib/setupOrConfigureEncryption';
import { invokeCmd } from '../lib/tauri';
import { showErrorToast } from '../lib/toast';
import { Button } from './core/Button';
import { Checkbox } from './core/Checkbox';
import { Label } from './core/Label';
import { PlainInput } from './core/PlainInput';
import { VStack } from './core/Stacks';
import { EncryptionHelp } from './EncryptionHelp';
import { SyncToFilesystemSetting } from './SyncToFilesystemSetting';
interface Props {
@@ -21,7 +25,7 @@ export function CreateWorkspaceDialog({ hide }: Props) {
filePath: string | null;
initGit?: boolean;
}>({ filePath: null, initGit: false });
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
return (
<VStack
as="form"
@@ -38,7 +42,8 @@ export function CreateWorkspaceDialog({ hide }: Props) {
const workspaceMeta = await invokeCmd<WorkspaceMeta>('cmd_get_workspace_meta', {
workspaceId,
});
await patchModel(workspaceMeta, {
await updateModel({
...workspaceMeta,
settingSyncDir: syncConfig.filePath,
});
@@ -55,6 +60,10 @@ export function CreateWorkspaceDialog({ hide }: Props) {
});
hide();
if (setupEncryption) {
setupOrConfigureEncryption();
}
}}
>
<PlainInput required label="Name" defaultValue={name} onChange={setName} />
@@ -64,7 +73,17 @@ export function CreateWorkspaceDialog({ hide }: Props) {
onCreateNewWorkspace={hide}
value={syncConfig}
/>
<Button type="submit" color="primary" className="ml-auto mt-3">
<div>
<Label htmlFor={null} help={<EncryptionHelp />}>
Workspace encryption
</Label>
<Checkbox
checked={setupEncryption}
onChange={setSetupEncryption}
title="Enable Encryption"
/>
</div>
<Button type="submit" color="primary" className="w-full mt-3">
Create Workspace
</Button>
</VStack>

View File

@@ -0,0 +1,14 @@
import {VStack} from "./core/Stacks";
export function EncryptionHelp() {
return <VStack space={3}>
<p>
Encrypt values like secrets and tokens. When enabled, Yaak will also encrypt HTTP responses,
cookies, and authentication credentials automatically.
</p>
<p>
Encrypted data remains secure when syncing to the filesystem or Git, and when exporting or
sharing with others.
</p>
</VStack>
}

View File

@@ -6,9 +6,17 @@ import type { ReactNode } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
import { useIsEncryptionEnabled } from '../hooks/useIsEncryptionEnabled';
import { useKeyValue } from '../hooks/useKeyValue';
import { useRandomKey } from '../hooks/useRandomKey';
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
import { analyzeTemplate, convertTemplateToSecure } from '../lib/encryption';
import { showPrompt } from '../lib/prompt';
import {
setupOrConfigureEncryption,
withEncryptionEnabled,
} from '../lib/setupOrConfigureEncryption';
import { BadgeButton } from './core/BadgeButton';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { ContextMenu } from './core/Dropdown';
@@ -17,7 +25,8 @@ import { Heading } from './core/Heading';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import type { PairEditorProps } from './core/PairEditor';
import type { PairWithId } from './core/PairEditor';
import { ensurePairId } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
import { Separator } from './core/Separator';
import { SplitLayout } from './core/SplitLayout';
@@ -120,16 +129,19 @@ const EnvironmentEditor = function ({
environment: Environment;
className?: string;
}) {
const activeWorkspaceId = activeEnvironment.workspaceId;
const isEncryptionEnabled = useIsEncryptionEnabled();
const valueVisibility = useKeyValue<boolean>({
namespace: 'global',
key: 'environmentValueVisibility',
fallback: true,
key: ['environmentValueVisibility', activeWorkspaceId],
fallback: false,
});
const { allEnvironments } = useEnvironmentsBreakdown();
const handleChange = useCallback<PairEditorProps['onChange']>(
(variables) => patchModel(activeEnvironment, { variables }),
const handleChange = useCallback(
(variables: PairWithId[]) => patchModel(activeEnvironment, { variables }),
[activeEnvironment],
);
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
// Gather a list of env names from other environments, to help the user get them aligned
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
@@ -162,19 +174,50 @@ const EnvironmentEditor = function ({
return name.match(/^[a-z_][a-z0-9_-]*$/i) != null;
}, []);
const valueType = !isEncryptionEnabled && valueVisibility.value ? 'text' : 'password';
const promptToEncrypt = useMemo(() => {
if (!isEncryptionEnabled) {
return false;
} else {
return !activeEnvironment.variables.every(
(v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure',
);
}
}, [activeEnvironment.variables, isEncryptionEnabled]);
const encryptEnvironment = (environment: Environment) => {
withEncryptionEnabled(async () => {
const encryptedVariables: PairWithId[] = [];
for (const variable of environment.variables) {
const value = variable.value ? await convertTemplateToSecure(variable.value) : '';
encryptedVariables.push(ensurePairId({ ...variable, value }));
}
await handleChange(encryptedVariables);
regenerateForceUpdateKey();
});
};
return (
<VStack space={4} className={classNames(className, 'pl-4')}>
<HStack space={2} className="justify-between">
<Heading className="w-full flex items-center gap-1">
<div>{activeEnvironment?.name}</div>
<IconButton
size="sm"
icon={valueVisibility.value ? 'eye' : 'eye_closed'}
title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'}
onClick={() => {
return valueVisibility.set((v) => !v);
}}
/>
{promptToEncrypt ? (
<BadgeButton color="notice" onClick={() => encryptEnvironment(activeEnvironment)}>
Encrypt All Variables
</BadgeButton>
) : isEncryptionEnabled ? (
<BadgeButton color="secondary" onClick={setupOrConfigureEncryption}>
Encryption Settings
</BadgeButton>
) : (
<IconButton
size="sm"
icon={valueVisibility.value ? 'eye' : 'eye_closed'}
title={valueVisibility.value ? 'Hide Values' : 'Reveal Values'}
onClick={() => valueVisibility.set((v) => !v)}
/>
)}
</Heading>
</HStack>
<div className="h-full pr-2 pb-2">
@@ -184,10 +227,10 @@ const EnvironmentEditor = function ({
nameAutocomplete={nameAutocomplete}
namePlaceholder="VAR_NAME"
nameValidate={validateName}
valueType={valueVisibility.value ? 'text' : 'password'}
valueType={valueType}
valueAutocompleteVariables
valueAutocompleteFunctions
forceUpdateKey={activeEnvironment.id}
forceUpdateKey={`${activeEnvironment.id}::${forceUpdateKey}`}
pairs={activeEnvironment.variables}
onChange={handleChange}
stateKey={`environment.${activeEnvironment.id}`}

View File

@@ -344,7 +344,7 @@ function SetupSyncDropdown({ workspaceMeta }: { workspaceMeta: WorkspaceMeta })
label: 'Open Workspace Settings',
leftSlot: <Icon icon="settings" />,
onSelect() {
openWorkspaceSettings.mutate({ openSyncMenu: true });
openWorkspaceSettings.mutate();
},
},
{ type: 'separator' },

View File

@@ -58,7 +58,7 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
await grpc.reflect.refetch();
}}
>
Add File
Add Proto File(s)
</Button>
<Button
isLoading={grpc.reflect.isFetching}
@@ -109,15 +109,15 @@ function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: Grp
<thead>
<tr>
<th className="text-text-subtlest">
<span className="font-mono">*.proto</span> Files
Added Files
</th>
<th></th>
<th/>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{protoFiles.map((f, i) => (
<tr key={f + i} className="group">
<td className="pl-1 font-mono">{f.split('/').pop()}</td>
<td className="pl-1 font-mono text-sm" title={f}>{f.split('/').pop()}</td>
<td className="w-0 py-0.5">
<IconButton
title="Remove file"

View File

@@ -219,7 +219,7 @@ export function GrpcRequestPane({
type: 'default',
shortLabel: o.label,
}))}
extraItems={[
itemsAfter={[
{
label: 'Refresh',
type: 'default',

View File

@@ -26,6 +26,7 @@ import { SplitLayout } from './core/SplitLayout';
import { HStack, VStack } from './core/Stacks';
import { EmptyStateText } from './EmptyStateText';
import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown';
import { HotKeyList } from './core/HotKeyList';
interface Props {
style?: CSSProperties;
@@ -72,11 +73,15 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
defaultRatio={0.4}
minHeightPx={20}
firstSlot={() =>
activeConnection && (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
activeConnection == null ? (
<HotKeyList
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'url_bar.focus']}
/>
) : (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1 items-center">
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
<HStack space={2}>
<span>{events.length} Messages</span>
<span className="whitespace-nowrap">{events.length} Messages</span>
{activeConnection.state !== 'closed' && (
<LoadingIcon size="sm" className="text-text-subtlest" />
)}
@@ -114,84 +119,86 @@ export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
)
}
secondSlot={
activeEvent &&
(() => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="h-full pl-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)] ">
{activeEvent.eventType === 'client_message' ||
activeEvent.eventType === 'server_message' ? (
<>
<div className="mb-2 select-text cursor-text grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold">
Message {activeEvent.eventType === 'client_message' ? 'Sent' : 'Received'}
</div>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copy(activeEvent.content)}
/>
</div>
{!showLarge && activeEvent.content.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<JsonAttributeTree attrValue={JSON.parse(activeEvent?.content ?? '{}')} />
)}
</>
) : (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<div>
<div className="select-text cursor-text font-semibold">
{activeEvent.content}
</div>
{activeEvent.error && (
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
{activeEvent.error}
</div>
)}
</div>
<div className="py-2 h-full">
{Object.keys(activeEvent.metadata).length === 0 ? (
<EmptyStateText>
No {activeEvent.eventType === 'connection_end' ? 'trailers' : 'metadata'}
</EmptyStateText>
) : (
<KeyValueRows>
{Object.entries(activeEvent.metadata).map(([key, value]) => (
<KeyValueRow key={key} label={key}>
{value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</div>
activeEvent != null && activeConnection != null
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
)}
</div>
</div>
))
<div className="h-full pl-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)] ">
{activeEvent.eventType === 'client_message' ||
activeEvent.eventType === 'server_message' ? (
<>
<div className="mb-2 select-text cursor-text grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold">
Message {activeEvent.eventType === 'client_message' ? 'Sent' : 'Received'}
</div>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copy(activeEvent.content)}
/>
</div>
{!showLarge && activeEvent.content.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : (
<JsonAttributeTree attrValue={JSON.parse(activeEvent?.content ?? '{}')} />
)}
</>
) : (
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
<div>
<div className="select-text cursor-text font-semibold">
{activeEvent.content}
</div>
{activeEvent.error && (
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
{activeEvent.error}
</div>
)}
</div>
<div className="py-2 h-full">
{Object.keys(activeEvent.metadata).length === 0 ? (
<EmptyStateText>
No{' '}
{activeEvent.eventType === 'connection_end' ? 'trailers' : 'metadata'}
</EmptyStateText>
) : (
<KeyValueRows>
{Object.entries(activeEvent.metadata).map(([key, value]) => (
<KeyValueRow key={key} label={key}>
{value}
</KeyValueRow>
))}
</KeyValueRows>
)}
</div>
</div>
)}
</div>
</div>
)
: null
}
/>
);

View File

@@ -6,7 +6,8 @@ import { encodings } from '../lib/data/encodings';
import { headerNames } from '../lib/data/headerNames';
import { mimeTypes } from '../lib/data/mimetypes';
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
import type { PairEditorProps } from './core/PairEditor';
import type { InputProps } from './core/Input';
import type { Pair, PairEditorProps } from './core/PairEditor';
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
type Props = {
@@ -19,19 +20,20 @@ type Props = {
export function HeadersEditor({ stateKey, headers, onChange, forceUpdateKey }: Props) {
return (
<PairOrBulkEditor
preferenceName="headers"
stateKey={stateKey}
valueAutocompleteFunctions
valueAutocompleteVariables
forceUpdateKey={forceUpdateKey}
nameAutocomplete={nameAutocomplete}
nameAutocompleteFunctions
nameAutocompleteVariables
pairs={headers}
onChange={onChange}
forceUpdateKey={forceUpdateKey}
nameValidate={validateHttpHeader}
nameAutocomplete={nameAutocomplete}
valueAutocomplete={valueAutocomplete}
namePlaceholder="Header-Name"
nameValidate={validateHttpHeader}
onChange={onChange}
pairs={headers}
preferenceName="headers"
stateKey={stateKey}
valueType={valueType}
valueAutocomplete={valueAutocomplete}
valueAutocompleteFunctions
valueAutocompleteVariables
/>
);
}
@@ -46,6 +48,24 @@ const headerOptionsMap: Record<string, string[]> = {
'accept-charset': charsets,
};
const valueType = (pair: Pair): InputProps['type'] => {
const name = pair.name.toLowerCase().trim();
if (
name.includes('authorization') ||
name.includes('api-key') ||
name.includes('access-token') ||
name.includes('auth') ||
name.includes('secret') ||
name.includes('token') ||
name === 'cookie' ||
name === 'set-cookie'
) {
return 'password';
} else {
return 'text';
}
};
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
const name = headerName.toLowerCase().trim();
const options: GenericCompletionOption[] =

View File

@@ -94,7 +94,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
className,
'x-theme-responsePane',
'max-h-full h-full',
'bg-surface rounded-md border border-border-subtle',
'bg-surface rounded-md border border-border-subtle overflow-hidden',
'relative',
)}
>
@@ -117,7 +117,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
alignItems="center"
className={classNames(
'cursor-default select-none',
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm',
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
)}
>
{activeResponse.state !== 'closed' && <LoadingIcon size="sm" />}

View File

@@ -2,9 +2,10 @@ import type { LicenseCheckStatus } from '@yaakapp-internal/license';
import { useLicense } from '@yaakapp-internal/license';
import type { ReactNode } from 'react';
import { openSettings } from '../commands/openSettings';
import { appInfo } from '../hooks/useAppInfo';
import { useLicenseConfirmation } from '../hooks/useLicenseConfirmation';
import { BadgeButton } from './core/BadgeButton';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import { SettingsTab } from './Settings/SettingsTab';
const details: Record<
@@ -21,11 +22,15 @@ export function LicenseBadge() {
const { check } = useLicense();
const [licenseDetails, setLicenseDetails] = useLicenseConfirmation();
if (appInfo.isDev) {
return null;
}
if (check.error) {
return (
<LicenseBadgeButton color="danger" onClick={() => openSettings.mutate(SettingsTab.License)}>
<BadgeButton color="danger" onClick={() => openSettings.mutate(SettingsTab.License)}>
License Error
</LicenseBadgeButton>
</BadgeButton>
);
}
@@ -50,7 +55,7 @@ export function LicenseBadge() {
}
return (
<LicenseBadgeButton
<BadgeButton
color={detail.color}
onClick={async () => {
if (check.data.type === 'trialing') {
@@ -63,10 +68,6 @@ export function LicenseBadge() {
}}
>
{detail.label}
</LicenseBadgeButton>
</BadgeButton>
);
}
function LicenseBadgeButton({ ...props }: ButtonProps) {
return <Button size="2xs" variant="border" className="!rounded-full mx-1" {...props} />;
}

View File

@@ -10,6 +10,7 @@ type ViewMode = 'edit' | 'preview';
interface Props extends Pick<EditorProps, 'heightMode' | 'stateKey' | 'forceUpdateKey'> {
placeholder: string;
className?: string;
editorClassName?: string;
defaultValue: string;
onChange: (value: string) => void;
name: string;
@@ -17,6 +18,7 @@ interface Props extends Pick<EditorProps, 'heightMode' | 'stateKey' | 'forceUpda
export function MarkdownEditor({
className,
editorClassName,
defaultValue,
onChange,
name,
@@ -31,7 +33,7 @@ export function MarkdownEditor({
<Editor
hideGutter
wrapLines
className="max-w-2xl max-h-full"
className={classNames(editorClassName, '[&_.cm-line]:!max-w-lg max-h-full')}
language="markdown"
defaultValue={defaultValue}
onChange={onChange}
@@ -44,9 +46,9 @@ export function MarkdownEditor({
defaultValue.length === 0 ? (
<p className="text-text-subtlest">No description</p>
) : (
<Markdown className="max-w-xl overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto">
{defaultValue}
</Markdown>
<div className="overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto">
<Markdown className="max-w-lg">{defaultValue}</Markdown>
</div>
);
const contents = viewMode === 'preview' ? preview : editor;
@@ -56,20 +58,22 @@ export function MarkdownEditor({
ref={containerRef}
className={classNames(
'group/markdown',
'w-full h-full pt-1.5 rounded-md grid grid-cols-[minmax(0,1fr)_auto] grid-rows-1 gap-x-1.5',
'relative w-full h-full pt-1.5 rounded-md gap-x-1.5',
className,
)}
>
<div className="h-full w-full">{contents}</div>
<SegmentedControl
name={name}
onChange={setViewMode}
value={viewMode}
options={[
{ icon: 'eye', label: 'Preview mode', value: 'preview' },
{ icon: 'pencil', label: 'Edit mode', value: 'edit' },
]}
/>
<div className="absolute top-0 right-0 pt-1.5 pr-1.5">
<SegmentedControl
name={name}
onChange={setViewMode}
value={viewMode}
options={[
{ icon: 'eye', label: 'Preview mode', value: 'preview' },
{ icon: 'pencil', label: 'Edit mode', value: 'edit' },
]}
/>
</div>
</div>
);
}

View File

@@ -32,7 +32,7 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
onChange,
className,
}: Props) {
const extraItems = useMemo<DropdownItem[]>(
const itemsAfter = useMemo<DropdownItem[]>(
() => [
{
key: 'custom',
@@ -57,7 +57,7 @@ export const RequestMethodDropdown = memo(function RequestMethodDropdown({
);
return (
<RadioDropdown value={method} items={radioItems} extraItems={extraItems} onChange={onChange}>
<RadioDropdown value={method} items={radioItems} itemsAfter={itemsAfter} onChange={onChange}>
<Button size="xs" className={classNames(className, 'text-text-subtle hover:text')}>
{method.toUpperCase()}
</Button>

View File

@@ -1,4 +1,5 @@
import type { HttpResponse } from '@yaakapp-internal/models';
import { useMemo } from 'react';
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
interface Props {
@@ -6,10 +7,14 @@ interface Props {
}
export function ResponseHeaders({ response }: Props) {
const sortedHeaders = useMemo(
() => [...response.headers].sort((a, b) => a.name.localeCompare(b.name)),
[response.headers],
);
return (
<div className="overflow-auto h-full pb-4">
<KeyValueRows>
{response.headers.map((h, i) => (
{sortedHeaders.map((h, i) => (
<KeyValueRow labelColor="primary" key={i} label={h.name}>
{h.value}
</KeyValueRow>

View File

@@ -1,9 +1,12 @@
import { open } from '@tauri-apps/plugin-dialog';
import classNames from 'classnames';
import mime from 'mime';
import type { ReactNode } from 'react';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip';
import { Label } from './core/Label';
import { HStack } from './core/Stacks';
type Props = Omit<ButtonProps, 'type'> & {
@@ -12,6 +15,8 @@ type Props = Omit<ButtonProps, 'type'> & {
directory?: boolean;
inline?: boolean;
noun?: string;
help?: ReactNode;
label?: ReactNode;
};
// Special character to insert ltr text in rtl element
@@ -25,6 +30,8 @@ export function SelectFile({
directory,
noun,
size = 'sm',
label,
help,
...props
}: Props) {
const handleClick = async () => {
@@ -46,41 +53,55 @@ export function SelectFile({
const selectOrChange = (filePath ? 'Change ' : 'Select ') + itemLabel;
return (
<HStack className="group relative justify-stretch overflow-hidden">
<Button
className={classNames(className, 'font-mono text-xs rtl mr-1.5', inline && 'w-full')}
color="secondary"
onClick={handleClick}
size={size}
{...props}
>
{rtlEscapeChar}
{inline ? filePath || selectOrChange : selectOrChange}
</Button>
{!inline && (
<>
{filePath && (
<IconButton
size={size}
variant="border"
icon="x"
title={'Unset ' + itemLabel}
onClick={handleClear}
/>
)}
<div
className={classNames(
'font-mono truncate rtl pl-1.5 pr-3 text-text',
size === 'xs' && 'text-xs',
size === 'sm' && 'text-sm',
)}
>
{rtlEscapeChar}
{filePath ?? `No ${itemLabel.toLowerCase()} selected`}
</div>
</>
<div>
{label && (
<Label htmlFor={null} help={help}>
{label}
</Label>
)}
</HStack>
<HStack className="relative justify-stretch overflow-hidden">
<Button
className={classNames(
className,
'rtl mr-1.5',
inline && 'w-full',
filePath && inline && 'font-mono text-xs',
)}
color="secondary"
onClick={handleClick}
size={size}
{...props}
>
{rtlEscapeChar}
{inline ? filePath || selectOrChange : selectOrChange}
</Button>
{!inline && (
<>
{filePath && (
<IconButton
size={size}
variant="border"
icon="x"
title={'Unset ' + itemLabel}
onClick={handleClear}
/>
)}
<div
className={classNames(
'truncate rtl pl-1.5 pr-3 text-text',
filePath && 'font-mono',
size === 'xs' && filePath && 'text-xs',
size === 'sm' && filePath && 'text-sm',
)}
>
{rtlEscapeChar}
{filePath ?? `No ${itemLabel.toLowerCase()} selected`}
</div>
{filePath == null && help && !label && <IconTooltip content={help} />}
</>
)}
</HStack>
</div>
);
}

View File

@@ -37,7 +37,7 @@ export function SettingsGeneral() {
value={settings.updateChannel}
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
options={[
{ label: 'Stable (less frequent)', value: 'stable' },
{ label: 'Stable', value: 'stable' },
{ label: 'Beta (more frequent)', value: 'beta' },
]}
/>
@@ -52,7 +52,7 @@ export function SettingsGeneral() {
</div>
<Select
name="switchWorkspaceBehavior"
label="Switch Workspace Behavior"
label="Workspace Window Behavior"
labelPosition="left"
labelClassName="w-[14rem]"
size="sm"
@@ -69,9 +69,9 @@ export function SettingsGeneral() {
else await patchModel(settings, { openWorkspaceNewWindow: null });
}}
options={[
{ label: 'Always Ask', value: 'ask' },
{ label: 'Current Window', value: 'current' },
{ label: 'New Window', value: 'new' },
{ label: 'Always ask', value: 'ask' },
{ label: 'Open in current window', value: 'current' },
{ label: 'Open in new window', value: 'new' },
]}
/>
@@ -100,6 +100,7 @@ export function SettingsGeneral() {
<Checkbox
checked={workspace.settingValidateCertificates}
help="When disabled, skip validatation of server certificates, useful when interacting with self-signed certs."
title="Validate TLS Certificates"
onChange={(settingValidateCertificates) =>
patchModel(workspace, { settingValidateCertificates })

View File

@@ -11,71 +11,64 @@ export interface SyncToFilesystemSettingProps {
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
onCreateNewWorkspace: () => void;
value: { filePath: string | null; initGit?: boolean };
forceOpen?: boolean;
}
export function SyncToFilesystemSetting({
onChange,
onCreateNewWorkspace,
value,
forceOpen,
}: SyncToFilesystemSettingProps) {
const [isNonEmpty, setIsNonEmpty] = useState<string | null>(null);
return (
<details open={forceOpen || !!value.filePath} className="w-full">
<summary>Data directory</summary>
<VStack className="my-2" space={3}>
{isNonEmpty ? (
<Banner color="notice" className="flex flex-col gap-1.5">
<p>The selected directory must be empty. Did you want to open it instead?</p>
<div>
<Button
variant="border"
color="notice"
size="xs"
type="button"
onClick={() => {
openWorkspaceFromSyncDir.mutate(isNonEmpty);
onCreateNewWorkspace();
}}
>
Open Workspace
</Button>
</div>
</Banner>
) : !value.filePath ? (
<Banner color="info">
Sync data to a folder for backup and Git integration.
</Banner>
) : null}
<VStack className="w-full my-2" space={3}>
{isNonEmpty && (
<Banner color="notice" className="flex flex-col gap-1.5">
<p>Directory is not empty. Do you want to open it instead?</p>
<div>
<Button
variant="border"
color="notice"
size="xs"
type="button"
onClick={() => {
openWorkspaceFromSyncDir.mutate(isNonEmpty);
onCreateNewWorkspace();
}}
>
Open Workspace
</Button>
</div>
</Banner>
)}
<SelectFile
directory
size="xs"
noun="Directory"
filePath={value.filePath}
onChange={async ({ filePath }) => {
if (filePath != null) {
const files = await readDir(filePath);
if (files.length > 0) {
setIsNonEmpty(filePath);
return;
}
<SelectFile
directory
label="Local directory sync"
size="xs"
noun="Directory"
help="Sync data to a folder for backup and Git integration."
filePath={value.filePath}
onChange={async ({ filePath }) => {
if (filePath != null) {
const files = await readDir(filePath);
if (files.length > 0) {
setIsNonEmpty(filePath);
return;
}
}
setIsNonEmpty(null);
onChange({ ...value, filePath });
}}
setIsNonEmpty(null);
onChange({ ...value, filePath });
}}
/>
{value.filePath && typeof value.initGit === 'boolean' && (
<Checkbox
checked={value.initGit}
onChange={(initGit) => onChange({ ...value, initGit })}
title="Initialize Git Repo"
/>
{value.filePath && typeof value.initGit === 'boolean' && (
<Checkbox
checked={value.initGit}
onChange={(initGit) => onChange({ ...value, initGit })}
title="Initialize Git Repo"
/>
)}
</VStack>
</details>
)}
</VStack>
);
}

View File

@@ -1,17 +1,23 @@
import type { TemplateFunction } from '@yaakapp-internal/plugins';
import type { FnArg, Tokens } from '@yaakapp-internal/templates';
import classNames from 'classnames';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useDebouncedValue } from '../hooks/useDebouncedValue';
import { useRenderTemplate } from '../hooks/useRenderTemplate';
import { useTemplateTokensToString } from '../hooks/useTemplateTokensToString';
import {
templateTokensToString,
useTemplateTokensToString,
} from '../hooks/useTemplateTokensToString';
import { useToggle } from '../hooks/useToggle';
import { convertTemplateToInsecure } from '../lib/encryption';
import { setupOrConfigureEncryption } from '../lib/setupOrConfigureEncryption';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { InlineCode } from './core/InlineCode';
import { PlainInput } from './core/PlainInput';
import { HStack, VStack } from './core/Stacks';
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from './DynamicForm';
import { IconButton } from './core/IconButton';
import { Banner } from './core/Banner';
interface Props {
templateFunction: TemplateFunction;
@@ -20,30 +26,74 @@ interface Props {
onChange: (insert: string) => void;
}
export function TemplateFunctionDialog({ templateFunction, hide, initialTokens, onChange }: Props) {
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(() => {
const initial: Record<string, string> = {};
const initialArgs =
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
? initialTokens.tokens[0]?.val.args
: [];
for (const arg of templateFunction.args) {
if (!('name' in arg)) {
// Skip visual-only args
continue;
}
const initialArg = initialArgs.find((a) => a.name === arg.name);
const initialArgValue =
initialArg?.value.type === 'str'
? initialArg?.value.text
: // TODO: Implement variable-based args
undefined;
initial[arg.name] = initialArgValue ?? arg.defaultValue ?? DYNAMIC_FORM_NULL_ARG;
export function TemplateFunctionDialog({ initialTokens, templateFunction, ...props }: Props) {
const [initialArgValues, setInitialArgValues] = useState<Record<string, string | boolean> | null>(
null,
);
useEffect(() => {
if (initialArgValues != null) {
return;
}
return initial;
});
(async function () {
const initial: Record<string, string> = {};
const initialArgs =
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
? initialTokens.tokens[0]?.val.args
: [];
for (const arg of templateFunction.args) {
if (!('name' in arg)) {
// Skip visual-only args
continue;
}
const initialArg = initialArgs.find((a) => a.name === arg.name);
const initialArgValue =
initialArg?.value.type === 'str'
? initialArg?.value.text
: // TODO: Implement variable-based args
undefined;
initial[arg.name] = initialArgValue ?? arg.defaultValue ?? DYNAMIC_FORM_NULL_ARG;
}
// HACK: Replace the secure() function's encrypted `value` arg with the decrypted version so
// we can display it in the editor input.
if (templateFunction.name === 'secure') {
const template = await templateTokensToString(initialTokens);
initial.value = await convertTemplateToInsecure(template);
}
setInitialArgValues(initial);
})().catch(console.error);
}, [
initialArgValues,
initialTokens,
initialTokens.tokens,
templateFunction.args,
templateFunction.name,
]);
if (initialArgValues == null) return null;
return (
<InitializedTemplateFunctionDialog
{...props}
templateFunction={templateFunction}
initialArgValues={initialArgValues}
/>
);
}
function InitializedTemplateFunctionDialog({
templateFunction,
hide,
initialArgValues,
onChange,
}: Omit<Props, 'initialTokens'> & {
initialArgValues: Record<string, string | boolean>;
}) {
const enablePreview = templateFunction.name !== 'secure';
const [showSecretsInPreview, toggleShowSecretsInPreview] = useToggle(false);
const [argValues, setArgValues] = useState<Record<string, string | boolean>>(initialArgValues);
const tokens: Tokens = useMemo(() => {
const argTokens: FnArg[] = Object.keys(argValues).map((name) => ({
@@ -79,15 +129,14 @@ export function TemplateFunctionDialog({ templateFunction, hide, initialTokens,
hide();
};
const debouncedTagText = useDebouncedValue(tagText.data ?? '', 200);
const debouncedTagText = useDebouncedValue(tagText.data ?? '', 400);
const rendered = useRenderTemplate(debouncedTagText);
const tooLarge = rendered.data ? rendered.data.length > 10000 : false;
const dataContainsSecrets = useMemo(() => {
for (const [name, value] of Object.entries(argValues)) {
const isPassword = templateFunction.args.some(
(a) => a.type === 'text' && a.password && a.name === name,
);
if (isPassword && typeof value === 'string' && value && rendered.data?.includes(value)) {
const arg = templateFunction.args.find((a) => 'name' in a && a.name === name);
const isTextPassword = arg?.type === 'text' && arg.password;
if (isTextPassword && typeof value === 'string' && value && rendered.data?.includes(value)) {
return true;
}
}
@@ -97,53 +146,83 @@ export function TemplateFunctionDialog({ templateFunction, hide, initialTokens,
}, [rendered.data]);
return (
<VStack className="pb-3" space={4}>
<h1 className="font-mono !text-base">{templateFunction.name}()</h1>
<DynamicForm
autocompleteVariables
autocompleteFunctions
inputs={templateFunction.args}
data={argValues}
onChange={setArgValues}
stateKey={`template_function.${templateFunction.name}`}
/>
<VStack className="w-full" space={1}>
<HStack space={0.5}>
<div className="text-sm text-text-subtle">Rendered Preview</div>
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? 'lock' : 'lock_open'}
title={showSecretsInPreview ? 'Show preview' : 'Hide preview'}
onClick={toggleShowSecretsInPreview}
className={classNames(
'ml-auto text-text-subtlest',
!dataContainsSecrets && 'invisible',
)}
/>
</HStack>
{rendered.error || tagText.error ? (
<Banner color="danger">{`${rendered.error || tagText.error}`}</Banner>
) : (
<InlineCode
className={classNames(
'whitespace-pre select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars',
tooLarge && 'italic text-danger',
)}
>
{dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">------ sensitive values hidden ------</span>
) : tooLarge ? (
'too large to preview'
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
<VStack
as="form"
className="pb-3"
space={4}
onSubmit={(e) => {
e.preventDefault();
handleDone();
}}
>
{templateFunction.name === 'secure' ? (
<PlainInput
required
label="Value"
name="value"
type="password"
placeholder="••••••••••••"
defaultValue={String(argValues['value'] ?? '')}
onChange={(value) => setArgValues({ ...argValues, value })}
/>
) : (
<DynamicForm
autocompleteVariables
autocompleteFunctions
inputs={templateFunction.args}
data={argValues}
onChange={setArgValues}
stateKey={`template_function.${templateFunction.name}`}
/>
)}
{enablePreview && (
<VStack className="w-full" space={1}>
<HStack space={0.5}>
<div className="text-sm text-text-subtle">Rendered Preview</div>
<IconButton
size="xs"
iconSize="sm"
icon={showSecretsInPreview ? 'lock' : 'lock_open'}
title={showSecretsInPreview ? 'Show preview' : 'Hide preview'}
onClick={toggleShowSecretsInPreview}
className={classNames(
'ml-auto text-text-subtlest',
!dataContainsSecrets && 'invisible',
)}
/>
</HStack>
{rendered.error || tagText.error ? (
<Banner color="danger">{`${rendered.error || tagText.error}`}</Banner>
) : (
<InlineCode
className={classNames(
'whitespace-pre select-text cursor-text max-h-[10rem] overflow-y-auto hide-scrollbars',
tooLarge && 'italic text-danger',
)}
>
{dataContainsSecrets && !showSecretsInPreview ? (
<span className="italic text-text-subtle">
------ sensitive values hidden ------
</span>
) : tooLarge ? (
'too large to preview'
) : (
rendered.data || <>&nbsp;</>
)}
</InlineCode>
)}
</VStack>
)}
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
{templateFunction.name === 'secure' && (
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
Reveal Encryption Key
</Button>
)}
</VStack>
<Button color="primary" onClick={handleDone}>
Done
</Button>
<Button type="submit" color="primary">
Save
</Button>
</div>
</VStack>
);
}

View File

@@ -38,6 +38,7 @@ export function TemplateVariableDialog({ hide, onChange, initialTokens }: Props)
}, [selectedVariableName, variables]);
const tagText = useTemplateTokensToString(tokens);
const handleDone = useCallback(async () => {
if (tagText.data != null) {
onChange(tagText.data);

View File

@@ -18,6 +18,7 @@ import { AutoScroller } from './core/AutoScroller';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { Editor } from './core/Editor/Editor';
import { HotKeyList } from './core/HotKeyList';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { LoadingIcon } from './core/LoadingIcon';
@@ -71,7 +72,11 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
defaultRatio={0.4}
minHeightPx={20}
firstSlot={() =>
activeConnection && (
activeConnection == null ? (
<HotKeyList
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'url_bar.focus']}
/>
) : (
<div className="w-full grid grid-rows-[auto_minmax(0,1fr)] items-center">
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle">
<HStack space={2}>
@@ -115,77 +120,78 @@ export function WebsocketResponsePane({ activeRequest }: Props) {
)
}
secondSlot={
activeEvent &&
(() => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
<div className="mx-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)]">
<div className="h-xs mb-2 grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold">
{activeEvent.messageType === 'close'
? 'Connection Closed'
: activeEvent.messageType === 'open'
? 'Connection open'
: `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`}
activeEvent != null && activeConnection != null
? () => (
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
<div className="pb-3 px-2">
<Separator />
</div>
{message != '' && (
<HStack space={1}>
<Button
variant="border"
size="xs"
onClick={() => {
if (activeEventId == null) return;
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
}}
>
{hexDump ? 'Show Message' : 'Show Hexdump'}
</Button>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copy(formattedMessage.data ?? '')}
/>
</HStack>
)}
</div>
{!showLarge && activeEvent.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
<div className="mx-2 overflow-y-auto grid grid-rows-[auto_minmax(0,1fr)]">
<div className="h-xs mb-2 grid grid-cols-[minmax(0,1fr)_auto] items-center">
<div className="font-semibold">
{activeEvent.messageType === 'close'
? 'Connection Closed'
: activeEvent.messageType === 'open'
? 'Connection open'
: `Message ${activeEvent.isServer ? 'Received' : 'Sent'}`}
</div>
{message != '' && (
<HStack space={1}>
<Button
variant="border"
size="xs"
onClick={() => {
if (activeEventId == null) return;
setHexDumps({ ...hexDumps, [activeEventId]: !hexDump });
}}
>
{hexDump ? 'Show Message' : 'Show Hexdump'}
</Button>
<IconButton
title="Copy message"
icon="copy"
size="xs"
onClick={() => copy(formattedMessage.data ?? '')}
/>
</HStack>
)}
</div>
</VStack>
) : activeEvent.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : (
<Editor
language={language}
defaultValue={formattedMessage.data ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
</div>
))
{!showLarge && activeEvent.message.length > 1000 * 1000 ? (
<VStack space={2} className="italic text-text-subtlest">
Message previews larger than 1MB are hidden
<div>
<Button
onClick={() => {
setShowingLarge(true);
setTimeout(() => {
setShowLarge(true);
setShowingLarge(false);
}, 500);
}}
isLoading={showingLarge}
color="secondary"
variant="border"
size="xs"
>
Try Showing
</Button>
</div>
</VStack>
) : activeEvent.message.length === 0 ? (
<EmptyStateText>No Content</EmptyStateText>
) : (
<Editor
language={language}
defaultValue={formattedMessage.data ?? ''}
wrapLines={false}
readOnly={true}
stateKey={null}
/>
)}
</div>
</div>
)
: null
}
/>
);

View File

@@ -24,7 +24,7 @@ import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useSidebarWidth } from '../hooks/useSidebarWidth';
import { useSyncWorkspaceRequestTitle } from '../hooks/useSyncWorkspaceRequestTitle';
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
import { duplicateRequestAndNavigate } from '../lib/deleteRequestAndNavigate';
import { duplicateRequestAndNavigate } from '../lib/duplicateRequestAndNavigate';
import { jotaiStore } from '../lib/jotai';
import { Banner } from './core/Banner';
import { Button } from './core/Button';

View File

@@ -33,9 +33,9 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
const { mutate: deleteSendHistory } = useDeleteSendHistory();
const { workspaceItems, extraItems } = useMemo<{
const { workspaceItems, itemsAfter } = useMemo<{
workspaceItems: RadioDropdownItem[];
extraItems: DropdownItem[];
itemsAfter: DropdownItem[];
}>(() => {
const workspaceItems: RadioDropdownItem[] = workspaces.map((w) => ({
key: w.id,
@@ -44,12 +44,12 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: w.id === workspace?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
}));
const extraItems: DropdownItem[] = [
const itemsAfter: DropdownItem[] = [
{
label: 'Workspace Settings',
leftSlot: <Icon icon="settings" />,
hotKeyAction: 'workspace_settings.show',
onSelect: () => openWorkspaceSettings.mutate({}),
onSelect: () => openWorkspaceSettings.mutate(),
},
{
label: revealInFinderText,
@@ -88,7 +88,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
},
];
return { workspaceItems, extraItems };
return { workspaceItems, itemsAfter };
}, [workspaces, workspaceMeta, deleteSendHistory, createWorkspace, workspace?.id]);
const handleSwitchWorkspace = useCallback(async (workspaceId: string | null) => {
@@ -114,7 +114,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
return (
<RadioDropdown
items={workspaceItems}
extraItems={extraItems}
itemsAfter={itemsAfter}
onChange={handleSwitchWorkspace}
value={workspace?.id ?? null}
>

View File

@@ -0,0 +1,227 @@
import { enableEncryption, revealWorkspaceKey, setWorkspaceKey } from '@yaakapp-internal/crypto';
import type { WorkspaceMeta } from '@yaakapp-internal/models';
import classNames from 'classnames';
import { useAtomValue } from 'jotai/index';
import { useEffect, useState } from 'react';
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { useStateWithDeps } from '../hooks/useStateWithDeps';
import { CopyIconButton } from './CopyIconButton';
import { Banner } from './core/Banner';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { IconTooltip } from './core/IconTooltip';
import { Label } from './core/Label';
import { PlainInput } from './core/PlainInput';
import { HStack, VStack } from './core/Stacks';
import { EncryptionHelp } from './EncryptionHelp';
interface Props {
size?: ButtonProps['size'];
expanded?: boolean;
onDone?: () => void;
onEnabledEncryption?: () => void;
}
export function WorkspaceEncryptionSetting({ size, expanded, onDone, onEnabledEncryption }: Props) {
const [justEnabledEncryption, setJustEnabledEncryption] = useState<boolean>(false);
const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
if (workspace == null || workspaceMeta == null) {
return null;
}
if (workspace.encryptionKeyChallenge && workspaceMeta.encryptionKey == null) {
return (
<EnterWorkspaceKey
workspaceMeta={workspaceMeta}
onEnabled={() => {
onDone?.();
onEnabledEncryption?.();
}}
/>
);
}
if (workspaceMeta.encryptionKey) {
const keyRevealer = (
<KeyRevealer
disableLabel={justEnabledEncryption}
defaultShow={justEnabledEncryption}
workspaceId={workspaceMeta.workspaceId}
/>
);
return (
<VStack space={2} className="w-full">
{justEnabledEncryption && (
<Banner color="success" className="flex flex-col gap-2">
{helpAfterEncryption}
</Banner>
)}
{keyRevealer}
{onDone && (
<Button color="secondary" onClick={() => {
onDone();
onEnabledEncryption?.();
}}>
Done
</Button>
)}
</VStack>
);
}
return (
<div className="mb-auto flex flex-col-reverse">
<Button
color={expanded ? 'info' : 'secondary'}
size={size}
onClick={async () => {
setJustEnabledEncryption(true);
await enableEncryption(workspaceMeta.workspaceId);
}}
>
Enable Encryption
</Button>
{expanded ? (
<Banner color="info" className="mb-6">
<EncryptionHelp />
</Banner>
) : (
<Label htmlFor={null} help={<EncryptionHelp />}>
Workspace encryption
</Label>
)}
</div>
);
}
const setWorkspaceKeyMut = createFastMutation({
mutationKey: ['set-workspace-key'],
mutationFn: setWorkspaceKey,
});
function EnterWorkspaceKey({
workspaceMeta,
onEnabled,
}: {
workspaceMeta: WorkspaceMeta;
onEnabled?: () => void;
}) {
const [key, setKey] = useState<string>('');
return (
<VStack space={4}>
<Banner color="info">
This workspace contains encrypted values but no key is configured. Please enter the
workspace key to access the encrypted data.
</Banner>
<HStack
as="form"
alignItems="end"
className="w-full"
space={1.5}
onSubmit={(e) => {
e.preventDefault();
setWorkspaceKeyMut.mutate(
{
workspaceId: workspaceMeta.workspaceId,
key: key.trim(),
},
{ onSuccess: onEnabled },
);
}}
>
<PlainInput
required
onChange={setKey}
label="Workspace encryption key"
placeholder="YK0000-111111-222222-333333-444444-AAAAAA-BBBBBB-CCCCCC-DDDDDD"
/>
<Button variant="border" type="submit" color="secondary">
Submit
</Button>
</HStack>
</VStack>
);
}
function KeyRevealer({
workspaceId,
defaultShow = false,
disableLabel = false,
}: {
workspaceId: string;
defaultShow?: boolean;
disableLabel?: boolean;
}) {
const [key, setKey] = useState<string | null>(null);
const [show, setShow] = useStateWithDeps<boolean>(defaultShow, [defaultShow]);
useEffect(() => {
revealWorkspaceKey(workspaceId).then(setKey);
}, [setKey, workspaceId]);
if (key == null) return null;
return (
<div
className={classNames(
'w-full border border-border rounded-md pl-3 py-2 p-1',
'grid gap-1 grid-cols-[minmax(0,1fr)_auto] items-center',
)}
>
<VStack space={0.5}>
{!disableLabel && (
<span className="text-sm text-primary flex items-center gap-1">
workspace encryption key{' '}
<IconTooltip iconSize="sm" size="lg" content={helpAfterEncryption} />
</span>
)}
{key && <HighlightedKey keyText={key} show={show} />}
</VStack>
<HStack>
{key && <CopyIconButton text={key} title="Copy workspace key" />}
<IconButton
title={show ? 'Hide' : 'Reveal' + 'workspace key'}
icon={show ? 'eye_closed' : 'eye'}
onClick={() => setShow((v) => !v)}
/>
</HStack>
</div>
);
}
function HighlightedKey({ keyText, show }: { keyText: string; show: boolean }) {
return (
<span className="text-xs font-mono [&_*]:cursor-auto [&_*]:select-text">
{show ? (
keyText.split('').map((c, i) => {
return (
<span
key={i}
className={classNames(
c.match(/[0-9]/) && 'text-info',
c == '-' && 'text-text-subtle',
)}
>
{c}
</span>
);
})
) : (
<div className="text-text-subtle"></div>
)}
</span>
);
}
const helpAfterEncryption = (
<p>
this key is used for any encryption used for this workspace. It is stored securely using your OS
keychain, but it is recommended to back it up. If you share this workspace with others,
you&apos;ll need to send them this key to access any encrypted values.
</p>
);

View File

@@ -1,7 +1,11 @@
import classNames from 'classnames';
import { useAtomValue } from 'jotai/index';
import React, { memo } from 'react';
import { activeWorkspaceAtom, activeWorkspaceMetaAtom } from '../hooks/useActiveWorkspace';
import { useToggleCommandPalette } from '../hooks/useToggleCommandPalette';
import { setupOrConfigureEncryption } from '../lib/setupOrConfigureEncryption';
import { CookieDropdown } from './CookieDropdown';
import { BadgeButton } from './core/BadgeButton';
import { Icon } from './core/Icon';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
@@ -19,6 +23,10 @@ interface Props {
export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Props) {
const togglePalette = useToggleCommandPalette();
const workspace = useAtomValue(activeWorkspaceAtom);
const workspaceMeta = useAtomValue(activeWorkspaceMetaAtom);
const showEncryptionSetup =
workspace?.encryptionKeyChallenge != null && workspaceMeta?.encryptionKey == null;
return (
<div
@@ -41,7 +49,13 @@ export const WorkspaceHeader = memo(function WorkspaceHeader({ className }: Prop
</div>
<div className="flex-1 flex gap-1 items-center h-full justify-end pointer-events-none pr-1">
<ImportCurlButton />
<LicenseBadge />
{showEncryptionSetup ? (
<BadgeButton color="danger" onClick={setupOrConfigureEncryption}>
Enter Encryption Key
</BadgeButton>
) : (
<LicenseBadge />
)}
<IconButton
icon="search"
title="Search or execute a command"

View File

@@ -5,23 +5,21 @@ import { router } from '../lib/router';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { Input } from './core/Input';
import { PlainInput } from './core/PlainInput';
import { Separator } from './core/Separator';
import { VStack } from './core/Stacks';
import { MarkdownEditor } from './MarkdownEditor';
import { SyncToFilesystemSetting } from './SyncToFilesystemSetting';
import { WorkspaceEncryptionSetting } from './WorkspaceEncryptionSetting';
interface Props {
workspaceId: string | null;
hide: () => void;
openSyncMenu?: boolean;
}
export function WorkspaceSettingsDialog({ workspaceId, hide, openSyncMenu }: Props) {
export function WorkspaceSettingsDialog({ workspaceId, hide }: Props) {
const workspace = useAtomValue(workspacesAtom).find((w) => w.id === workspaceId);
const workspaceMeta = useAtomValue(workspaceMetasAtom).find(
(wm) => wm.workspaceId === workspaceId,
);
const workspaceMeta = useAtomValue(workspaceMetasAtom).find((m) => m.workspaceId === workspaceId);
if (workspace == null) {
return (
@@ -39,48 +37,50 @@ export function WorkspaceSettingsDialog({ workspaceId, hide, openSyncMenu }: Pro
);
return (
<VStack space={3} alignItems="start" className="pb-3 h-full">
<Input
<VStack space={4} alignItems="start" className="pb-3 h-full">
<PlainInput
required
hideLabel
placeholder="Workspace Name"
label="Name"
defaultValue={workspace.name}
className="!text-base font-sans"
onChange={(name) => patchModel(workspace, { name })}
stateKey={`name.${workspace.id}`}
/>
<MarkdownEditor
name="workspace-description"
placeholder="Workspace description"
className="min-h-[10rem] max-h-[25rem] border border-border px-2"
className="min-h-[3rem] max-h-[25rem] border border-border px-2"
defaultValue={workspace.description}
stateKey={`description.${workspace.id}`}
onChange={(description) => patchModel(workspace, { description })}
heightMode="auto"
/>
<VStack space={6} className="mt-3 w-full" alignItems="start">
<SyncToFilesystemSetting
value={{ filePath: workspaceMeta.settingSyncDir }}
forceOpen={openSyncMenu}
onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/>
<Separator />
<Button
onClick={async () => {
const didDelete = await deleteModelWithConfirm(workspace);
if (didDelete) {
hide(); // Only hide if actually deleted workspace
await router.navigate({ to: '/workspaces' });
}
}}
color="danger"
variant="border"
size="xs"
>
Delete Workspace
</Button>
</VStack>
<SyncToFilesystemSetting
value={{ filePath: workspaceMeta.settingSyncDir }}
onCreateNewWorkspace={hide}
onChange={({ filePath }) => patchModel(workspaceMeta, { settingSyncDir: filePath })}
/>
<WorkspaceEncryptionSetting size="xs" />
<Separator className="my-4" />
<Button
onClick={async () => {
const didDelete = await deleteModelWithConfirm(workspace);
if (didDelete) {
hide(); // Only hide if actually deleted workspace
await router.navigate({ to: '/' });
}
}}
color="danger"
variant="border"
size="xs"
>
Delete Workspace
</Button>
</VStack>
);
}

View File

@@ -0,0 +1,6 @@
import type { ButtonProps } from './Button';
import { Button } from './Button';
export function BadgeButton(props: ButtonProps) {
return <Button size="2xs" variant="border" className="!rounded-full mx-1" {...props} />;
}

View File

@@ -1,13 +1,13 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
interface Props {
export interface BannerProps {
children: ReactNode;
className?: string;
color?: 'primary' | 'secondary' | 'success' | 'notice' | 'warning' | 'danger' | 'info';
}
export function Banner({ children, className, color }: Props) {
export function Banner({ children, className, color }: BannerProps) {
return (
<div className="w-full mb-auto grid grid-rows-1 max-h-full">
<div

View File

@@ -1,6 +1,7 @@
import classNames from 'classnames';
import { type ReactNode } from 'react';
import { Icon } from './Icon';
import { IconTooltip } from './IconTooltip';
import { HStack } from './Stacks';
export interface CheckboxProps {
@@ -12,6 +13,7 @@ export interface CheckboxProps {
inputWrapperClassName?: string;
hideLabel?: boolean;
fullWidth?: boolean;
help?: ReactNode;
}
export function Checkbox({
@@ -23,9 +25,15 @@ export function Checkbox({
title,
hideLabel,
fullWidth,
help,
}: CheckboxProps) {
return (
<HStack as="label" space={2} className={classNames(className, 'text-text mr-auto')}>
<HStack
as="label"
alignItems="center"
space={2}
className={classNames(className, 'text-text mr-auto')}
>
<div className={classNames(inputWrapperClassName, 'x-theme-input', 'relative flex')}>
<input
aria-hidden
@@ -51,6 +59,7 @@ export function Checkbox({
<div className={classNames(fullWidth && 'w-full', disabled && 'opacity-disabled')}>
{!hideLabel && title}
</div>
{help && <IconTooltip content={help} />}
</HStack>
);
}

View File

@@ -75,6 +75,7 @@ export function Dialog({
'relative bg-surface pointer-events-auto',
'rounded-lg',
'border border-border-subtle shadow-lg shadow-[rgba(0,0,0,0.1)]',
'min-h-[10rem]',
'max-w-[calc(100vw-5rem)] max-h-[calc(100vh-5rem)]',
size === 'sm' && 'w-[28rem]',
size === 'md' && 'w-[45rem]',
@@ -88,15 +89,15 @@ export function Dialog({
{title}
</Heading>
) : (
<span />
<span aria-hidden />
)}
{description ? (
<div className="px-6 text-text-subtle" id={descriptionId}>
<div className="px-6 text-text-subtle mb-3" id={descriptionId}>
{description}
</div>
) : (
<span />
<span aria-hidden />
)}
<div

View File

@@ -0,0 +1,32 @@
import classNames from 'classnames';
import { useKeyValue } from '../../hooks/useKeyValue';
import type { BannerProps } from './Banner';
import { Banner } from './Banner';
import { IconButton } from './IconButton';
export function DismissibleBanner({
children,
className,
id,
...props
}: BannerProps & { id: string }) {
const { set: setDismissed, value: dismissed } = useKeyValue<boolean>({
namespace: 'global',
key: ['dismiss-banner', id],
fallback: false,
});
if (dismissed) return null;
return (
<Banner className={classNames(className, 'relative pr-8')} {...props}>
<IconButton
className="!absolute right-0 top-0"
icon="x"
onClick={() => setDismissed((d) => !d)}
title="Dismiss message"
/>
{children}
</Banner>
);
}

View File

@@ -134,30 +134,31 @@
.cm-searchMatch {
@apply bg-transparent !important;
@apply rounded-[2px] outline outline-1;
&.cm-searchMatch-selected {
@apply outline-text;
@apply bg-text !important;
&, * {
@apply text-surface font-semibold !important;
}
}
}
/*.cm-searchMatch {*/
/* @apply bg-transparent !important;*/
/* @apply outline outline-[1.5px] outline-text-subtlest rounded-sm;*/
/* &.cm-searchMatch-selected {*/
/* @apply outline-text;*/
/* & * {*/
/* @apply text-text font-semibold;*/
/* }*/
/* }*/
/*}*/
/* Obscure text for password fields */
.cm-wrapper.cm-obscure-text .cm-line {
-webkit-text-security: disc;
}
/* Obscure text for password fields */
.cm-wrapper.cm-obscure-text .cm-line {
-webkit-text-security: disc;
.cm-placeholder {
-webkit-text-security: none;
}
}
.cm-editor .cm-gutterElement {
@apply flex items-center;
transition: color var(--transition-duration);

View File

@@ -6,12 +6,13 @@ import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/vie
import { emacs } from '@replit/codemirror-emacs';
import { vim } from '@replit/codemirror-vim';
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import type {EditorKeymap, EnvironmentVariable} from '@yaakapp-internal/models';
import { settingsAtom} from '@yaakapp-internal/models';
import type { EditorKeymap, EnvironmentVariable } from '@yaakapp-internal/models';
import { settingsAtom } from '@yaakapp-internal/models';
import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins';
import { parseTemplate } from '@yaakapp-internal/templates';
import classNames from 'classnames';
import { EditorView } from 'codemirror';
import {useAtomValue} from "jotai";
import { useAtomValue } from 'jotai';
import { md5 } from 'js-md5';
import type { MutableRefObject, ReactNode } from 'react';
import {
@@ -26,14 +27,15 @@ import {
useRef,
} from 'react';
import { useActiveEnvironmentVariables } from '../../../hooks/useActiveEnvironmentVariables';
import { parseTemplate } from '../../../hooks/useParseTemplate';
import { useRequestEditor } from '../../../hooks/useRequestEditor';
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
import { showDialog } from '../../../lib/dialog';
import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { TemplateVariableDialog } from '../../TemplateVariableDialog';
import { IconButton } from '../IconButton';
import { InlineCode } from '../InlineCode';
import { HStack } from '../Stacks';
import './Editor.css';
import {
@@ -203,7 +205,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
useEffect(
function configurePlaceholder() {
if (cm.current === null) return;
const ext = placeholderExt(placeholderElFromText(placeholder, type));
const ext = placeholderExt(placeholderElFromText(placeholder));
const effects = placeholderCompartment.current.reconfigure(ext);
cm.current?.view.dispatch({ effects });
},
@@ -265,32 +267,39 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const onClickFunction = useCallback(
async (fn: TemplateFunction, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
showDialog({
id: 'template-function-'+Math.random(), // Allow multiple at once
size: 'sm',
title: 'Configure Function',
description: fn.description,
render: ({ hide }) => (
<TemplateFunctionDialog
templateFunction={fn}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
const initialTokens = parseTemplate(tagValue);
const show = () =>
showDialog({
id: 'template-function-' + Math.random(), // Allow multiple at once
size: 'sm',
title: <InlineCode>{fn.name}()</InlineCode>,
description: fn.description,
render: ({ hide }) => (
<TemplateFunctionDialog
templateFunction={fn}
hide={hide}
initialTokens={initialTokens}
onChange={(insert) => {
cm.current?.view.dispatch({
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
});
}}
/>
),
});
if (fn.name === 'secure') {
withEncryptionEnabled(show);
} else {
show();
}
},
[],
);
const onClickVariable = useCallback(
async (_v: EnvironmentVariable, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
const initialTokens = parseTemplate(tagValue);
showDialog({
size: 'dynamic',
id: 'template-variable',
@@ -313,7 +322,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const onClickMissingVariable = useCallback(
async (_name: string, tagValue: string, startPos: number) => {
const initialTokens = await parseTemplate(tagValue);
const initialTokens = parseTemplate(tagValue);
showDialog({
size: 'dynamic',
id: 'template-variable',
@@ -398,9 +407,7 @@ export const Editor = forwardRef<EditorView | undefined, EditorProps>(function E
const extensions = [
languageCompartment.of(langExt),
placeholderCompartment.current.of(
placeholderExt(placeholderElFromText(placeholder, type)),
),
placeholderCompartment.current.of(placeholderExt(placeholderElFromText(placeholder))),
wrapLinesCompartment.current.of(wrapLines ? EditorView.lineWrapping : emptyExtension),
tabIndentCompartment.current.of(
!disableTabIndent ? keymap.of([indentWithTab]) : emptyExtension,
@@ -639,17 +646,11 @@ function getExtensions({
];
}
const placeholderElFromText = (text: string | undefined, type: EditorProps['type']) => {
const placeholderElFromText = (text: string | undefined) => {
const el = document.createElement('div');
if (type === 'password') {
// Will be obscured (dots) so just needs to be something to take up space
el.innerHTML = 'something-cool';
el.setAttribute('aria-hidden', 'true');
} else {
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
// to do with how Yaak "hacks" it with CSS for single line input.
el.innerHTML = text ? text.replaceAll('\n', '<br/>') : ' ';
}
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
// to do with how Yaak "hacks" it with CSS for single line input.
el.innerHTML = text ? text.replaceAll('\n', '<br/>') : ' ';
return el;
};

View File

@@ -11,7 +11,7 @@ export function Heading({ className, level = 1, ...props }: Props) {
<Component
className={classNames(
className,
'font-semibold text',
'font-semibold text-text',
level === 1 && 'text-2xl',
level === 2 && 'text-xl',
level === 3 && 'text-lg',

View File

@@ -56,8 +56,8 @@ const icons = {
git_branch_plus: lucide.GitBranchPlusIcon,
git_commit: lucide.GitCommitIcon,
git_commit_vertical: lucide.GitCommitVerticalIcon,
git_pull_request: lucide.GitPullRequestIcon,
git_fork: lucide.GitForkIcon,
git_pull_request: lucide.GitPullRequestIcon,
grip_vertical: lucide.GripVerticalIcon,
hand: lucide.HandIcon,
help: lucide.CircleHelpIcon,
@@ -65,6 +65,7 @@ const icons = {
house: lucide.HomeIcon,
import: lucide.ImportIcon,
info: lucide.InfoIcon,
key_round: lucide.KeyRoundIcon,
keyboard: lucide.KeyboardIcon,
left_panel_hidden: lucide.PanelLeftOpenIcon,
left_panel_visible: lucide.PanelLeftCloseIcon,
@@ -87,6 +88,9 @@ const icons = {
search: lucide.SearchIcon,
send_horizontal: lucide.SendHorizonalIcon,
settings: lucide.SettingsIcon,
shield: lucide.ShieldIcon,
shield_check: lucide.ShieldCheckIcon,
shield_off: lucide.ShieldOffIcon,
sparkles: lucide.SparklesIcon,
sun: lucide.SunIcon,
table: lucide.TableIcon,
@@ -126,7 +130,7 @@ export const Icon = memo(function Icon({
title={title}
className={classNames(
className,
'flex-shrink-0',
'flex-shrink-0 transform-cpu',
size === 'xl' && 'h-6 w-6',
size === 'lg' && 'h-5 w-5',
size === 'md' && 'h-4 w-4',
@@ -134,7 +138,7 @@ export const Icon = memo(function Icon({
size === 'xs' && 'h-3 w-3',
size === '2xs' && 'h-2.5 w-2.5',
color === 'default' && 'inherit',
color === 'danger' && 'text-danger',
color === 'danger' && 'text-danger!',
color === 'warning' && 'text-warning',
color === 'notice' && 'text-notice',
color === 'info' && 'text-info',

View File

@@ -74,10 +74,11 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
size={iconSize}
icon={confirmed ? 'check' : icon}
spin={spin}
color={confirmed ? 'success' : iconColor}
color={iconColor}
className={classNames(
iconClassName,
'group-hover/button:text-text',
confirmed && '!text-success', // Don't use Icon.color here because it won't override the hover color
props.disabled && 'opacity-70',
)}
/>

View File

@@ -0,0 +1,19 @@
import React from 'react';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
import type { TooltipProps } from './Tooltip';
import { Tooltip } from './Tooltip';
type Props = Omit<TooltipProps, 'children'> & {
icon?: IconProps['icon'];
iconSize?: IconProps['size'];
className?: string;
};
export function IconTooltip({ content, icon = 'info', iconSize, ...tooltipProps }: Props) {
return (
<Tooltip content={content} {...tooltipProps}>
<Icon className="opacity-60 hover:opacity-100" icon={icon} size={iconSize} />
</Tooltip>
);
}

View File

@@ -1,11 +1,32 @@
import type { Color } from '@yaakapp/api';
import classNames from 'classnames';
import type { EditorView } from 'codemirror';
import type { ReactNode } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react';
import { useIsEncryptionEnabled } from '../../hooks/useIsEncryptionEnabled';
import { useStateWithDeps } from '../../hooks/useStateWithDeps';
import {
analyzeTemplate,
convertTemplateToInsecure,
convertTemplateToSecure,
} from '../../lib/encryption';
import { generateId } from '../../lib/generateId';
import { withEncryptionEnabled } from '../../lib/setupOrConfigureEncryption';
import { Button } from './Button';
import type { DropdownItem } from './Dropdown';
import { Dropdown } from './Dropdown';
import type { EditorProps } from './Editor/Editor';
import { Editor } from './Editor/Editor';
import type { IconProps } from './Icon';
import { Icon } from './Icon';
import { IconButton } from './IconButton';
import { Label } from './Label';
import { HStack } from './Stacks';
@@ -23,34 +44,46 @@ export type InputProps = Pick<
| 'onKeyDown'
| 'readOnly'
> & {
name?: string;
type?: 'text' | 'password';
label: ReactNode;
hideLabel?: boolean;
labelPosition?: 'top' | 'left';
labelClassName?: string;
className?: string;
containerClassName?: string;
defaultValue?: string | null;
disableObscureToggle?: boolean;
fullHeight?: boolean;
hideLabel?: boolean;
inputWrapperClassName?: string;
label: ReactNode;
labelClassName?: string;
labelPosition?: 'top' | 'left';
leftSlot?: ReactNode;
multiLine?: boolean;
name?: string;
onBlur?: () => void;
onChange?: (value: string) => void;
onFocus?: () => void;
onBlur?: () => void;
onPaste?: (value: string) => void;
onPasteOverwrite?: EditorProps['onPasteOverwrite'];
defaultValue?: string;
leftSlot?: ReactNode;
placeholder?: string;
required?: boolean;
rightSlot?: ReactNode;
size?: 'xs' | 'sm' | 'md' | 'auto';
className?: string;
placeholder?: string;
validate?: boolean | ((v: string) => boolean);
required?: boolean;
wrapLines?: boolean;
multiLine?: boolean;
fullHeight?: boolean;
stateKey: EditorProps['stateKey'];
tint?: Color;
type?: 'text' | 'password';
validate?: boolean | ((v: string) => boolean);
wrapLines?: boolean;
};
export const Input = forwardRef<EditorView, InputProps>(function Input(
export const Input = forwardRef<EditorView, InputProps>(function Input({ type, ...props }, ref) {
// If it's a password and template functions are supported (ie. secure(...)) then
// use the encrypted input component.
if (type === 'password' && props.autocompleteFunctions) {
return <EncryptionInput {...props} />;
} else {
return <BaseInput ref={ref} type={type} {...props} />;
}
});
const BaseInput = forwardRef<EditorView, InputProps>(function InputBase(
{
className,
containerClassName,
@@ -74,6 +107,8 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
wrapLines,
size = 'md',
type = 'text',
disableObscureToggle,
tint,
validate,
readOnly,
stateKey,
@@ -83,11 +118,11 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
}: InputProps,
ref,
) {
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [currentValue, setCurrentValue] = useState(defaultValue ?? '');
const [focused, setFocused] = useState(false);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [stateKey, forceUpdateKey]);
const [obscured, setObscured] = useStateWithDeps(type === 'password', [type]);
const [hasChanged, setHasChanged] = useStateWithDeps<boolean>(false, [forceUpdateKey]);
const editorRef = useRef<EditorView | null>(null);
useImperativeHandle<EditorView | null, EditorView | null>(ref, () => editorRef.current);
const handleFocus = useCallback(() => {
@@ -116,15 +151,14 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
);
const isValid = useMemo(() => {
if (required && !validateRequire(currentValue)) return false;
if (required && !validateRequire(defaultValue ?? '')) return false;
if (typeof validate === 'boolean') return validate;
if (typeof validate === 'function' && !validate(currentValue)) return false;
if (typeof validate === 'function' && !validate(defaultValue ?? '')) return false;
return true;
}, [required, currentValue, validate]);
}, [required, defaultValue, validate]);
const handleChange = useCallback(
(value: string) => {
setCurrentValue(value);
onChange?.(value);
setHasChanged(true);
},
@@ -171,7 +205,7 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
containerClassName,
fullHeight && 'h-full',
'x-theme-input',
'relative w-full rounded-md text',
'relative w-full rounded-md text overflow-hidden',
'border',
focused && !disabled ? 'border-border-focus' : 'border-border',
disabled && 'border-dotted',
@@ -181,6 +215,21 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
size === 'xs' && 'min-h-xs',
)}
>
{tint != null && (
<div
aria-hidden
className={classNames(
'absolute inset-0 opacity-5 pointer-events-none',
tint === 'primary' && 'bg-primary',
tint === 'secondary' && 'bg-secondary',
tint === 'info' && 'bg-info',
tint === 'success' && 'bg-success',
tint === 'notice' && 'bg-notice',
tint === 'warning' && 'bg-warning',
tint === 'danger' && 'bg-danger',
)}
/>
)}
{leftSlot}
<HStack
className={classNames(
@@ -219,15 +268,21 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
{...props}
/>
</HStack>
{type === 'password' && (
{type === 'password' && !disableObscureToggle && (
<IconButton
title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs"
className={classNames(
'mr-0.5 group/obscure !h-auto my-0.5',
disabled && 'opacity-disabled',
)}
iconClassName="group-hover/obscure:text"
className={classNames('mr-0.5 !h-auto my-0.5', disabled && 'opacity-disabled')}
color={tint}
// iconClassName={classNames(
// tint === 'primary' && 'text-primary',
// tint === 'secondary' && 'text-secondary',
// tint === 'info' && 'text-info',
// tint === 'success' && 'text-success',
// tint === 'notice' && 'text-notice',
// tint === 'warning' && 'text-warning',
// tint === 'danger' && 'text-danger',
// )}
iconSize="sm"
icon={obscured ? 'eye' : 'eye_closed'}
onClick={() => setObscured((o) => !o)}
@@ -242,3 +297,167 @@ export const Input = forwardRef<EditorView, InputProps>(function Input(
function validateRequire(v: string) {
return v.length > 0;
}
type PasswordFieldType = 'text' | 'encrypted';
function EncryptionInput({
defaultValue,
onChange,
autocompleteFunctions,
autocompleteVariables,
forceUpdateKey: ogForceUpdateKey,
...props
}: Omit<InputProps, 'type'>) {
const isEncryptionEnabled = useIsEncryptionEnabled();
const [state, setState] = useStateWithDeps<{
fieldType: PasswordFieldType;
value: string | null;
security: ReturnType<typeof analyzeTemplate> | null;
obscured: boolean;
}>({ fieldType: 'encrypted', value: null, security: null, obscured: true }, [ogForceUpdateKey]);
const forceUpdateKey = `${ogForceUpdateKey}::${state.fieldType}::${state.value === null}`;
useEffect(() => {
if (state.value != null) {
// We already configured it
return;
}
const security = analyzeTemplate(defaultValue ?? '');
if (analyzeTemplate(defaultValue ?? '') === 'global_secured') {
// Lazily update value to decrypted representation
convertTemplateToInsecure(defaultValue ?? '').then((value) => {
setState({ fieldType: 'encrypted', security, value, obscured: true });
});
} else if (isEncryptionEnabled && !defaultValue) {
// Default to encrypted field for new encrypted inputs
setState({ fieldType: 'encrypted', security, value: '', obscured: true });
} else if (isEncryptionEnabled) {
// Don't obscure plain text when encryption is enabled
setState({ fieldType: 'text', security, value: defaultValue ?? '', obscured: false });
} else {
// Don't obscure plain text when encryption is disabled
setState({ fieldType: 'text', security, value: defaultValue ?? '', obscured: true });
}
}, [defaultValue, isEncryptionEnabled, setState, state.value]);
const handleChange = useCallback(
(value: string, fieldType: PasswordFieldType) => {
if (fieldType === 'encrypted') {
convertTemplateToSecure(value).then((value) => onChange?.(value));
} else {
onChange?.(value);
}
setState((s) => {
// We can't analyze when encrypted because we don't have the raw value, so assume it's secured
const security = fieldType === 'encrypted' ? 'global_secured' : analyzeTemplate(value);
// Reset obscured value when the field type is being changed
const obscured = fieldType === s.fieldType ? s.obscured : fieldType !== 'text';
return { fieldType, value, security, obscured };
});
},
[onChange, setState],
);
const handleInputChange = useCallback(
(value: string) => {
if (state.fieldType != null) {
handleChange(value, state.fieldType);
}
},
[handleChange, state],
);
const handleFieldTypeChange = useCallback(
(newFieldType: PasswordFieldType) => {
const { value, fieldType } = state;
if (value == null || fieldType === newFieldType) {
return;
}
withEncryptionEnabled(async () => {
const newValue = await convertTemplateToInsecure(value);
handleChange(newValue, newFieldType);
});
},
[handleChange, state],
);
const dropdownItems = useMemo<DropdownItem[]>(
() => [
{
label: state.obscured ? 'Reveal value' : 'Conceal value',
disabled: isEncryptionEnabled && state.fieldType === 'text',
leftSlot: <Icon icon={state.obscured ? 'eye' : 'eye_closed'} />,
onSelect: () => setState((s) => ({ ...s, obscured: !s.obscured })),
},
{ type: 'separator' },
{
label: state.fieldType === 'text' ? 'Encrypt Value' : 'Decrypt Value',
leftSlot: <Icon icon={state.fieldType === 'text' ? 'lock' : 'lock_open'} />,
onSelect: () => handleFieldTypeChange(state.fieldType === 'text' ? 'encrypted' : 'text'),
},
],
[handleFieldTypeChange, isEncryptionEnabled, setState, state.fieldType, state.obscured],
);
let tint: InputProps['tint'];
if (!isEncryptionEnabled) {
tint = undefined;
} else if (state.fieldType === 'encrypted') {
tint = 'info';
} else if (state.security === 'local_secured') {
tint = 'secondary';
} else if (state.security === 'insecure') {
tint = 'notice';
}
const rightSlot = useMemo(() => {
let icon: IconProps['icon'];
if (isEncryptionEnabled) {
icon = state.security === 'insecure' ? 'shield_off' : 'shield_check';
} else {
icon = state.obscured ? 'eye_closed' : 'eye';
}
return (
<HStack className="h-auto m-0.5">
<Dropdown items={dropdownItems}>
<Button
size="sm"
variant="border"
color={tint}
aria-label="Configure encryption"
className={classNames(
'flex items-center justify-center !h-full !px-1',
'opacity-70', // Makes it a bit subtler
props.disabled && '!opacity-disabled',
)}
>
<HStack space={0.5}>
<Icon size="sm" title="Configure encryption" icon={icon} />
<Icon size="xs" title="Configure encryption" icon="chevron_down" />
</HStack>
</Button>
</Dropdown>
</HStack>
);
}, [dropdownItems, isEncryptionEnabled, props.disabled, state.obscured, state.security, tint]);
const type = state.obscured ? 'password' : 'text';
return (
<BaseInput
disableObscureToggle
autocompleteFunctions={autocompleteFunctions}
autocompleteVariables={autocompleteVariables}
defaultValue={state.value ?? ''}
forceUpdateKey={forceUpdateKey}
onChange={handleInputChange}
tint={tint}
type={type}
rightSlot={rightSlot}
{...props}
/>
);
}

View File

@@ -1,5 +1,6 @@
import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import type { HTMLAttributes, ReactNode } from 'react';
import { IconTooltip } from './IconTooltip';
export function Label({
htmlFor,
@@ -8,21 +9,24 @@ export function Label({
visuallyHidden,
tags = [],
required,
help,
...props
}: HTMLAttributes<HTMLLabelElement> & {
htmlFor: string;
htmlFor: string | null;
required?: boolean;
tags?: string[];
visuallyHidden?: boolean;
children: ReactNode;
help?: ReactNode;
}) {
return (
<label
htmlFor={htmlFor}
htmlFor={htmlFor ?? undefined}
className={classNames(
className,
visuallyHidden && 'sr-only',
'flex-shrink-0 text-sm',
'text-text-subtle whitespace-nowrap flex items-center gap-1',
'text-text-subtle whitespace-nowrap flex items-center gap-1 mb-0.5',
)}
{...props}
>
@@ -35,6 +39,7 @@ export function Label({
({tag})
</span>
))}
{help && <IconTooltip content={help} />}
</label>
);
}

View File

@@ -55,7 +55,7 @@ export type PairEditorProps = {
valueAutocompleteFunctions?: boolean;
valueAutocompleteVariables?: boolean;
valuePlaceholder?: string;
valueType?: 'text' | 'password';
valueType?: InputProps['type'] | ((pair: Pair) => InputProps['type']);
valueValidate?: InputProps['validate'];
};
@@ -78,7 +78,6 @@ const MAX_INITIAL_PAIRS = 50;
export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function PairEditor(
{
stateKey,
allowFileValues,
allowMultilineValues,
className,
@@ -91,6 +90,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
noScroll,
onChange,
pairs: originalPairs,
stateKey,
valueAutocomplete,
valueAutocompleteFunctions,
valueAutocompleteVariables,
@@ -124,7 +124,7 @@ export const PairEditor = forwardRef<PairEditorRef, PairEditorProps>(function Pa
const p = originalPairs[i];
if (!p) continue; // Make TS happy
if (isPairEmpty(p)) continue;
newPairs.push({ ...p, id: p.id ?? generateId() });
newPairs.push(ensurePairId(p));
}
// Add empty last pair if there is none
@@ -555,7 +555,7 @@ function PairEditorRow({
name={`value[${index}]`}
onChange={handleChangeValueText}
onFocus={handleFocus}
type={isLast ? 'text' : valueType}
type={isLast ? 'text' : typeof valueType === 'function' ? valueType(pair) : valueType}
placeholder={valuePlaceholder ?? 'value'}
autocomplete={valueAutocomplete?.(pair.name)}
autocompleteFunctions={valueAutocompleteFunctions}
@@ -615,7 +615,7 @@ function FileActionsDropdown({
[onChangeFile, onChangeText],
);
const extraItems = useMemo<DropdownItem[]>(
const itemsAfter = useMemo<DropdownItem[]>(
() => [
{
label: 'Edit Multi-Line',
@@ -664,7 +664,7 @@ function FileActionsDropdown({
value={pair.isFile ? 'file' : 'text'}
onChange={onChange}
items={fileItems}
extraItems={extraItems}
itemsAfter={itemsAfter}
>
<IconButton iconSize="sm" size="xs" icon="chevron_down" title="Select form data type" />
</RadioDropdown>
@@ -672,12 +672,7 @@ function FileActionsDropdown({
}
function emptyPair(): PairWithId {
return {
enabled: true,
name: '',
value: '',
id: generateId(),
};
return ensurePairId({ enabled: true, name: '', value: '' });
}
function isPairEmpty(pair: Pair): boolean {
@@ -723,3 +718,12 @@ function MultilineEditDialog({
</div>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function ensurePairId(p: Pair): PairWithId {
if (typeof p.id === 'string') {
return p as PairWithId;
} else {
return { ...p, id: p.id ?? generateId() };
}
}

View File

@@ -12,6 +12,7 @@ export type PlainInputProps = Omit<InputProps, 'wrapLines' | 'onKeyDown' | 'type
onFocusRaw?: HTMLAttributes<HTMLInputElement>['onFocus'];
type?: 'text' | 'password' | 'number';
step?: number;
hideObscureToggle?: boolean;
};
export function PlainInput({
@@ -31,8 +32,10 @@ export function PlainInput({
onPaste,
required,
rightSlot,
hideObscureToggle,
size = 'md',
type = 'text',
tint,
validate,
autoSelect,
placeholder,
@@ -115,6 +118,16 @@ export function PlainInput({
size === 'xs' && 'min-h-xs',
)}
>
{tint != null && (
<div
aria-hidden
className={classNames(
'absolute inset-0 opacity-5 pointer-events-none',
tint === 'info' && 'bg-info',
tint === 'warning' && 'bg-warning',
)}
/>
)}
{leftSlot}
<HStack
className={classNames(
@@ -128,7 +141,7 @@ export function PlainInput({
key={forceUpdateKey}
id={id}
type={type === 'password' && !obscured ? 'text' : type}
defaultValue={defaultValue}
defaultValue={defaultValue ?? undefined}
autoComplete="off"
autoCapitalize="off"
autoCorrect="off"
@@ -143,7 +156,7 @@ export function PlainInput({
onKeyDownCapture={onKeyDownCapture}
/>
</HStack>
{type === 'password' && (
{type === 'password' && !hideObscureToggle && (
<IconButton
title={obscured ? `Show ${label}` : `Obscure ${label}`}
size="xs"

View File

@@ -17,20 +17,23 @@ export type RadioDropdownItem<T = string | null> =
export interface RadioDropdownProps<T = string | null> {
value: T;
onChange: (value: T) => void;
itemsBefore?: DropdownItem[];
items: RadioDropdownItem<T>[];
extraItems?: DropdownItem[];
itemsAfter?: DropdownItem[];
children: DropdownProps['children'];
}
export function RadioDropdown<T = string | null>({
value,
items,
extraItems,
itemsAfter,
itemsBefore,
onChange,
children,
}: RadioDropdownProps<T>) {
const dropdownItems = useMemo(
() => [
...((itemsBefore ? [...itemsBefore, { type: 'separator' }] : []) as DropdownItem[]),
...items.map((item) => {
if (item.type === 'separator') {
return item;
@@ -44,9 +47,9 @@ export function RadioDropdown<T = string | null>({
} as DropdownItem;
}
}),
...((extraItems ? [{ type: 'separator' }, ...extraItems] : []) as DropdownItem[]),
...((itemsAfter ? [{ type: 'separator' }, ...itemsAfter] : []) as DropdownItem[]),
],
[items, extraItems, value, onChange],
[itemsBefore, items, itemsAfter, value, onChange],
);
return (

View File

@@ -10,9 +10,15 @@ interface Props<T extends string> {
onChange: (value: T) => void;
value: T;
name: string;
className?: string;
}
export function SegmentedControl<T extends string>({ value, onChange, options }: Props<T>) {
export function SegmentedControl<T extends string>({
value,
onChange,
options,
className,
}: Props<T>) {
const [selectedValue, setSelectedValue] = useStateWithDeps<T>(value, [value]);
const containerRef = useRef<HTMLDivElement>(null);
return (
@@ -21,7 +27,12 @@ export function SegmentedControl<T extends string>({ value, onChange, options }:
role="group"
dir="ltr"
space={0.5}
className="bg-surface-highlight rounded-md mb-auto opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100 transition-opacity transform-gpu"
className={classNames(
className,
'bg-surface-highlight rounded-md mb-auto opacity-0',
'transition-opacity transform-gpu',
'group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100',
)}
onKeyDown={(e) => {
const selectedIndex = options.findIndex((o) => o.value === selectedValue);
if (e.key === 'ArrowRight') {

View File

@@ -144,7 +144,7 @@ export function SplitLayout({
const containerQueryReady = size.width > 0 || size.height > 0;
return (
<div ref={containerRef} style={styles} className={classNames(className, 'grid w-full h-full')}>
<div ref={containerRef} style={styles} className={classNames(className, 'grid w-full h-full overflow-hidden')}>
{containerQueryReady && (
<>
{firstSlot({ style: areaL, orientation: vertical ? 'vertical' : 'horizontal' })}

View File

@@ -0,0 +1,135 @@
import classNames from 'classnames';
import type { CSSProperties, KeyboardEvent, ReactNode } from 'react';
import React, { useRef, useState } from 'react';
import { generateId } from '../../lib/generateId';
import { Portal } from '../Portal';
export interface TooltipProps {
children: ReactNode;
content: ReactNode;
size?: 'md' | 'lg';
}
const hiddenStyles: CSSProperties = {
left: -99999,
top: -99999,
visibility: 'hidden',
pointerEvents: 'none',
opacity: 0,
};
export function Tooltip({ children, content, size = 'md' }: TooltipProps) {
const [isOpen, setIsOpen] = useState<CSSProperties>();
const triggerRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const showTimeout = useRef<NodeJS.Timeout>();
const handleOpenImmediate = () => {
if (triggerRef.current == null || tooltipRef.current == null) return;
clearTimeout(showTimeout.current);
setIsOpen(undefined);
const triggerRect = triggerRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const docRect = document.documentElement.getBoundingClientRect();
const styles: CSSProperties = {
bottom: docRect.height - triggerRect.top,
left: Math.max(0, triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2),
maxHeight: triggerRect.top,
};
setIsOpen(styles);
};
const handleOpen = () => {
clearTimeout(showTimeout.current);
showTimeout.current = setTimeout(handleOpenImmediate, 500);
};
const handleClose = () => {
clearTimeout(showTimeout.current);
setIsOpen(undefined);
};
const handleToggleImmediate = () => {
if (isOpen) handleClose();
else handleOpenImmediate();
};
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
if (isOpen && e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
handleClose();
}
};
const id = useRef(`tooltip-${generateId()}`);
return (
<>
<Portal name="tooltip">
<div
ref={tooltipRef}
style={isOpen ?? hiddenStyles}
id={id.current}
role="tooltip"
aria-hidden={!isOpen}
onMouseEnter={handleOpenImmediate}
onMouseLeave={handleClose}
className="p-2 fixed z-50 text-sm transition-opacity grid grid-rows-[minmax(0,1fr)]"
>
<div
className={classNames(
'bg-surface-highlight rounded-md px-3 py-2 z-50 border border-border overflow-auto',
size === 'md' && 'max-w-sm',
size === 'lg' && 'max-w-md',
)}
>
{content}
</div>
<Triangle className="text-border mb-2" />
</div>
</Portal>
<button
ref={triggerRef}
type="button"
aria-describedby={isOpen ? id.current : undefined}
className="flex-grow-0 inline-flex items-center"
onClick={handleToggleImmediate}
onMouseEnter={handleOpen}
onMouseLeave={handleClose}
onFocus={handleOpenImmediate}
onBlur={handleClose}
onKeyDown={handleKeyDown}
>
{children}
</button>
</>
);
}
function Triangle({ className }: { className?: string }) {
return (
<svg
aria-hidden
viewBox="0 0 30 10"
preserveAspectRatio="none"
shapeRendering="crispEdges"
className={classNames(
className,
'absolute z-50 border-t-[2px] border-surface-highlight',
'-bottom-[calc(0.5rem-3px)] left-[calc(50%-0.4rem)]',
'h-[0.5rem] w-[0.8rem]',
)}
>
<polygon className="fill-surface-highlight" points="0,0 30,0 15,10" />
<path
d="M0 0 L15 9 L30 0"
fill="none"
stroke="currentColor"
strokeWidth="1"
strokeLinejoin="miter"
vectorEffect="non-scaling-stroke"
/>
</svg>
);
}

View File

@@ -12,9 +12,9 @@ import { useMoveToWorkspace } from '../../hooks/useMoveToWorkspace';
import { useSendAnyHttpRequest } from '../../hooks/useSendAnyHttpRequest';
import { useSendManyRequests } from '../../hooks/useSendManyRequests';
import { deleteModelWithConfirm } from '../../lib/deleteModelWithConfirm';
import { duplicateRequestAndNavigate } from '../../lib/deleteRequestAndNavigate';
import { showDialog } from '../../lib/dialog';
import { duplicateRequestAndNavigate } from '../../lib/duplicateRequestAndNavigate';
import { renameModelWithPrompt } from '../../lib/renameModelWithPrompt';
import type { DropdownItem } from '../core/Dropdown';
import { ContextMenu } from '../core/Dropdown';