mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-20 00:23:58 +01:00
[WIP] Encryption for secure values (#183)
This commit is contained in:
@@ -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: [],
|
||||
};
|
||||
|
||||
|
||||
34
src-web/components/CopyIconButton.tsx
Normal file
34
src-web/components/CopyIconButton.tsx
Normal 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();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
14
src-web/components/EncryptionHelp.tsx
Normal file
14
src-web/components/EncryptionHelp.tsx
Normal 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>
|
||||
}
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -219,7 +219,7 @@ export function GrpcRequestPane({
|
||||
type: 'default',
|
||||
shortLabel: o.label,
|
||||
}))}
|
||||
extraItems={[
|
||||
itemsAfter={[
|
||||
{
|
||||
label: 'Refresh',
|
||||
type: 'default',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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[] =
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 || <> </>
|
||||
)}
|
||||
</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 || <> </>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
227
src-web/components/WorkspaceEncryptionSetting.tsx
Normal file
227
src-web/components/WorkspaceEncryptionSetting.tsx
Normal 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'll need to send them this key to access any encrypted values.
|
||||
</p>
|
||||
);
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
6
src-web/components/core/BadgeButton.tsx
Normal file
6
src-web/components/core/BadgeButton.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
32
src-web/components/core/DismissibleBanner.tsx
Normal file
32
src-web/components/core/DismissibleBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
)}
|
||||
/>
|
||||
|
||||
19
src-web/components/core/IconTooltip.tsx
Normal file
19
src-web/components/core/IconTooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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' })}
|
||||
|
||||
135
src-web/components/core/Tooltip.tsx
Normal file
135
src-web/components/core/Tooltip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user