mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-03-18 07:23:51 +01:00
Refactor desktop app into separate client and proxy apps
This commit is contained in:
2
apps/yaak-client/.gitignore
vendored
Normal file
2
apps/yaak-client/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
vite.config.d.ts
|
||||
vite.config.js
|
||||
170
apps/yaak-client/commands/commands.tsx
Normal file
170
apps/yaak-client/commands/commands.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import { createWorkspaceModel, type Folder, modelTypeLabel } from '@yaakapp-internal/models';
|
||||
import { applySync, calculateSync } from '@yaakapp-internal/sync';
|
||||
import { Banner } from '../components/core/Banner';
|
||||
import { Button } from '../components/core/Button';
|
||||
import { InlineCode } from '../components/core/InlineCode';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TruncatedWideTableCell,
|
||||
} from '../components/core/Table';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { pluralizeCount } from '../lib/pluralize';
|
||||
import { showPrompt } from '../lib/prompt';
|
||||
import { resolvedModelNameWithFolders } from '../lib/resolvedModelName';
|
||||
|
||||
export const createFolder = createFastMutation<
|
||||
string | null,
|
||||
void,
|
||||
Partial<Pick<Folder, 'name' | 'sortPriority' | 'folderId'>>
|
||||
>({
|
||||
mutationKey: ['create_folder'],
|
||||
mutationFn: async (patch) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) {
|
||||
throw new Error("Cannot create folder when there's no active workspace");
|
||||
}
|
||||
|
||||
if (!patch.name) {
|
||||
const name = await showPrompt({
|
||||
id: 'new-folder',
|
||||
label: 'Name',
|
||||
defaultValue: 'Folder',
|
||||
title: 'New Folder',
|
||||
confirmText: 'Create',
|
||||
placeholder: 'Name',
|
||||
});
|
||||
if (name == null) return null;
|
||||
|
||||
patch.name = name;
|
||||
}
|
||||
|
||||
patch.sortPriority = patch.sortPriority || -Date.now();
|
||||
const id = await createWorkspaceModel({ model: 'folder', workspaceId, ...patch });
|
||||
return id;
|
||||
},
|
||||
});
|
||||
|
||||
export const syncWorkspace = createFastMutation<
|
||||
void,
|
||||
void,
|
||||
{ workspaceId: string; syncDir: string; force?: boolean }
|
||||
>({
|
||||
mutationKey: [],
|
||||
mutationFn: async ({ workspaceId, syncDir, force }) => {
|
||||
const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
|
||||
if (ops.length === 0) {
|
||||
console.log('Nothing to sync', workspaceId, syncDir);
|
||||
return;
|
||||
}
|
||||
console.log('Syncing workspace', workspaceId, syncDir, ops);
|
||||
|
||||
const dbOps = ops.filter((o) => o.type.startsWith('db'));
|
||||
|
||||
if (dbOps.length === 0) {
|
||||
await applySync(workspaceId, syncDir, ops);
|
||||
return;
|
||||
}
|
||||
|
||||
const isDeletingWorkspace = ops.some(
|
||||
(o) => o.type === 'dbDelete' && o.model.model === 'workspace',
|
||||
);
|
||||
|
||||
console.log('Directory changes detected', { dbOps, ops });
|
||||
|
||||
if (force) {
|
||||
await applySync(workspaceId, syncDir, ops);
|
||||
return;
|
||||
}
|
||||
|
||||
showDialog({
|
||||
id: 'commit-sync',
|
||||
title: 'Changes Detected',
|
||||
size: 'md',
|
||||
render: ({ hide }) => (
|
||||
<form
|
||||
className="h-full grid grid-rows-[auto_auto_minmax(0,1fr)_auto] gap-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await applySync(workspaceId, syncDir, ops);
|
||||
hide();
|
||||
}}
|
||||
>
|
||||
{isDeletingWorkspace ? (
|
||||
<Banner color="danger">
|
||||
🚨 <strong>Changes contain a workspace deletion!</strong>
|
||||
</Banner>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<p>
|
||||
{pluralizeCount('file', dbOps.length)} in the directory{' '}
|
||||
{dbOps.length === 1 ? 'has' : 'have'} changed. Do you want to update your workspace?
|
||||
</p>
|
||||
<Table scrollable className="my-4">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Type</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Operation</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{dbOps.map((op, i) => {
|
||||
let name: string;
|
||||
let label: string;
|
||||
let color: string;
|
||||
let model: string;
|
||||
|
||||
if (op.type === 'dbCreate') {
|
||||
label = 'create';
|
||||
name = resolvedModelNameWithFolders(op.fs.model);
|
||||
color = 'text-success';
|
||||
model = modelTypeLabel(op.fs.model);
|
||||
} else if (op.type === 'dbUpdate') {
|
||||
label = 'update';
|
||||
name = resolvedModelNameWithFolders(op.fs.model);
|
||||
color = 'text-info';
|
||||
model = modelTypeLabel(op.fs.model);
|
||||
} else if (op.type === 'dbDelete') {
|
||||
label = 'delete';
|
||||
name = resolvedModelNameWithFolders(op.model);
|
||||
color = 'text-danger';
|
||||
model = modelTypeLabel(op.model);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
<TableRow key={i}>
|
||||
<TableCell className="text-text-subtle">{model}</TableCell>
|
||||
<TruncatedWideTableCell>{name}</TruncatedWideTableCell>
|
||||
<TableCell className="text-right">
|
||||
<InlineCode className={color}>{label}</InlineCode>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<footer className="py-3 flex flex-row-reverse items-center gap-3">
|
||||
<Button type="submit" color="primary">
|
||||
Apply Changes
|
||||
</Button>
|
||||
<Button onClick={hide} color="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
51
apps/yaak-client/commands/createEnvironment.tsx
Normal file
51
apps/yaak-client/commands/createEnvironment.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Environment } from '@yaakapp-internal/models';
|
||||
import { CreateEnvironmentDialog } from '../components/CreateEnvironmentDialog';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
|
||||
|
||||
export const createSubEnvironmentAndActivate = createFastMutation<
|
||||
string | null,
|
||||
unknown,
|
||||
Environment | null
|
||||
>({
|
||||
mutationKey: ['create_environment'],
|
||||
mutationFn: async (baseEnvironment) => {
|
||||
if (baseEnvironment == null) {
|
||||
throw new Error('No base environment passed');
|
||||
}
|
||||
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) {
|
||||
throw new Error('Cannot create environment when no active workspace');
|
||||
}
|
||||
|
||||
return new Promise<string | null>((resolve) => {
|
||||
showDialog({
|
||||
id: 'new-environment',
|
||||
title: 'New Environment',
|
||||
description: 'Create multiple environments with different sets of variables',
|
||||
size: 'sm',
|
||||
onClose: () => resolve(null),
|
||||
render: ({ hide }) => (
|
||||
<CreateEnvironmentDialog
|
||||
workspaceId={workspaceId}
|
||||
hide={hide}
|
||||
onCreate={(id: string) => {
|
||||
resolve(id);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
});
|
||||
});
|
||||
},
|
||||
onSuccess: async (environmentId) => {
|
||||
if (environmentId == null) {
|
||||
return; // Was not created
|
||||
}
|
||||
|
||||
setWorkspaceSearchParams({ environment_id: environmentId });
|
||||
},
|
||||
});
|
||||
8
apps/yaak-client/commands/deleteWebsocketConnections.ts
Normal file
8
apps/yaak-client/commands/deleteWebsocketConnections.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { WebsocketRequest } from '@yaakapp-internal/models';
|
||||
import { deleteWebsocketConnections as cmdDeleteWebsocketConnections } from '@yaakapp-internal/ws';
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
|
||||
export const deleteWebsocketConnections = createFastMutation({
|
||||
mutationKey: ['delete_websocket_connections'],
|
||||
mutationFn: async (request: WebsocketRequest) => cmdDeleteWebsocketConnections(request.id),
|
||||
});
|
||||
35
apps/yaak-client/commands/moveToWorkspace.tsx
Normal file
35
apps/yaak-client/commands/moveToWorkspace.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||
|
||||
import { MoveToWorkspaceDialog } from '../components/MoveToWorkspaceDialog';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
import { pluralizeCount } from '../lib/pluralize';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
|
||||
export const moveToWorkspace = createFastMutation({
|
||||
mutationKey: ['move_workspace'],
|
||||
mutationFn: async (requests: (HttpRequest | GrpcRequest | WebsocketRequest)[]) => {
|
||||
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (activeWorkspaceId == null) return;
|
||||
if (requests.length === 0) return;
|
||||
|
||||
const title =
|
||||
requests.length === 1
|
||||
? 'Move Request'
|
||||
: `Move ${pluralizeCount('Request', requests.length)}`;
|
||||
|
||||
showDialog({
|
||||
id: 'change-workspace',
|
||||
title,
|
||||
size: 'sm',
|
||||
render: ({ hide }) => (
|
||||
<MoveToWorkspaceDialog
|
||||
onDone={hide}
|
||||
requests={requests}
|
||||
activeWorkspaceId={activeWorkspaceId}
|
||||
/>
|
||||
),
|
||||
});
|
||||
},
|
||||
});
|
||||
17
apps/yaak-client/commands/openFolderSettings.tsx
Normal file
17
apps/yaak-client/commands/openFolderSettings.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getModel } from '@yaakapp-internal/models';
|
||||
import type { FolderSettingsTab } from '../components/FolderSettingsDialog';
|
||||
import { FolderSettingsDialog } from '../components/FolderSettingsDialog';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
|
||||
export function openFolderSettings(folderId: string, tab?: FolderSettingsTab) {
|
||||
const folder = getModel('folder', folderId);
|
||||
if (folder == null) return;
|
||||
showDialog({
|
||||
id: 'folder-settings',
|
||||
title: null,
|
||||
size: 'lg',
|
||||
className: 'h-[50rem]',
|
||||
noPadding: true,
|
||||
render: () => <FolderSettingsDialog folderId={folderId} tab={tab} />,
|
||||
});
|
||||
}
|
||||
30
apps/yaak-client/commands/openSettings.tsx
Normal file
30
apps/yaak-client/commands/openSettings.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { SettingsTab } from '../components/Settings/Settings';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { router } from '../lib/router';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
|
||||
// Allow tab with optional subtab (e.g., "plugins:installed")
|
||||
type SettingsTabWithSubtab = SettingsTab | `${SettingsTab}:${string}` | null;
|
||||
|
||||
export const openSettings = createFastMutation<void, string, SettingsTabWithSubtab>({
|
||||
mutationKey: ['open_settings'],
|
||||
mutationFn: async (tab) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
|
||||
const location = router.buildLocation({
|
||||
to: '/workspaces/$workspaceId/settings',
|
||||
params: { workspaceId },
|
||||
search: { tab: (tab ?? undefined) as SettingsTab | undefined },
|
||||
});
|
||||
|
||||
await invokeCmd('cmd_new_child_window', {
|
||||
url: location.href,
|
||||
label: 'settings',
|
||||
title: 'Yaak Settings',
|
||||
innerSize: [750, 600],
|
||||
});
|
||||
},
|
||||
});
|
||||
27
apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx
Normal file
27
apps/yaak-client/commands/openWorkspaceFromSyncDir.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { applySync, calculateSyncFsOnly } from '@yaakapp-internal/sync';
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
import { showSimpleAlert } from '../lib/alert';
|
||||
import { router } from '../lib/router';
|
||||
|
||||
export const openWorkspaceFromSyncDir = createFastMutation<void, void, string>({
|
||||
mutationKey: [],
|
||||
mutationFn: async (dir) => {
|
||||
const ops = await calculateSyncFsOnly(dir);
|
||||
|
||||
const workspace = ops
|
||||
.map((o) => (o.type === 'dbCreate' && o.fs.model.type === 'workspace' ? o.fs.model : null))
|
||||
.filter((m) => m)[0];
|
||||
|
||||
if (workspace == null) {
|
||||
showSimpleAlert('Failed to Open', 'No workspace found in directory');
|
||||
return;
|
||||
}
|
||||
|
||||
await applySync(workspace.id, dir, ops);
|
||||
|
||||
router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId: workspace.id },
|
||||
});
|
||||
},
|
||||
});
|
||||
19
apps/yaak-client/commands/openWorkspaceSettings.tsx
Normal file
19
apps/yaak-client/commands/openWorkspaceSettings.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { WorkspaceSettingsTab } from '../components/WorkspaceSettingsDialog';
|
||||
import { WorkspaceSettingsDialog } from '../components/WorkspaceSettingsDialog';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
|
||||
export function openWorkspaceSettings(tab?: WorkspaceSettingsTab) {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (workspaceId == null) return;
|
||||
showDialog({
|
||||
id: 'workspace-settings',
|
||||
size: 'md',
|
||||
className: 'h-[calc(100vh-5rem)] !max-h-[40rem]',
|
||||
noPadding: true,
|
||||
render: ({ hide }) => (
|
||||
<WorkspaceSettingsDialog workspaceId={workspaceId} hide={hide} tab={tab} />
|
||||
),
|
||||
});
|
||||
}
|
||||
43
apps/yaak-client/commands/switchWorkspace.tsx
Normal file
43
apps/yaak-client/commands/switchWorkspace.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createFastMutation } from '../hooks/useFastMutation';
|
||||
import { getRecentCookieJars } from '../hooks/useRecentCookieJars';
|
||||
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
import { getRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { router } from '../lib/router';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
|
||||
export const switchWorkspace = createFastMutation<
|
||||
void,
|
||||
unknown,
|
||||
{
|
||||
workspaceId: string;
|
||||
inNewWindow: boolean;
|
||||
}
|
||||
>({
|
||||
mutationKey: ['open_workspace'],
|
||||
mutationFn: async ({ workspaceId, inNewWindow }) => {
|
||||
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? undefined;
|
||||
const requestId = (await getRecentRequests(workspaceId))[0] ?? undefined;
|
||||
const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? undefined;
|
||||
const search = {
|
||||
environment_id: environmentId,
|
||||
cookie_jar_id: cookieJarId,
|
||||
request_id: requestId,
|
||||
};
|
||||
|
||||
if (inNewWindow) {
|
||||
const location = router.buildLocation({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId },
|
||||
search,
|
||||
});
|
||||
await invokeCmd<void>('cmd_new_main_window', { url: location.href });
|
||||
return;
|
||||
}
|
||||
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId },
|
||||
search,
|
||||
});
|
||||
},
|
||||
});
|
||||
65
apps/yaak-client/components/BinaryFileEditor.tsx
Normal file
65
apps/yaak-client/components/BinaryFileEditor.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import mime from 'mime';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import { SelectFile } from './SelectFile';
|
||||
|
||||
type Props = {
|
||||
requestId: string;
|
||||
contentType: string | null;
|
||||
body: HttpRequest['body'];
|
||||
onChange: (body: HttpRequest['body']) => void;
|
||||
onChangeContentType: (contentType: string | null) => void;
|
||||
};
|
||||
|
||||
export function BinaryFileEditor({
|
||||
contentType,
|
||||
body,
|
||||
onChange,
|
||||
onChangeContentType,
|
||||
requestId,
|
||||
}: Props) {
|
||||
const ignoreContentType = useKeyValue<boolean>({
|
||||
namespace: 'global',
|
||||
key: ['ignore_content_type', requestId],
|
||||
fallback: false,
|
||||
});
|
||||
|
||||
const handleChange = async ({ filePath }: { filePath: string | null }) => {
|
||||
await ignoreContentType.set(false);
|
||||
onChange({ filePath: filePath ?? undefined });
|
||||
};
|
||||
|
||||
const filePath = typeof body.filePath === 'string' ? body.filePath : null;
|
||||
const mimeType = mime.getType(filePath ?? '') ?? 'application/octet-stream';
|
||||
|
||||
return (
|
||||
<VStack space={2}>
|
||||
<SelectFile onChange={handleChange} filePath={filePath} />
|
||||
{filePath != null && mimeType !== contentType && !ignoreContentType.value && (
|
||||
<Banner className="mt-3 !py-5">
|
||||
<div className="mb-4 text-center">
|
||||
<div>Set Content-Type header</div>
|
||||
<InlineCode>{mimeType}</InlineCode> for current request?
|
||||
</div>
|
||||
<HStack space={1.5} justifyContent="center">
|
||||
<Button size="sm" variant="border" onClick={() => ignoreContentType.set(true)}>
|
||||
Ignore
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
size="sm"
|
||||
onClick={() => onChangeContentType(mimeType)}
|
||||
>
|
||||
Set Header
|
||||
</Button>
|
||||
</HStack>
|
||||
</Banner>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
19
apps/yaak-client/components/CargoFeature.tsx
Normal file
19
apps/yaak-client/components/CargoFeature.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
feature: 'updater' | 'license';
|
||||
}
|
||||
|
||||
const featureMap: Record<Props['feature'], boolean> = {
|
||||
updater: appInfo.featureUpdater,
|
||||
license: appInfo.featureLicense,
|
||||
};
|
||||
|
||||
export function CargoFeature({ children, feature }: Props) {
|
||||
if (featureMap[feature]) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
161
apps/yaak-client/components/CloneGitRepositoryDialog.tsx
Normal file
161
apps/yaak-client/components/CloneGitRepositoryDialog.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { gitClone } from '@yaakapp-internal/git';
|
||||
import { useState } from 'react';
|
||||
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
import { showErrorToast } from '../lib/toast';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { promptCredentials } from './git/credentials';
|
||||
|
||||
interface Props {
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
// Detect path separator from an existing path (defaults to /)
|
||||
function getPathSeparator(path: string): string {
|
||||
return path.includes('\\') ? '\\' : '/';
|
||||
}
|
||||
|
||||
export function CloneGitRepositoryDialog({ hide }: Props) {
|
||||
const [url, setUrl] = useState<string>('');
|
||||
const [baseDirectory, setBaseDirectory] = useState<string>(appInfo.defaultProjectDir);
|
||||
const [directoryOverride, setDirectoryOverride] = useState<string | null>(null);
|
||||
const [hasSubdirectory, setHasSubdirectory] = useState(false);
|
||||
const [subdirectory, setSubdirectory] = useState<string>('');
|
||||
const [isCloning, setIsCloning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const repoName = extractRepoName(url);
|
||||
const sep = getPathSeparator(baseDirectory);
|
||||
const computedDirectory = repoName ? `${baseDirectory}${sep}${repoName}` : baseDirectory;
|
||||
const directory = directoryOverride ?? computedDirectory;
|
||||
const workspaceDirectory =
|
||||
hasSubdirectory && subdirectory ? `${directory}${sep}${subdirectory}` : directory;
|
||||
|
||||
const handleSelectDirectory = async () => {
|
||||
const dir = await open({
|
||||
title: 'Select Directory',
|
||||
directory: true,
|
||||
multiple: false,
|
||||
});
|
||||
if (dir != null) {
|
||||
setBaseDirectory(dir);
|
||||
setDirectoryOverride(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClone = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!url || !directory) return;
|
||||
|
||||
setIsCloning(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await gitClone(url, directory, promptCredentials);
|
||||
|
||||
if (result.type === 'needs_credentials') {
|
||||
setError(
|
||||
result.error ?? 'Authentication failed. Please check your credentials and try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Open the workspace from the cloned directory (or subdirectory)
|
||||
await openWorkspaceFromSyncDir.mutateAsync(workspaceDirectory);
|
||||
|
||||
hide();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
showErrorToast({
|
||||
id: 'git-clone-error',
|
||||
title: 'Clone Failed',
|
||||
message: String(err),
|
||||
});
|
||||
} finally {
|
||||
setIsCloning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack as="form" space={3} alignItems="start" className="pb-3" onSubmit={handleClone}>
|
||||
{error && (
|
||||
<Banner color="danger" className="w-full">
|
||||
{error}
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<PlainInput
|
||||
required
|
||||
label="Repository URL"
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
defaultValue={url}
|
||||
onChange={setUrl}
|
||||
/>
|
||||
|
||||
<PlainInput
|
||||
label="Directory"
|
||||
placeholder={appInfo.defaultProjectDir}
|
||||
defaultValue={directory}
|
||||
onChange={setDirectoryOverride}
|
||||
rightSlot={
|
||||
<IconButton
|
||||
size="xs"
|
||||
className="mr-0.5 !h-auto my-0.5"
|
||||
icon="folder"
|
||||
title="Browse"
|
||||
onClick={handleSelectDirectory}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
checked={hasSubdirectory}
|
||||
onChange={setHasSubdirectory}
|
||||
title="Workspace is in a subdirectory"
|
||||
help="Enable if the Yaak workspace files are not at the root of the repository"
|
||||
/>
|
||||
|
||||
{hasSubdirectory && (
|
||||
<PlainInput
|
||||
label="Subdirectory"
|
||||
placeholder="path/to/workspace"
|
||||
defaultValue={subdirectory}
|
||||
onChange={setSubdirectory}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
color="primary"
|
||||
className="w-full mt-3"
|
||||
disabled={!url || !directory || isCloning}
|
||||
isLoading={isCloning}
|
||||
>
|
||||
{isCloning ? 'Cloning...' : 'Clone Repository'}
|
||||
</Button>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function extractRepoName(url: string): string {
|
||||
// Handle various Git URL formats:
|
||||
// https://github.com/user/repo.git
|
||||
// git@github.com:user/repo.git
|
||||
// https://github.com/user/repo
|
||||
const match = url.match(/\/([^/]+?)(\.git)?$/);
|
||||
if (match?.[1]) {
|
||||
return match[1];
|
||||
}
|
||||
// Fallback for SSH-style URLs
|
||||
const sshMatch = url.match(/:([^/]+?)(\.git)?$/);
|
||||
if (sshMatch?.[1]) {
|
||||
return sshMatch[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
28
apps/yaak-client/components/ColorIndicator.tsx
Normal file
28
apps/yaak-client/components/ColorIndicator.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
interface Props {
|
||||
color: string | null;
|
||||
onClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColorIndicator({ color, onClick, className }: Props) {
|
||||
const style: CSSProperties = { backgroundColor: color ?? undefined };
|
||||
const finalClassName = classNames(
|
||||
className,
|
||||
'inline-block w-[0.75em] h-[0.75em] rounded-full mr-1.5 border border-transparent flex-shrink-0',
|
||||
);
|
||||
|
||||
if (onClick) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
className={classNames(finalClassName, 'hover:border-text')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <span style={style} className={finalClassName} />;
|
||||
}
|
||||
507
apps/yaak-client/components/CommandPaletteDialog.tsx
Normal file
507
apps/yaak-client/components/CommandPaletteDialog.tsx
Normal file
@@ -0,0 +1,507 @@
|
||||
import { workspacesAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { fuzzyFilter } from 'fuzzbunny';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import {
|
||||
Fragment,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { createFolder } from '../commands/commands';
|
||||
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment';
|
||||
import { openSettings } from '../commands/openSettings';
|
||||
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 { useCreateWorkspace } from '../hooks/useCreateWorkspace';
|
||||
import { useDebouncedState } from '../hooks/useDebouncedState';
|
||||
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
|
||||
import { useGrpcRequestActions } from '../hooks/useGrpcRequestActions';
|
||||
import type { HotkeyAction } from '../hooks/useHotKey';
|
||||
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
|
||||
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
||||
import { useScrollIntoView } from '../hooks/useScrollIntoView';
|
||||
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
import { copyToClipboard } from '../lib/copy';
|
||||
import { createRequestAndNavigate } from '../lib/createRequestAndNavigate';
|
||||
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { editEnvironment } from '../lib/editEnvironment';
|
||||
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
|
||||
import {
|
||||
resolvedModelNameWithFolders,
|
||||
resolvedModelNameWithFoldersArray,
|
||||
} from '../lib/resolvedModelName';
|
||||
import { router } from '../lib/router';
|
||||
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
|
||||
import { CookieDialog } from './CookieDialog';
|
||||
import { Button } from './core/Button';
|
||||
import { Heading } from './core/Heading';
|
||||
import { Hotkey } from './core/Hotkey';
|
||||
import { HttpMethodTag } from './core/HttpMethodTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
|
||||
interface CommandPaletteGroup {
|
||||
key: string;
|
||||
label: ReactNode;
|
||||
items: CommandPaletteItem[];
|
||||
}
|
||||
|
||||
type CommandPaletteItem = {
|
||||
key: string;
|
||||
onSelect: () => void;
|
||||
action?: HotkeyAction;
|
||||
} & ({ searchText: string; label: ReactNode } | { label: string });
|
||||
|
||||
const MAX_PER_GROUP = 8;
|
||||
|
||||
export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
const [command, setCommand] = useDebouncedState<string>('', 150);
|
||||
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
|
||||
const activeEnvironment = useActiveEnvironment();
|
||||
const httpRequestActions = useHttpRequestActions();
|
||||
const grpcRequestActions = useGrpcRequestActions();
|
||||
const workspaceId = useAtomValue(activeWorkspaceIdAtom);
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
const { baseEnvironment, subEnvironments } = useEnvironmentsBreakdown();
|
||||
const createWorkspace = useCreateWorkspace();
|
||||
const recentEnvironments = useRecentEnvironments();
|
||||
const recentWorkspaces = useRecentWorkspaces();
|
||||
const requests = useAllRequests();
|
||||
const activeRequest = useActiveRequest();
|
||||
const activeCookieJar = useActiveCookieJar();
|
||||
const [recentRequests] = useRecentRequests();
|
||||
const [, setSidebarHidden] = useSidebarHidden();
|
||||
const { mutate: sendRequest } = useSendAnyHttpRequest();
|
||||
|
||||
const handleSetCommand = (command: string) => {
|
||||
setCommand(command);
|
||||
setSelectedItemKey(null);
|
||||
};
|
||||
|
||||
const workspaceCommands = useMemo<CommandPaletteItem[]>(() => {
|
||||
if (workspaceId == null) return [];
|
||||
|
||||
const commands: CommandPaletteItem[] = [
|
||||
{
|
||||
key: 'settings.open',
|
||||
label: 'Open Settings',
|
||||
action: 'settings.show',
|
||||
onSelect: () => openSettings.mutate(null),
|
||||
},
|
||||
{
|
||||
key: 'app.create',
|
||||
label: 'Create Workspace',
|
||||
onSelect: createWorkspace,
|
||||
},
|
||||
{
|
||||
key: 'model.create',
|
||||
label: 'Create HTTP Request',
|
||||
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',
|
||||
label: 'Create Folder',
|
||||
onSelect: () => createFolder.mutate({}),
|
||||
},
|
||||
{
|
||||
key: 'cookies.show',
|
||||
label: 'Show Cookies',
|
||||
onSelect: async () => {
|
||||
showDialog({
|
||||
id: 'cookies',
|
||||
title: 'Manage Cookies',
|
||||
size: 'full',
|
||||
render: () => <CookieDialog cookieJarId={activeCookieJar?.id ?? null} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'environment.edit',
|
||||
label: 'Edit Environment',
|
||||
action: 'environment_editor.toggle',
|
||||
onSelect: () => editEnvironment(activeEnvironment),
|
||||
},
|
||||
{
|
||||
key: 'environment.create',
|
||||
label: 'Create Environment',
|
||||
onSelect: () => createSubEnvironmentAndActivate.mutate(baseEnvironment),
|
||||
},
|
||||
{
|
||||
key: 'sidebar.toggle',
|
||||
label: 'Toggle Sidebar',
|
||||
action: 'sidebar.focus',
|
||||
onSelect: () => setSidebarHidden((h) => !h),
|
||||
},
|
||||
];
|
||||
|
||||
if (activeRequest?.model === 'http_request') {
|
||||
commands.push({
|
||||
key: 'request.send',
|
||||
action: 'request.send',
|
||||
label: 'Send Request',
|
||||
onSelect: () => sendRequest(activeRequest.id),
|
||||
});
|
||||
if (appInfo.cliVersion != null) {
|
||||
commands.push({
|
||||
key: 'request.copy_cli_send',
|
||||
searchText: `copy cli send yaak request send ${activeRequest.id}`,
|
||||
label: 'Copy CLI Send Command',
|
||||
onSelect: () => copyToClipboard(`yaak request send ${activeRequest.id}`),
|
||||
});
|
||||
}
|
||||
httpRequestActions.forEach((a, i) => {
|
||||
commands.push({
|
||||
key: `http_request_action.${i}`,
|
||||
label: a.label,
|
||||
onSelect: () => a.call(activeRequest),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (activeRequest?.model === 'grpc_request') {
|
||||
grpcRequestActions.forEach((a, i) => {
|
||||
commands.push({
|
||||
key: `grpc_request_action.${i}`,
|
||||
label: a.label,
|
||||
onSelect: () => a.call(activeRequest),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (activeRequest != null) {
|
||||
commands.push({
|
||||
key: 'http_request.rename',
|
||||
label: 'Rename Request',
|
||||
onSelect: () => renameModelWithPrompt(activeRequest),
|
||||
});
|
||||
|
||||
commands.push({
|
||||
key: 'sidebar.selected.delete',
|
||||
label: 'Delete Request',
|
||||
onSelect: () => deleteModelWithConfirm(activeRequest),
|
||||
});
|
||||
}
|
||||
|
||||
return commands.sort((a, b) =>
|
||||
('searchText' in a ? a.searchText : a.label).localeCompare(
|
||||
'searchText' in b ? b.searchText : b.label,
|
||||
),
|
||||
);
|
||||
}, [
|
||||
activeCookieJar?.id,
|
||||
activeEnvironment,
|
||||
activeRequest,
|
||||
baseEnvironment,
|
||||
createWorkspace,
|
||||
grpcRequestActions,
|
||||
httpRequestActions,
|
||||
sendRequest,
|
||||
setSidebarHidden,
|
||||
workspaceId,
|
||||
]);
|
||||
|
||||
const sortedRequests = useMemo(() => {
|
||||
return [...requests].sort((a, b) => {
|
||||
const aRecentIndex = recentRequests.indexOf(a.id);
|
||||
const bRecentIndex = recentRequests.indexOf(b.id);
|
||||
|
||||
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
|
||||
return aRecentIndex - bRecentIndex;
|
||||
}
|
||||
if (aRecentIndex >= 0 && bRecentIndex === -1) {
|
||||
return -1;
|
||||
}
|
||||
if (aRecentIndex === -1 && bRecentIndex >= 0) {
|
||||
return 1;
|
||||
}
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
});
|
||||
}, [recentRequests, requests]);
|
||||
|
||||
const sortedEnvironments = useMemo(() => {
|
||||
return [...subEnvironments].sort((a, b) => {
|
||||
const aRecentIndex = recentEnvironments.indexOf(a.id);
|
||||
const bRecentIndex = recentEnvironments.indexOf(b.id);
|
||||
|
||||
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
|
||||
return aRecentIndex - bRecentIndex;
|
||||
}
|
||||
if (aRecentIndex >= 0 && bRecentIndex === -1) {
|
||||
return -1;
|
||||
}
|
||||
if (aRecentIndex === -1 && bRecentIndex >= 0) {
|
||||
return 1;
|
||||
}
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
});
|
||||
}, [subEnvironments, recentEnvironments]);
|
||||
|
||||
const sortedWorkspaces = useMemo(() => {
|
||||
if (recentWorkspaces == null) {
|
||||
// Should never happen
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
return [...workspaces].sort((a, b) => {
|
||||
const aRecentIndex = recentWorkspaces?.indexOf(a.id);
|
||||
const bRecentIndex = recentWorkspaces?.indexOf(b.id);
|
||||
|
||||
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
|
||||
return aRecentIndex - bRecentIndex;
|
||||
}
|
||||
if (aRecentIndex >= 0 && bRecentIndex === -1) {
|
||||
return -1;
|
||||
}
|
||||
if (aRecentIndex === -1 && bRecentIndex >= 0) {
|
||||
return 1;
|
||||
}
|
||||
return a.createdAt.localeCompare(b.createdAt);
|
||||
});
|
||||
}, [recentWorkspaces, workspaces]);
|
||||
|
||||
const groups = useMemo<CommandPaletteGroup[]>(() => {
|
||||
const actionsGroup: CommandPaletteGroup = {
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
items: workspaceCommands,
|
||||
};
|
||||
|
||||
const requestGroup: CommandPaletteGroup = {
|
||||
key: 'requests',
|
||||
label: 'Switch Request',
|
||||
items: [],
|
||||
};
|
||||
|
||||
for (const r of sortedRequests) {
|
||||
requestGroup.items.push({
|
||||
key: `switch-request-${r.id}`,
|
||||
searchText: resolvedModelNameWithFolders(r),
|
||||
label: (
|
||||
<div className="flex items-center gap-x-0.5">
|
||||
<HttpMethodTag short className="text-xs mr-2" request={r} />
|
||||
{resolvedModelNameWithFoldersArray(r).map((name, i, all) => (
|
||||
<Fragment key={name}>
|
||||
{i !== 0 && <Icon icon="chevron_right" className="opacity-80" />}
|
||||
<div className={classNames(i < all.length - 1 && 'truncate')}>{name}</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
onSelect: async () => {
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId: r.workspaceId },
|
||||
search: (prev) => ({ ...prev, request_id: r.id }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const environmentGroup: CommandPaletteGroup = {
|
||||
key: 'environments',
|
||||
label: 'Switch Environment',
|
||||
items: [],
|
||||
};
|
||||
|
||||
for (const e of sortedEnvironments) {
|
||||
if (e.id === activeEnvironment?.id) {
|
||||
continue;
|
||||
}
|
||||
environmentGroup.items.push({
|
||||
key: `switch-environment-${e.id}`,
|
||||
label: e.name,
|
||||
onSelect: () => setWorkspaceSearchParams({ environment_id: e.id }),
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceGroup: CommandPaletteGroup = {
|
||||
key: 'workspaces',
|
||||
label: 'Switch Workspace',
|
||||
items: [],
|
||||
};
|
||||
|
||||
for (const w of sortedWorkspaces) {
|
||||
workspaceGroup.items.push({
|
||||
key: `switch-workspace-${w.id}`,
|
||||
label: w.name,
|
||||
onSelect: () => switchWorkspace.mutate({ workspaceId: w.id, inNewWindow: false }),
|
||||
});
|
||||
}
|
||||
|
||||
return [actionsGroup, requestGroup, environmentGroup, workspaceGroup];
|
||||
}, [
|
||||
workspaceCommands,
|
||||
sortedRequests,
|
||||
sortedEnvironments,
|
||||
activeEnvironment?.id,
|
||||
sortedWorkspaces,
|
||||
]);
|
||||
|
||||
const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);
|
||||
|
||||
const { filteredGroups, filteredAllItems } = useMemo(() => {
|
||||
const result = command
|
||||
? fuzzyFilter(
|
||||
allItems.map((i) => ({
|
||||
...i,
|
||||
filterBy: 'searchText' in i ? i.searchText : i.label,
|
||||
})),
|
||||
command,
|
||||
{ fields: ['filterBy'] },
|
||||
)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map((v) => v.item)
|
||||
: allItems;
|
||||
|
||||
const filteredGroups = groups
|
||||
.map((g) => {
|
||||
const items = result
|
||||
.filter((i) => g.items.find((i2) => i2.key === i.key))
|
||||
.slice(0, MAX_PER_GROUP);
|
||||
return { ...g, items };
|
||||
})
|
||||
.filter((g) => g.items.length > 0);
|
||||
|
||||
const filteredAllItems = filteredGroups.flatMap((g) => g.items);
|
||||
return { filteredAllItems, filteredGroups };
|
||||
}, [allItems, command, groups]);
|
||||
|
||||
const handleSelectAndClose = useCallback(
|
||||
(cb: () => void) => {
|
||||
onClose();
|
||||
cb();
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const selectedItem = useMemo(() => {
|
||||
let selectedItem = filteredAllItems.find((i) => i.key === selectedItemKey) ?? null;
|
||||
if (selectedItem == null) {
|
||||
selectedItem = filteredAllItems[0] ?? null;
|
||||
}
|
||||
return selectedItem;
|
||||
}, [filteredAllItems, selectedItemKey]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
const index = filteredAllItems.findIndex((v) => v.key === selectedItem?.key);
|
||||
if (e.key === 'ArrowDown' || (e.ctrlKey && e.key === 'n')) {
|
||||
const next = filteredAllItems[index + 1] ?? filteredAllItems[0];
|
||||
setSelectedItemKey(next?.key ?? null);
|
||||
} else if (e.key === 'ArrowUp' || (e.ctrlKey && e.key === 'k')) {
|
||||
const prev = filteredAllItems[index - 1] ?? filteredAllItems[filteredAllItems.length - 1];
|
||||
setSelectedItemKey(prev?.key ?? null);
|
||||
} else if (e.key === 'Enter') {
|
||||
const selected = filteredAllItems[index];
|
||||
setSelectedItemKey(selected?.key ?? null);
|
||||
if (selected) {
|
||||
handleSelectAndClose(selected.onSelect);
|
||||
}
|
||||
}
|
||||
},
|
||||
[filteredAllItems, handleSelectAndClose, selectedItem?.key],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-[min(700px,80vw)] grid grid-rows-[auto_minmax(0,1fr)] overflow-hidden py-2">
|
||||
<div className="px-2 w-full">
|
||||
<PlainInput
|
||||
autoFocus
|
||||
hideLabel
|
||||
leftSlot={
|
||||
<div className="h-md w-10 flex justify-center items-center">
|
||||
<Icon icon="search" color="secondary" />
|
||||
</div>
|
||||
}
|
||||
name="command"
|
||||
label="Command"
|
||||
placeholder="Search or type a command"
|
||||
className="font-sans !text-base"
|
||||
defaultValue={command}
|
||||
onChange={handleSetCommand}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
/>
|
||||
</div>
|
||||
<div className="h-full px-1.5 overflow-y-auto pt-2 pb-1">
|
||||
{filteredGroups.map((g) => (
|
||||
<div key={g.key} className="mb-1.5 w-full">
|
||||
<Heading level={2} className="!text-xs uppercase px-1.5 h-sm flex items-center">
|
||||
{g.label}
|
||||
</Heading>
|
||||
{g.items.map((v) => (
|
||||
<CommandPaletteItem
|
||||
active={v.key === selectedItem?.key}
|
||||
key={v.key}
|
||||
onClick={() => handleSelectAndClose(v.onSelect)}
|
||||
rightSlot={v.action && <CommandPaletteAction action={v.action} />}
|
||||
>
|
||||
{v.label}
|
||||
</CommandPaletteItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandPaletteItem({
|
||||
children,
|
||||
active,
|
||||
onClick,
|
||||
rightSlot,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
rightSlot?: ReactNode;
|
||||
}) {
|
||||
const ref = useRef<HTMLButtonElement | null>(null);
|
||||
useScrollIntoView(ref.current, active);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
tabIndex={active ? undefined : -1}
|
||||
rightSlot={rightSlot}
|
||||
color="custom"
|
||||
justify="start"
|
||||
className={classNames(
|
||||
'w-full h-sm flex items-center rounded px-1.5',
|
||||
'hover:text-text',
|
||||
active && 'bg-surface-highlight',
|
||||
!active && 'text-text-subtle',
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{children}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandPaletteAction({ action }: { action: HotkeyAction }) {
|
||||
return <Hotkey className="ml-auto" action={action} />;
|
||||
}
|
||||
76
apps/yaak-client/components/ConfirmLargeRequestBody.tsx
Normal file
76
apps/yaak-client/components/ConfirmLargeRequestBody.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useToggle } from '../hooks/useToggle';
|
||||
import { showConfirm } from '../lib/confirm';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { Link } from './core/Link';
|
||||
import { SizeTag } from './core/SizeTag';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
request: HttpRequest;
|
||||
}
|
||||
|
||||
const LARGE_TEXT_BYTES = 2 * 1000 * 1000;
|
||||
|
||||
export function ConfirmLargeRequestBody({ children, request }: Props) {
|
||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
||||
|
||||
if (request.body?.text == null) {
|
||||
return children;
|
||||
}
|
||||
|
||||
const contentLength = request.body.text.length ?? 0;
|
||||
const tooLargeBytes = LARGE_TEXT_BYTES;
|
||||
const isLarge = contentLength > tooLargeBytes;
|
||||
if (!showLargeResponse && isLarge) {
|
||||
return (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Rendering content over{' '}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={tooLargeBytes} />
|
||||
</InlineCode>{' '}
|
||||
may impact performance.
|
||||
</p>
|
||||
<p>
|
||||
See{' '}
|
||||
<Link href="https://feedback.yaak.app/en/help/articles/1198684-working-with-large-values">
|
||||
Working With Large Values
|
||||
</Link>{' '}
|
||||
for tips.
|
||||
</p>
|
||||
<HStack wrap space={2}>
|
||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
||||
Reveal Body
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
size="xs"
|
||||
variant="border"
|
||||
onClick={async () => {
|
||||
const confirm = await showConfirm({
|
||||
id: `delete-body-${request.id}`,
|
||||
confirmText: 'Delete Body',
|
||||
title: 'Delete Body Text',
|
||||
description: 'Are you sure you want to delete the request body text?',
|
||||
color: 'danger',
|
||||
});
|
||||
if (confirm) {
|
||||
await patchModel(request, { body: { ...request.body, text: '' } });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Delete Body
|
||||
</Button>
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
63
apps/yaak-client/components/ConfirmLargeResponse.tsx
Normal file
63
apps/yaak-client/components/ConfirmLargeResponse.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { useSaveResponse } from '../hooks/useSaveResponse';
|
||||
import { useToggle } from '../hooks/useToggle';
|
||||
import { isProbablyTextContentType } from '../lib/contentType';
|
||||
import { getContentTypeFromHeaders } from '../lib/model_util';
|
||||
import { getResponseBodyText } from '../lib/responseBody';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { SizeTag } from './core/SizeTag';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
const LARGE_BYTES = 2 * 1000 * 1000;
|
||||
|
||||
export function ConfirmLargeResponse({ children, response }: Props) {
|
||||
const { mutate: saveResponse } = useSaveResponse(response);
|
||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
||||
const isProbablyText = useMemo(() => {
|
||||
const contentType = getContentTypeFromHeaders(response.headers);
|
||||
return isProbablyTextContentType(contentType);
|
||||
}, [response.headers]);
|
||||
|
||||
const contentLength = response.contentLength ?? 0;
|
||||
const isLarge = contentLength > LARGE_BYTES;
|
||||
if (!showLargeResponse && isLarge) {
|
||||
return (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Showing responses over{' '}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={LARGE_BYTES} />
|
||||
</InlineCode>{' '}
|
||||
may impact performance
|
||||
</p>
|
||||
<HStack wrap space={2}>
|
||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
||||
Reveal Response
|
||||
</Button>
|
||||
<Button color="secondary" variant="border" size="xs" onClick={() => saveResponse()}>
|
||||
Save to File
|
||||
</Button>
|
||||
{isProbablyText && (
|
||||
<CopyButton
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
text={() => getResponseBodyText({ response, filter: null })}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
58
apps/yaak-client/components/ConfirmLargeResponseRequest.tsx
Normal file
58
apps/yaak-client/components/ConfirmLargeResponseRequest.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { type ReactNode, useMemo } from 'react';
|
||||
import { getRequestBodyText as getHttpResponseRequestBodyText } from '../hooks/useHttpRequestBody';
|
||||
import { useToggle } from '../hooks/useToggle';
|
||||
import { isProbablyTextContentType } from '../lib/contentType';
|
||||
import { getContentTypeFromHeaders } from '../lib/model_util';
|
||||
import { CopyButton } from './CopyButton';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { SizeTag } from './core/SizeTag';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
const LARGE_BYTES = 2 * 1000 * 1000;
|
||||
|
||||
export function ConfirmLargeResponseRequest({ children, response }: Props) {
|
||||
const [showLargeResponse, toggleShowLargeResponse] = useToggle();
|
||||
const isProbablyText = useMemo(() => {
|
||||
const contentType = getContentTypeFromHeaders(response.headers);
|
||||
return isProbablyTextContentType(contentType);
|
||||
}, [response.headers]);
|
||||
|
||||
const contentLength = response.requestContentLength ?? 0;
|
||||
const isLarge = contentLength > LARGE_BYTES;
|
||||
if (!showLargeResponse && isLarge) {
|
||||
return (
|
||||
<Banner color="primary" className="flex flex-col gap-3">
|
||||
<p>
|
||||
Showing content over{' '}
|
||||
<InlineCode>
|
||||
<SizeTag contentLength={LARGE_BYTES} />
|
||||
</InlineCode>{' '}
|
||||
may impact performance
|
||||
</p>
|
||||
<HStack wrap space={2}>
|
||||
<Button color="primary" size="xs" onClick={toggleShowLargeResponse}>
|
||||
Reveal Request Body
|
||||
</Button>
|
||||
{isProbablyText && (
|
||||
<CopyButton
|
||||
color="secondary"
|
||||
variant="border"
|
||||
size="xs"
|
||||
text={() => getHttpResponseRequestBodyText(response).then((d) => d?.bodyText ?? '')}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
68
apps/yaak-client/components/CookieDialog.tsx
Normal file
68
apps/yaak-client/components/CookieDialog.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Cookie } from '@yaakapp-internal/models';
|
||||
import { cookieJarsAtom, patchModel } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { cookieDomain } from '../lib/model_util';
|
||||
import { Banner } from './core/Banner';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
|
||||
interface Props {
|
||||
cookieJarId: string | null;
|
||||
}
|
||||
|
||||
export const CookieDialog = ({ cookieJarId }: Props) => {
|
||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
||||
const cookieJar = cookieJars?.find((c) => c.id === cookieJarId);
|
||||
|
||||
if (cookieJar == null) {
|
||||
return <div>No cookie jar selected</div>;
|
||||
}
|
||||
|
||||
if (cookieJar.cookies.length === 0) {
|
||||
return (
|
||||
<Banner>
|
||||
Cookies will appear when a response contains the <InlineCode>Set-Cookie</InlineCode> header
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-2">
|
||||
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="py-2 text-left">Domain</th>
|
||||
<th className="py-2 text-left pl-4">Cookie</th>
|
||||
<th className="py-2 pl-4" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{cookieJar?.cookies.map((c: Cookie) => (
|
||||
<tr key={JSON.stringify(c)}>
|
||||
<td className="py-2 select-text cursor-text font-mono font-semibold max-w-0">
|
||||
{cookieDomain(c)}
|
||||
</td>
|
||||
<td className="py-2 pl-4 select-text cursor-text font-mono text-text-subtle whitespace-nowrap overflow-x-auto max-w-[200px] hide-scrollbars">
|
||||
{c.raw_cookie}
|
||||
</td>
|
||||
<td className="max-w-0 w-10">
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
title="Delete"
|
||||
className="ml-auto"
|
||||
onClick={() =>
|
||||
patchModel(cookieJar, {
|
||||
cookies: cookieJar.cookies.filter((c2: Cookie) => c2 !== c),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
99
apps/yaak-client/components/CookieDropdown.tsx
Normal file
99
apps/yaak-client/components/CookieDropdown.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { cookieJarsAtom, patchModel } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
|
||||
import { useCreateCookieJar } from '../hooks/useCreateCookieJar';
|
||||
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { showPrompt } from '../lib/prompt';
|
||||
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
|
||||
import { CookieDialog } from './CookieDialog';
|
||||
import { Dropdown, type DropdownItem } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
|
||||
export const CookieDropdown = memo(function CookieDropdown() {
|
||||
const activeCookieJar = useActiveCookieJar();
|
||||
const createCookieJar = useCreateCookieJar();
|
||||
const cookieJars = useAtomValue(cookieJarsAtom);
|
||||
|
||||
const items = useMemo((): DropdownItem[] => {
|
||||
return [
|
||||
...(cookieJars ?? []).map((j) => ({
|
||||
key: j.id,
|
||||
label: j.name,
|
||||
leftSlot: <Icon icon={j.id === activeCookieJar?.id ? 'check' : 'empty'} />,
|
||||
onSelect: () => {
|
||||
setWorkspaceSearchParams({ cookie_jar_id: j.id });
|
||||
},
|
||||
})),
|
||||
...(((cookieJars ?? []).length > 0 && activeCookieJar != null
|
||||
? [
|
||||
{ type: 'separator', label: activeCookieJar.name },
|
||||
{
|
||||
key: 'manage',
|
||||
label: 'Manage Cookies',
|
||||
leftSlot: <Icon icon="cookie" />,
|
||||
onSelect: () => {
|
||||
if (activeCookieJar == null) return;
|
||||
showDialog({
|
||||
id: 'cookies',
|
||||
title: 'Manage Cookies',
|
||||
size: 'full',
|
||||
render: () => <CookieDialog cookieJarId={activeCookieJar.id} />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'rename',
|
||||
label: 'Rename',
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
onSelect: async () => {
|
||||
const name = await showPrompt({
|
||||
id: 'rename-cookie-jar',
|
||||
title: 'Rename Cookie Jar',
|
||||
description: (
|
||||
<>
|
||||
Enter a new name for <InlineCode>{activeCookieJar?.name}</InlineCode>
|
||||
</>
|
||||
),
|
||||
label: 'Name',
|
||||
confirmText: 'Save',
|
||||
placeholder: 'New name',
|
||||
defaultValue: activeCookieJar?.name,
|
||||
});
|
||||
if (name == null) return;
|
||||
await patchModel(activeCookieJar, { name });
|
||||
},
|
||||
},
|
||||
...(((cookieJars ?? []).length > 1 // Never delete the last one
|
||||
? [
|
||||
{
|
||||
label: 'Delete',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
color: 'danger',
|
||||
onSelect: async () => {
|
||||
await deleteModelWithConfirm(activeCookieJar);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []) as DropdownItem[]),
|
||||
]
|
||||
: []) as DropdownItem[]),
|
||||
{ type: 'separator' },
|
||||
{
|
||||
key: 'create-cookie-jar',
|
||||
label: 'New Cookie Jar',
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: () => createCookieJar.mutate(),
|
||||
},
|
||||
];
|
||||
}, [activeCookieJar, cookieJars, createCookieJar]);
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<IconButton size="sm" icon="cookie" iconColor="secondary" title="Cookie Jar" />
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
33
apps/yaak-client/components/CopyButton.tsx
Normal file
33
apps/yaak-client/components/CopyButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useTimedBoolean } from '../hooks/useTimedBoolean';
|
||||
import { copyToClipboard } from '../lib/copy';
|
||||
import { showToast } from '../lib/toast';
|
||||
import type { ButtonProps } from './core/Button';
|
||||
import { Button } from './core/Button';
|
||||
|
||||
interface Props extends Omit<ButtonProps, 'onClick'> {
|
||||
text: string | (() => Promise<string | null>);
|
||||
}
|
||||
|
||||
export function CopyButton({ text, ...props }: Props) {
|
||||
const [copied, setCopied] = useTimedBoolean();
|
||||
return (
|
||||
<Button
|
||||
{...props}
|
||||
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 {
|
||||
copyToClipboard(content, { disableToast: true });
|
||||
setCopied();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
33
apps/yaak-client/components/CopyIconButton.tsx
Normal file
33
apps/yaak-client/components/CopyIconButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useTimedBoolean } from '../hooks/useTimedBoolean';
|
||||
import { copyToClipboard } from '../lib/copy';
|
||||
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 [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 {
|
||||
copyToClipboard(content, { disableToast: true });
|
||||
setCopied();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
21
apps/yaak-client/components/CreateDropdown.tsx
Normal file
21
apps/yaak-client/components/CreateDropdown.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useCreateDropdownItems } from '../hooks/useCreateDropdownItems';
|
||||
import type { DropdownProps } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
|
||||
interface Props extends Omit<DropdownProps, 'items'> {
|
||||
hideFolder?: boolean;
|
||||
}
|
||||
|
||||
export function CreateDropdown({ hideFolder, children, ...props }: Props) {
|
||||
const getItems = useCreateDropdownItems({
|
||||
hideFolder,
|
||||
hideIcons: true,
|
||||
folderId: 'active-folder',
|
||||
});
|
||||
|
||||
return (
|
||||
<Dropdown items={getItems} {...props}>
|
||||
{children}
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
68
apps/yaak-client/components/CreateEnvironmentDialog.tsx
Normal file
68
apps/yaak-client/components/CreateEnvironmentDialog.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { createWorkspaceModel } from '@yaakapp-internal/models';
|
||||
import { useState } from 'react';
|
||||
import { useToggle } from '../hooks/useToggle';
|
||||
import { ColorIndicator } from './ColorIndicator';
|
||||
import { Button } from './core/Button';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { ColorPickerWithThemeColors } from './core/ColorPicker';
|
||||
import { Label } from './core/Label';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
|
||||
interface Props {
|
||||
onCreate: (id: string) => void;
|
||||
hide: () => void;
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export function CreateEnvironmentDialog({ workspaceId, hide, onCreate }: Props) {
|
||||
const [name, setName] = useState<string>('');
|
||||
const [color, setColor] = useState<string | null>(null);
|
||||
const [sharable, toggleSharable] = useToggle(false);
|
||||
return (
|
||||
<form
|
||||
className="pb-3 flex flex-col gap-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const id = await createWorkspaceModel({
|
||||
model: 'environment',
|
||||
name,
|
||||
color,
|
||||
variables: [],
|
||||
public: sharable,
|
||||
workspaceId,
|
||||
parentModel: 'environment',
|
||||
});
|
||||
hide();
|
||||
onCreate(id);
|
||||
}}
|
||||
>
|
||||
<PlainInput
|
||||
label="Name"
|
||||
required
|
||||
defaultValue={name}
|
||||
onChange={setName}
|
||||
placeholder="Production"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={sharable}
|
||||
title="Share this environment"
|
||||
help="Sharable environments are included in data export and directory sync."
|
||||
onChange={toggleSharable}
|
||||
/>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor="color"
|
||||
className="mb-1.5"
|
||||
help="Select a color to be displayed when this environment is active, to help identify it."
|
||||
>
|
||||
Color
|
||||
</Label>
|
||||
<ColorPickerWithThemeColors onChange={setColor} color={color} />
|
||||
</div>
|
||||
<Button type="submit" color="secondary" className="mt-3">
|
||||
{color != null && <ColorIndicator color={color} />}
|
||||
Create Environment
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
97
apps/yaak-client/components/CreateWorkspaceDialog.tsx
Normal file
97
apps/yaak-client/components/CreateWorkspaceDialog.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { gitMutations } from '@yaakapp-internal/git';
|
||||
import type { WorkspaceMeta } 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 { gitCallbacks } from './git/callbacks';
|
||||
import { SyncToFilesystemSetting } from './SyncToFilesystemSetting';
|
||||
|
||||
interface Props {
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
export function CreateWorkspaceDialog({ hide }: Props) {
|
||||
const [name, setName] = useState<string>('');
|
||||
const [syncConfig, setSyncConfig] = useState<{
|
||||
filePath: string | null;
|
||||
initGit?: boolean;
|
||||
}>({ filePath: null, initGit: false });
|
||||
const [setupEncryption, setSetupEncryption] = useState<boolean>(false);
|
||||
return (
|
||||
<VStack
|
||||
as="form"
|
||||
space={3}
|
||||
alignItems="start"
|
||||
className="pb-3"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const workspaceId = await createGlobalModel({ model: 'workspace', name });
|
||||
if (workspaceId == null) return;
|
||||
|
||||
// Do getWorkspaceMeta instead of naively creating one because it might have
|
||||
// been created already when the store refreshes the workspace meta after
|
||||
const workspaceMeta = await invokeCmd<WorkspaceMeta>('cmd_get_workspace_meta', {
|
||||
workspaceId,
|
||||
});
|
||||
await updateModel({
|
||||
...workspaceMeta,
|
||||
settingSyncDir: syncConfig.filePath,
|
||||
});
|
||||
|
||||
if (syncConfig.initGit && syncConfig.filePath) {
|
||||
gitMutations(syncConfig.filePath, gitCallbacks(syncConfig.filePath))
|
||||
.init.mutateAsync()
|
||||
.catch((err) => {
|
||||
showErrorToast({
|
||||
id: 'git-init-error',
|
||||
title: 'Error initializing Git',
|
||||
message: String(err),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Navigate to workspace
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId },
|
||||
});
|
||||
|
||||
hide();
|
||||
|
||||
if (setupEncryption) {
|
||||
setupOrConfigureEncryption();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PlainInput required label="Name" defaultValue={name} onChange={setName} />
|
||||
|
||||
<SyncToFilesystemSetting
|
||||
onChange={setSyncConfig}
|
||||
onCreateNewWorkspace={hide}
|
||||
value={syncConfig}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
41
apps/yaak-client/components/Dialogs.tsx
Normal file
41
apps/yaak-client/components/Dialogs.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { ComponentType } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
import { dialogsAtom, hideDialog } from '../lib/dialog';
|
||||
import { Dialog, type DialogProps } from './core/Dialog';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
|
||||
export type DialogInstance = {
|
||||
id: string;
|
||||
render: ComponentType<{ hide: () => void }>;
|
||||
} & Omit<DialogProps, 'open' | 'children'>;
|
||||
|
||||
export function Dialogs() {
|
||||
const dialogs = useAtomValue(dialogsAtom);
|
||||
return (
|
||||
<>
|
||||
{dialogs.map(({ id, ...props }) => (
|
||||
<DialogInstance key={id} id={id} {...props} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogInstance({ render: Component, onClose, id, ...props }: DialogInstance) {
|
||||
const hide = useCallback(() => {
|
||||
hideDialog(id);
|
||||
}, [id]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
onClose?.();
|
||||
hideDialog(id);
|
||||
}, [id, onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open onClose={handleClose} {...props}>
|
||||
<ErrorBoundary name={`Dialog ${id}`}>
|
||||
<Component hide={hide} {...props} />
|
||||
</ErrorBoundary>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
181
apps/yaak-client/components/DnsOverridesEditor.tsx
Normal file
181
apps/yaak-client/components/DnsOverridesEditor.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import type { DnsOverride, Workspace } from '@yaakapp-internal/models';
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import { useCallback, useId, useMemo } from 'react';
|
||||
import { Button } from './core/Button';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from './core/Table';
|
||||
|
||||
interface Props {
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
interface DnsOverrideWithId extends DnsOverride {
|
||||
_id: string;
|
||||
}
|
||||
|
||||
export function DnsOverridesEditor({ workspace }: Props) {
|
||||
const reactId = useId();
|
||||
|
||||
// Ensure each override has an internal ID for React keys
|
||||
const overridesWithIds = useMemo<DnsOverrideWithId[]>(() => {
|
||||
return workspace.settingDnsOverrides.map((override, index) => ({
|
||||
...override,
|
||||
_id: `${reactId}-${index}`,
|
||||
}));
|
||||
}, [workspace.settingDnsOverrides, reactId]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(overrides: DnsOverride[]) => {
|
||||
patchModel(workspace, { settingDnsOverrides: overrides });
|
||||
},
|
||||
[workspace],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
const newOverride: DnsOverride = {
|
||||
hostname: '',
|
||||
ipv4: [''],
|
||||
ipv6: [],
|
||||
enabled: true,
|
||||
};
|
||||
handleChange([...workspace.settingDnsOverrides, newOverride]);
|
||||
}, [workspace.settingDnsOverrides, handleChange]);
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
(index: number, update: Partial<DnsOverride>) => {
|
||||
const updated = workspace.settingDnsOverrides.map((o, i) =>
|
||||
i === index ? { ...o, ...update } : o,
|
||||
);
|
||||
handleChange(updated);
|
||||
},
|
||||
[workspace.settingDnsOverrides, handleChange],
|
||||
);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(index: number) => {
|
||||
const updated = workspace.settingDnsOverrides.filter((_, i) => i !== index);
|
||||
handleChange(updated);
|
||||
},
|
||||
[workspace.settingDnsOverrides, handleChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack space={3} className="pb-3">
|
||||
<div className="text-text-subtle text-sm">
|
||||
Override DNS resolution for specific hostnames. This works like{' '}
|
||||
<code className="text-text-subtlest bg-surface-highlight px-1 rounded">/etc/hosts</code>{' '}
|
||||
but only for requests made from this workspace.
|
||||
</div>
|
||||
|
||||
{overridesWithIds.length > 0 && (
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-8" />
|
||||
<TableHeaderCell>Hostname</TableHeaderCell>
|
||||
<TableHeaderCell>IPv4 Address</TableHeaderCell>
|
||||
<TableHeaderCell>IPv6 Address</TableHeaderCell>
|
||||
<TableHeaderCell className="w-10" />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{overridesWithIds.map((override, index) => (
|
||||
<DnsOverrideRow
|
||||
key={override._id}
|
||||
override={override}
|
||||
onUpdate={(update) => handleUpdate(index, update)}
|
||||
onDelete={() => handleDelete(index)}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
<HStack>
|
||||
<Button size="xs" color="secondary" variant="border" onClick={handleAdd}>
|
||||
Add DNS Override
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
interface DnsOverrideRowProps {
|
||||
override: DnsOverride;
|
||||
onUpdate: (update: Partial<DnsOverride>) => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
function DnsOverrideRow({ override, onUpdate, onDelete }: DnsOverrideRowProps) {
|
||||
const ipv4Value = override.ipv4.join(', ');
|
||||
const ipv6Value = override.ipv6.join(', ');
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title={override.enabled ? 'Disable override' : 'Enable override'}
|
||||
checked={override.enabled ?? true}
|
||||
onChange={(enabled) => onUpdate({ enabled })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
hideLabel
|
||||
label="Hostname"
|
||||
placeholder="api.example.com"
|
||||
defaultValue={override.hostname}
|
||||
onChange={(hostname) => onUpdate({ hostname })}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
hideLabel
|
||||
label="IPv4 addresses"
|
||||
placeholder="127.0.0.1"
|
||||
defaultValue={ipv4Value}
|
||||
onChange={(value) =>
|
||||
onUpdate({
|
||||
ipv4: value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
hideLabel
|
||||
label="IPv6 addresses"
|
||||
placeholder="::1"
|
||||
defaultValue={ipv6Value}
|
||||
onChange={(value) =>
|
||||
onUpdate({
|
||||
ipv6: value
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
size="xs"
|
||||
iconSize="sm"
|
||||
icon="trash"
|
||||
title="Delete override"
|
||||
onClick={onDelete}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
34
apps/yaak-client/components/DropMarker.tsx
Normal file
34
apps/yaak-client/components/DropMarker.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
orientation?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
export const DropMarker = memo(
|
||||
function DropMarker({ className, style, orientation = 'horizontal' }: Props) {
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
'absolute pointer-events-none z-50',
|
||||
orientation === 'horizontal' && 'w-full',
|
||||
orientation === 'vertical' && 'w-0 top-0 bottom-0',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'absolute bg-primary rounded-full',
|
||||
orientation === 'horizontal' && 'left-2 right-2 -bottom-[0.1rem] h-[0.2rem]',
|
||||
orientation === 'vertical' && '-left-[0.1rem] top-0 bottom-0 w-[0.2rem]',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
631
apps/yaak-client/components/DynamicForm.tsx
Normal file
631
apps/yaak-client/components/DynamicForm.tsx
Normal file
@@ -0,0 +1,631 @@
|
||||
import type { Folder, HttpRequest } from '@yaakapp-internal/models';
|
||||
import { foldersAtom, httpRequestsAtom } from '@yaakapp-internal/models';
|
||||
import type {
|
||||
FormInput,
|
||||
FormInputCheckbox,
|
||||
FormInputEditor,
|
||||
FormInputFile,
|
||||
FormInputHttpRequest,
|
||||
FormInputKeyValue,
|
||||
FormInputSelect,
|
||||
FormInputText,
|
||||
JsonPrimitive,
|
||||
} from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useRandomKey } from '../hooks/useRandomKey';
|
||||
import { capitalize } from '../lib/capitalize';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { DetailsBanner } from './core/DetailsBanner';
|
||||
import { Editor } from './core/Editor/LazyEditor';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import type { InputProps } from './core/Input';
|
||||
import { Input } from './core/Input';
|
||||
import { Label } from './core/Label';
|
||||
import type { Pair } from './core/PairEditor';
|
||||
import { PairEditor } from './core/PairEditor';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import { Select } from './core/Select';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { Markdown } from './Markdown';
|
||||
import { SelectFile } from './SelectFile';
|
||||
|
||||
export const DYNAMIC_FORM_NULL_ARG = '__NULL__';
|
||||
const INPUT_SIZE = 'sm';
|
||||
|
||||
interface Props<T> {
|
||||
inputs: FormInput[] | undefined | null;
|
||||
onChange: (value: T) => void;
|
||||
data: T;
|
||||
autocompleteFunctions?: boolean;
|
||||
autocompleteVariables?: boolean;
|
||||
stateKey: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DynamicForm<T extends Record<string, JsonPrimitive>>({
|
||||
inputs,
|
||||
data,
|
||||
onChange,
|
||||
autocompleteVariables,
|
||||
autocompleteFunctions,
|
||||
stateKey,
|
||||
className,
|
||||
disabled,
|
||||
}: Props<T>) {
|
||||
const setDataAttr = useCallback(
|
||||
(name: string, value: JsonPrimitive) => {
|
||||
onChange({ ...data, [name]: value === DYNAMIC_FORM_NULL_ARG ? undefined : value });
|
||||
},
|
||||
[data, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormInputsStack
|
||||
disabled={disabled}
|
||||
inputs={inputs}
|
||||
setDataAttr={setDataAttr}
|
||||
stateKey={stateKey}
|
||||
autocompleteFunctions={autocompleteFunctions}
|
||||
autocompleteVariables={autocompleteVariables}
|
||||
data={data}
|
||||
className={classNames(className, 'pb-4')} // Pad the bottom to look nice
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FormInputsStack<T extends Record<string, JsonPrimitive>>({
|
||||
className,
|
||||
...props
|
||||
}: FormInputsProps<T> & { className?: string }) {
|
||||
return (
|
||||
<VStack
|
||||
space={3}
|
||||
className={classNames(
|
||||
className,
|
||||
'h-full overflow-auto',
|
||||
'pr-1', // A bit of space between inputs and scrollbar
|
||||
)}
|
||||
>
|
||||
<FormInputs {...props} />
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
type FormInputsProps<T> = Pick<
|
||||
Props<T>,
|
||||
'inputs' | 'autocompleteFunctions' | 'autocompleteVariables' | 'stateKey' | 'data'
|
||||
> & {
|
||||
setDataAttr: (name: string, value: JsonPrimitive) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
function FormInputs<T extends Record<string, JsonPrimitive>>({
|
||||
inputs,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
stateKey,
|
||||
setDataAttr,
|
||||
data,
|
||||
disabled,
|
||||
}: FormInputsProps<T>) {
|
||||
return (
|
||||
<>
|
||||
{inputs?.map((input, i) => {
|
||||
if ('hidden' in input && input.hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ('disabled' in input && disabled != null) {
|
||||
input.disabled = disabled;
|
||||
}
|
||||
|
||||
switch (input.type) {
|
||||
case 'select':
|
||||
return (
|
||||
<SelectArg
|
||||
key={i + stateKey}
|
||||
arg={input}
|
||||
onChange={(v) => setDataAttr(input.name, v)}
|
||||
value={
|
||||
data[input.name]
|
||||
? String(data[input.name])
|
||||
: (input.defaultValue ?? DYNAMIC_FORM_NULL_ARG)
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'text':
|
||||
return (
|
||||
<TextArg
|
||||
key={i + stateKey}
|
||||
stateKey={stateKey}
|
||||
arg={input}
|
||||
autocompleteFunctions={autocompleteFunctions || false}
|
||||
autocompleteVariables={autocompleteVariables || false}
|
||||
onChange={(v) => setDataAttr(input.name, v)}
|
||||
value={
|
||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? '')
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'editor':
|
||||
return (
|
||||
<EditorArg
|
||||
key={i + stateKey}
|
||||
stateKey={stateKey}
|
||||
arg={input}
|
||||
autocompleteFunctions={autocompleteFunctions || false}
|
||||
autocompleteVariables={autocompleteVariables || false}
|
||||
onChange={(v) => setDataAttr(input.name, v)}
|
||||
value={
|
||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? '')
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'checkbox':
|
||||
return (
|
||||
<CheckboxArg
|
||||
key={i + stateKey}
|
||||
arg={input}
|
||||
onChange={(v) => setDataAttr(input.name, v)}
|
||||
value={data[input.name] != null ? data[input.name] === true : false}
|
||||
/>
|
||||
);
|
||||
case 'http_request':
|
||||
return (
|
||||
<HttpRequestArg
|
||||
key={i + stateKey}
|
||||
arg={input}
|
||||
onChange={(v) => setDataAttr(input.name, v)}
|
||||
value={data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG}
|
||||
/>
|
||||
);
|
||||
case 'file':
|
||||
return (
|
||||
<FileArg
|
||||
key={i + stateKey}
|
||||
arg={input}
|
||||
onChange={(v) => setDataAttr(input.name, v)}
|
||||
filePath={
|
||||
data[input.name] != null ? String(data[input.name]) : DYNAMIC_FORM_NULL_ARG
|
||||
}
|
||||
/>
|
||||
);
|
||||
case 'accordion':
|
||||
if (!hasVisibleInputs(input.inputs)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div key={i + stateKey}>
|
||||
<DetailsBanner
|
||||
summary={input.label}
|
||||
className={classNames('!mb-auto', disabled && 'opacity-disabled')}
|
||||
>
|
||||
<div className="mt-3">
|
||||
<FormInputsStack
|
||||
data={data}
|
||||
disabled={disabled}
|
||||
inputs={input.inputs}
|
||||
setDataAttr={setDataAttr}
|
||||
stateKey={stateKey}
|
||||
autocompleteFunctions={autocompleteFunctions || false}
|
||||
autocompleteVariables={autocompleteVariables}
|
||||
/>
|
||||
</div>
|
||||
</DetailsBanner>
|
||||
</div>
|
||||
);
|
||||
case 'h_stack':
|
||||
if (!hasVisibleInputs(input.inputs)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap sm:flex-nowrap gap-3 items-end" key={i + stateKey}>
|
||||
<FormInputs
|
||||
data={data}
|
||||
disabled={disabled}
|
||||
inputs={input.inputs}
|
||||
setDataAttr={setDataAttr}
|
||||
stateKey={stateKey}
|
||||
autocompleteFunctions={autocompleteFunctions || false}
|
||||
autocompleteVariables={autocompleteVariables}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
case 'banner':
|
||||
if (!hasVisibleInputs(input.inputs)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Banner
|
||||
key={i + stateKey}
|
||||
color={input.color}
|
||||
className={classNames(disabled && 'opacity-disabled')}
|
||||
>
|
||||
<FormInputsStack
|
||||
data={data}
|
||||
disabled={disabled}
|
||||
inputs={input.inputs}
|
||||
setDataAttr={setDataAttr}
|
||||
stateKey={stateKey}
|
||||
autocompleteFunctions={autocompleteFunctions || false}
|
||||
autocompleteVariables={autocompleteVariables}
|
||||
/>
|
||||
</Banner>
|
||||
);
|
||||
case 'markdown':
|
||||
return <Markdown key={i + stateKey}>{input.content}</Markdown>;
|
||||
case 'key_value':
|
||||
return (
|
||||
<KeyValueArg
|
||||
key={i + stateKey}
|
||||
arg={input}
|
||||
stateKey={stateKey}
|
||||
onChange={(v) => setDataAttr(input.name, v)}
|
||||
value={
|
||||
data[input.name] != null ? String(data[input.name]) : (input.defaultValue ?? '[]')
|
||||
}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
// @ts-expect-error
|
||||
throw new Error(`Invalid input type: ${input.type}`);
|
||||
}
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TextArg({
|
||||
arg,
|
||||
onChange,
|
||||
value,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
stateKey,
|
||||
}: {
|
||||
arg: FormInputText;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
autocompleteFunctions: boolean;
|
||||
autocompleteVariables: boolean;
|
||||
stateKey: string;
|
||||
}) {
|
||||
const props: InputProps = {
|
||||
onChange,
|
||||
name: arg.name,
|
||||
multiLine: arg.multiLine,
|
||||
className: arg.multiLine ? 'min-h-[4rem]' : undefined,
|
||||
defaultValue: value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value,
|
||||
required: !arg.optional,
|
||||
disabled: arg.disabled,
|
||||
help: arg.description,
|
||||
type: arg.password ? 'password' : 'text',
|
||||
label: arg.label ?? arg.name,
|
||||
size: INPUT_SIZE,
|
||||
hideLabel: arg.hideLabel ?? arg.label == null,
|
||||
placeholder: arg.placeholder ?? undefined,
|
||||
forceUpdateKey: stateKey,
|
||||
autocomplete: arg.completionOptions ? { options: arg.completionOptions } : undefined,
|
||||
stateKey,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
};
|
||||
if (autocompleteVariables || autocompleteFunctions || arg.completionOptions) {
|
||||
return <Input {...props} />;
|
||||
}
|
||||
return <PlainInput {...props} />;
|
||||
}
|
||||
|
||||
function EditorArg({
|
||||
arg,
|
||||
onChange,
|
||||
value,
|
||||
autocompleteFunctions,
|
||||
autocompleteVariables,
|
||||
stateKey,
|
||||
}: {
|
||||
arg: FormInputEditor;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
autocompleteFunctions: boolean;
|
||||
autocompleteVariables: boolean;
|
||||
stateKey: string;
|
||||
}) {
|
||||
const id = `input-${arg.name}`;
|
||||
|
||||
// Read-only editor force refresh for every defaultValue change
|
||||
// Should this be built into the <Editor/> component?
|
||||
const [popoutKey, regeneratePopoutKey] = useRandomKey();
|
||||
const forceUpdateKey = popoutKey + (arg.readOnly ? arg.defaultValue + stateKey : stateKey);
|
||||
|
||||
return (
|
||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)]">
|
||||
<Label
|
||||
htmlFor={id}
|
||||
required={!arg.optional}
|
||||
visuallyHidden={arg.hideLabel}
|
||||
help={arg.description}
|
||||
tags={arg.language ? [capitalize(arg.language)] : undefined}
|
||||
>
|
||||
{arg.label}
|
||||
</Label>
|
||||
<div
|
||||
className={classNames(
|
||||
'border border-border rounded-md overflow-hidden px-2 py-1',
|
||||
'focus-within:border-border-focus',
|
||||
!arg.rows && 'max-h-[10rem]', // So it doesn't take up too much space
|
||||
)}
|
||||
style={arg.rows ? { height: `${arg.rows * 1.4 + 0.75}rem` } : undefined}
|
||||
>
|
||||
<Editor
|
||||
id={id}
|
||||
autocomplete={arg.completionOptions ? { options: arg.completionOptions } : undefined}
|
||||
disabled={arg.disabled}
|
||||
language={arg.language}
|
||||
readOnly={arg.readOnly}
|
||||
onChange={onChange}
|
||||
hideGutter
|
||||
heightMode="auto"
|
||||
className="min-h-[3rem]"
|
||||
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
|
||||
placeholder={arg.placeholder ?? undefined}
|
||||
autocompleteFunctions={autocompleteFunctions}
|
||||
autocompleteVariables={autocompleteVariables}
|
||||
stateKey={stateKey}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
actions={
|
||||
<div>
|
||||
<IconButton
|
||||
variant="border"
|
||||
size="sm"
|
||||
className="my-0.5 opacity-60 group-hover:opacity-100"
|
||||
icon="expand"
|
||||
title="Pop out to large editor"
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
id: 'id',
|
||||
size: 'full',
|
||||
title: arg.readOnly ? 'View Value' : 'Edit Value',
|
||||
className: '!max-w-[50rem] !max-h-[60rem]',
|
||||
description: arg.label && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
required={!arg.optional}
|
||||
visuallyHidden={arg.hideLabel}
|
||||
help={arg.description}
|
||||
tags={arg.language ? [capitalize(arg.language)] : undefined}
|
||||
>
|
||||
{arg.label}
|
||||
</Label>
|
||||
),
|
||||
onClose() {
|
||||
// Force the main editor to update on close
|
||||
regeneratePopoutKey();
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<Editor
|
||||
id={id}
|
||||
autocomplete={
|
||||
arg.completionOptions ? { options: arg.completionOptions } : undefined
|
||||
}
|
||||
disabled={arg.disabled}
|
||||
language={arg.language}
|
||||
readOnly={arg.readOnly}
|
||||
onChange={onChange}
|
||||
defaultValue={value === DYNAMIC_FORM_NULL_ARG ? arg.defaultValue : value}
|
||||
placeholder={arg.placeholder ?? undefined}
|
||||
autocompleteFunctions={autocompleteFunctions}
|
||||
autocompleteVariables={autocompleteVariables}
|
||||
stateKey={stateKey}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectArg({
|
||||
arg,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
arg: FormInputSelect;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Select
|
||||
label={arg.label ?? arg.name}
|
||||
name={arg.name}
|
||||
help={arg.description}
|
||||
onChange={onChange}
|
||||
defaultValue={arg.defaultValue}
|
||||
hideLabel={arg.hideLabel}
|
||||
value={value}
|
||||
size={INPUT_SIZE}
|
||||
disabled={arg.disabled}
|
||||
options={arg.options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function FileArg({
|
||||
arg,
|
||||
filePath,
|
||||
onChange,
|
||||
}: {
|
||||
arg: FormInputFile;
|
||||
filePath: string;
|
||||
onChange: (v: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<SelectFile
|
||||
disabled={arg.disabled}
|
||||
help={arg.description}
|
||||
onChange={({ filePath }) => onChange(filePath)}
|
||||
filePath={filePath === DYNAMIC_FORM_NULL_ARG ? null : filePath}
|
||||
directory={!!arg.directory}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function HttpRequestArg({
|
||||
arg,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
arg: FormInputHttpRequest;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
const httpRequests = useAtomValue(httpRequestsAtom);
|
||||
const activeHttpRequest = useActiveRequest('http_request');
|
||||
|
||||
useEffect(() => {
|
||||
if (value === DYNAMIC_FORM_NULL_ARG && activeHttpRequest) {
|
||||
onChange(activeHttpRequest.id);
|
||||
}
|
||||
}, [activeHttpRequest, onChange, value]);
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={arg.label ?? arg.name}
|
||||
name={arg.name}
|
||||
onChange={onChange}
|
||||
help={arg.description}
|
||||
value={value}
|
||||
disabled={arg.disabled}
|
||||
options={[
|
||||
...httpRequests.map((r) => {
|
||||
return {
|
||||
label:
|
||||
buildRequestBreadcrumbs(r, folders).join(' / ') +
|
||||
(r.id === activeHttpRequest?.id ? ' (current)' : ''),
|
||||
value: r.id,
|
||||
};
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function buildRequestBreadcrumbs(request: HttpRequest, folders: Folder[]): string[] {
|
||||
const ancestors: (HttpRequest | Folder)[] = [request];
|
||||
|
||||
const next = () => {
|
||||
const latest = ancestors[0];
|
||||
if (latest == null) return [];
|
||||
|
||||
const parent = folders.find((f) => f.id === latest.folderId);
|
||||
if (parent == null) return;
|
||||
|
||||
ancestors.unshift(parent);
|
||||
next();
|
||||
};
|
||||
next();
|
||||
|
||||
return ancestors.map((a) => (a.model === 'folder' ? a.name : resolvedModelName(a)));
|
||||
}
|
||||
|
||||
function CheckboxArg({
|
||||
arg,
|
||||
onChange,
|
||||
value,
|
||||
}: {
|
||||
arg: FormInputCheckbox;
|
||||
value: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<Checkbox
|
||||
onChange={onChange}
|
||||
checked={value}
|
||||
help={arg.description}
|
||||
disabled={arg.disabled}
|
||||
title={arg.label ?? arg.name}
|
||||
hideLabel={arg.label == null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function KeyValueArg({
|
||||
arg,
|
||||
onChange,
|
||||
value,
|
||||
stateKey,
|
||||
}: {
|
||||
arg: FormInputKeyValue;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
stateKey: string;
|
||||
}) {
|
||||
const pairs: Pair[] = useMemo(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newPairs: Pair[]) => {
|
||||
onChange(JSON.stringify(newPairs));
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden">
|
||||
<Label
|
||||
htmlFor={`input-${arg.name}`}
|
||||
required={!arg.optional}
|
||||
visuallyHidden={arg.hideLabel}
|
||||
help={arg.description}
|
||||
>
|
||||
{arg.label ?? arg.name}
|
||||
</Label>
|
||||
<PairEditor
|
||||
pairs={pairs}
|
||||
onChange={handleChange}
|
||||
stateKey={stateKey}
|
||||
namePlaceholder="name"
|
||||
valuePlaceholder="value"
|
||||
noScroll
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasVisibleInputs(inputs: FormInput[] | undefined): boolean {
|
||||
if (!inputs) return false;
|
||||
|
||||
for (const input of inputs) {
|
||||
if ('inputs' in input && !hasVisibleInputs(input.inputs)) {
|
||||
// Has children, but none are visible
|
||||
return false;
|
||||
}
|
||||
if (!input.hidden) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
23
apps/yaak-client/components/EmptyStateText.tsx
Normal file
23
apps/yaak-client/components/EmptyStateText.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function EmptyStateText({ children, className }: Props) {
|
||||
return (
|
||||
<div className="w-full h-full pb-2">
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
'rounded-lg border border-dashed border-border-subtle',
|
||||
'h-full py-2 text-text-subtlest flex items-center justify-center italic',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
apps/yaak-client/components/EncryptionHelp.tsx
Normal file
13
apps/yaak-client/components/EncryptionHelp.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { VStack } from './core/Stacks';
|
||||
|
||||
export function EncryptionHelp() {
|
||||
return (
|
||||
<VStack space={3}>
|
||||
<p>Encrypt passwords, tokens, and other sensitive info when encryption is enabled.</p>
|
||||
<p>
|
||||
Encrypted data remains secure when syncing to the filesystem or Git, and when exporting or
|
||||
sharing with others.
|
||||
</p>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
78
apps/yaak-client/components/EnvironmentActionsDropdown.tsx
Normal file
78
apps/yaak-client/components/EnvironmentActionsDropdown.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import classNames from 'classnames';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
|
||||
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
|
||||
import { editEnvironment } from '../lib/editEnvironment';
|
||||
import { setWorkspaceSearchParams } from '../lib/setWorkspaceSearchParams';
|
||||
import type { ButtonProps } from './core/Button';
|
||||
import { Button } from './core/Button';
|
||||
import type { DropdownItem } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
} & Pick<ButtonProps, 'forDropdown' | 'leftSlot'>;
|
||||
|
||||
export const EnvironmentActionsDropdown = memo(function EnvironmentActionsDropdown({
|
||||
className,
|
||||
...buttonProps
|
||||
}: Props) {
|
||||
const { subEnvironments, baseEnvironment } = useEnvironmentsBreakdown();
|
||||
const activeEnvironment = useActiveEnvironment();
|
||||
|
||||
const items: DropdownItem[] = useMemo(
|
||||
() => [
|
||||
...subEnvironments.map(
|
||||
(e) => ({
|
||||
key: e.id,
|
||||
label: e.name,
|
||||
rightSlot: <EnvironmentColorIndicator environment={e} />,
|
||||
leftSlot: e.id === activeEnvironment?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: async () => {
|
||||
if (e.id !== activeEnvironment?.id) {
|
||||
setWorkspaceSearchParams({ environment_id: e.id });
|
||||
} else {
|
||||
setWorkspaceSearchParams({ environment_id: null });
|
||||
}
|
||||
},
|
||||
}),
|
||||
[activeEnvironment?.id],
|
||||
),
|
||||
...((subEnvironments.length > 0
|
||||
? [{ type: 'separator', label: 'Environments' }]
|
||||
: []) as DropdownItem[]),
|
||||
{
|
||||
label: 'Manage Environments',
|
||||
hotKeyAction: 'environment_editor.toggle',
|
||||
leftSlot: <Icon icon="box" />,
|
||||
onSelect: () => editEnvironment(activeEnvironment),
|
||||
},
|
||||
],
|
||||
[subEnvironments, activeEnvironment],
|
||||
);
|
||||
|
||||
const hasBaseVars =
|
||||
(baseEnvironment?.variables ?? []).filter((v) => v.enabled && (v.name || v.value)).length > 0;
|
||||
|
||||
return (
|
||||
<Dropdown items={items}>
|
||||
<Button
|
||||
size="sm"
|
||||
className={classNames(
|
||||
className,
|
||||
'text !px-2 truncate',
|
||||
!activeEnvironment && !hasBaseVars && 'text-text-subtlest italic',
|
||||
)}
|
||||
// If no environments, the button simply opens the dialog.
|
||||
// NOTE: We don't create a new button because we want to reuse the hotkey from the menu items
|
||||
onClick={subEnvironments.length === 0 ? () => editEnvironment(null) : undefined}
|
||||
{...buttonProps}
|
||||
>
|
||||
<EnvironmentColorIndicator environment={activeEnvironment ?? null} />
|
||||
{activeEnvironment?.name ?? (hasBaseVars ? 'Environment' : 'No Environment')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
23
apps/yaak-client/components/EnvironmentColorIndicator.tsx
Normal file
23
apps/yaak-client/components/EnvironmentColorIndicator.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { Environment } from '@yaakapp-internal/models';
|
||||
import { showColorPicker } from '../lib/showColorPicker';
|
||||
import { ColorIndicator } from './ColorIndicator';
|
||||
|
||||
export function EnvironmentColorIndicator({
|
||||
environment,
|
||||
clickToEdit,
|
||||
className,
|
||||
}: {
|
||||
environment: Environment | null;
|
||||
clickToEdit?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
if (environment?.color == null) return null;
|
||||
|
||||
return (
|
||||
<ColorIndicator
|
||||
className={className}
|
||||
color={environment?.color ?? null}
|
||||
onClick={clickToEdit ? () => showColorPicker(environment) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
33
apps/yaak-client/components/EnvironmentColorPicker.tsx
Normal file
33
apps/yaak-client/components/EnvironmentColorPicker.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useState } from 'react';
|
||||
import { ColorIndicator } from './ColorIndicator';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { ColorPickerWithThemeColors } from './core/ColorPicker';
|
||||
|
||||
export function EnvironmentColorPicker({
|
||||
color: defaultColor,
|
||||
onChange,
|
||||
}: {
|
||||
color: string | null;
|
||||
onChange: (color: string | null) => void;
|
||||
}) {
|
||||
const [color, setColor] = useState<string | null>(defaultColor);
|
||||
return (
|
||||
<form
|
||||
className="flex flex-col items-stretch gap-5 pb-2 w-full"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
onChange(color);
|
||||
}}
|
||||
>
|
||||
<Banner color="secondary">
|
||||
This color will be used to color the interface when this environment is active
|
||||
</Banner>
|
||||
<ColorPickerWithThemeColors color={color} onChange={setColor} />
|
||||
<Button type="submit" color="secondary">
|
||||
{color != null && <ColorIndicator color={color} />}
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
412
apps/yaak-client/components/EnvironmentEditDialog.tsx
Normal file
412
apps/yaak-client/components/EnvironmentEditDialog.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import type { Environment, Workspace } from '@yaakapp-internal/models';
|
||||
import { duplicateModel, patchModel } from '@yaakapp-internal/models';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createSubEnvironmentAndActivate } from '../commands/createEnvironment';
|
||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import {
|
||||
environmentsBreakdownAtom,
|
||||
useEnvironmentsBreakdown,
|
||||
} from '../hooks/useEnvironmentsBreakdown';
|
||||
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { isBaseEnvironment, isSubEnvironment } from '../lib/model_util';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { showColorPicker } from '../lib/showColorPicker';
|
||||
import { Banner } from './core/Banner';
|
||||
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { IconTooltip } from './core/IconTooltip';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import type { PairEditorHandle } from './core/PairEditor';
|
||||
import { SplitLayout } from './core/SplitLayout';
|
||||
import type { TreeNode } from './core/tree/common';
|
||||
import type { TreeHandle, TreeProps } from './core/tree/Tree';
|
||||
import { Tree } from './core/tree/Tree';
|
||||
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
|
||||
import { EnvironmentEditor } from './EnvironmentEditor';
|
||||
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
|
||||
|
||||
interface Props {
|
||||
initialEnvironmentId: string | null;
|
||||
setRef?: (ref: PairEditorHandle | null) => void;
|
||||
}
|
||||
|
||||
type TreeModel = Environment | Workspace;
|
||||
|
||||
export function EnvironmentEditDialog({ initialEnvironmentId, setRef }: Props) {
|
||||
const { allEnvironments, baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
||||
const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string | null>(
|
||||
initialEnvironmentId ?? null,
|
||||
);
|
||||
|
||||
const selectedEnvironment =
|
||||
selectedEnvironmentId != null
|
||||
? allEnvironments.find((e) => e.id === selectedEnvironmentId)
|
||||
: baseEnvironment;
|
||||
|
||||
return (
|
||||
<SplitLayout
|
||||
name="env_editor"
|
||||
defaultRatio={0.75}
|
||||
layout="horizontal"
|
||||
className="gap-0"
|
||||
resizeHandleClassName="-translate-x-[1px]"
|
||||
firstSlot={() => (
|
||||
<EnvironmentEditDialogSidebar
|
||||
selectedEnvironmentId={selectedEnvironment?.id ?? null}
|
||||
setSelectedEnvironmentId={setSelectedEnvironmentId}
|
||||
/>
|
||||
)}
|
||||
secondSlot={() => (
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
{baseEnvironments.length > 1 ? (
|
||||
<div className="p-3">
|
||||
<Banner color="notice">
|
||||
There are multiple base environments for this workspace. Please delete the
|
||||
environments you no longer need.
|
||||
</Banner>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{selectedEnvironment == null ? (
|
||||
<div className="p-3 mt-10">
|
||||
<Banner color="danger">
|
||||
Failed to find selected environment <InlineCode>{selectedEnvironmentId}</InlineCode>
|
||||
</Banner>
|
||||
</div>
|
||||
) : (
|
||||
<EnvironmentEditor
|
||||
key={selectedEnvironment.id}
|
||||
setRef={setRef}
|
||||
className="pl-4 pt-3"
|
||||
environment={selectedEnvironment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const sharableTooltip = (
|
||||
<IconTooltip
|
||||
tabIndex={-1}
|
||||
icon="eye"
|
||||
iconSize="sm"
|
||||
content="This environment will be included in Directory Sync and data exports"
|
||||
/>
|
||||
);
|
||||
|
||||
function EnvironmentEditDialogSidebar({
|
||||
selectedEnvironmentId,
|
||||
setSelectedEnvironmentId,
|
||||
}: {
|
||||
selectedEnvironmentId: string | null;
|
||||
setSelectedEnvironmentId: (id: string | null) => void;
|
||||
}) {
|
||||
const activeWorkspaceId = useAtomValue(activeWorkspaceIdAtom) ?? '';
|
||||
const treeId = `environment.${activeWorkspaceId}.sidebar`;
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
const { baseEnvironment, baseEnvironments } = useEnvironmentsBreakdown();
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
useLayoutEffect(() => {
|
||||
if (selectedEnvironmentId == null) return;
|
||||
treeRef.current?.selectItem(selectedEnvironmentId);
|
||||
treeRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleDeleteEnvironment = useCallback(
|
||||
async (environment: Environment) => {
|
||||
await deleteModelWithConfirm(environment);
|
||||
if (selectedEnvironmentId === environment.id) {
|
||||
setSelectedEnvironmentId(baseEnvironment?.id ?? null);
|
||||
}
|
||||
},
|
||||
[baseEnvironment?.id, selectedEnvironmentId, setSelectedEnvironmentId],
|
||||
);
|
||||
|
||||
const actions = useMemo(() => {
|
||||
const enable = () => treeRef.current?.hasFocus() ?? false;
|
||||
|
||||
const actions = {
|
||||
'sidebar.selected.rename': {
|
||||
enable,
|
||||
allowDefault: true,
|
||||
priority: 100,
|
||||
cb: async (items: TreeModel[]) => {
|
||||
const item = items[0];
|
||||
if (items.length === 1 && item != null) {
|
||||
treeRef.current?.renameItem(item.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
'sidebar.selected.delete': {
|
||||
priority: 100,
|
||||
enable,
|
||||
cb: (items: TreeModel[]) => deleteModelWithConfirm(items),
|
||||
},
|
||||
'sidebar.selected.duplicate': {
|
||||
priority: 100,
|
||||
enable,
|
||||
cb: async (items: TreeModel[]) => {
|
||||
if (items.length === 1 && items[0]) {
|
||||
const item = items[0];
|
||||
const newId = await duplicateModel(item);
|
||||
setSelectedEnvironmentId(newId);
|
||||
} else {
|
||||
await Promise.all(items.map(duplicateModel));
|
||||
}
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
return actions;
|
||||
}, [setSelectedEnvironmentId]);
|
||||
|
||||
const hotkeys = useMemo<TreeProps<TreeModel>['hotkeys']>(() => ({ actions }), [actions]);
|
||||
|
||||
const getContextMenu = useCallback(
|
||||
(items: TreeModel[]): ContextMenuProps['items'] => {
|
||||
const environment = items[0];
|
||||
const addEnvironmentItem: DropdownItem = {
|
||||
label: 'Create Sub Environment',
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: async () => {
|
||||
await createSubEnvironment();
|
||||
},
|
||||
};
|
||||
|
||||
if (environment == null || environment.model !== 'environment') {
|
||||
return [addEnvironmentItem];
|
||||
}
|
||||
|
||||
const singleEnvironment = items.length === 1;
|
||||
const canDeleteEnvironment =
|
||||
isSubEnvironment(environment) ||
|
||||
(isBaseEnvironment(environment) && baseEnvironments.length > 1);
|
||||
|
||||
const menuItems: DropdownItem[] = [
|
||||
{
|
||||
label: 'Rename',
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
hidden: isBaseEnvironment(environment) || !singleEnvironment,
|
||||
hotKeyAction: 'sidebar.selected.rename',
|
||||
hotKeyLabelOnly: true,
|
||||
onSelect: async () => {
|
||||
// Not sure why this is needed, but without it the
|
||||
// edit input blurs immediately after opening.
|
||||
requestAnimationFrame(() => {
|
||||
actions['sidebar.selected.rename'].cb(items);
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: isBaseEnvironment(environment),
|
||||
hotKeyAction: 'sidebar.selected.duplicate',
|
||||
hotKeyLabelOnly: true,
|
||||
onSelect: () => actions['sidebar.selected.duplicate'].cb(items),
|
||||
},
|
||||
{
|
||||
label: environment.color ? 'Change Color' : 'Assign Color',
|
||||
leftSlot: <Icon icon="palette" />,
|
||||
hidden: isBaseEnvironment(environment) || !singleEnvironment,
|
||||
onSelect: async () => showColorPicker(environment),
|
||||
},
|
||||
{
|
||||
label: `Make ${environment.public ? 'Private' : 'Sharable'}`,
|
||||
leftSlot: <Icon icon={environment.public ? 'eye_closed' : 'eye'} />,
|
||||
rightSlot: <EnvironmentSharableTooltip />,
|
||||
hidden: items.length > 1,
|
||||
onSelect: async () => {
|
||||
await patchModel(environment, { public: !environment.public });
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
label: 'Delete',
|
||||
hotKeyAction: 'sidebar.selected.delete',
|
||||
hotKeyLabelOnly: true,
|
||||
hidden: !canDeleteEnvironment,
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => handleDeleteEnvironment(environment),
|
||||
},
|
||||
];
|
||||
|
||||
// Add sub environment to base environment
|
||||
if (isBaseEnvironment(environment) && singleEnvironment) {
|
||||
menuItems.push({ type: 'separator' });
|
||||
menuItems.push(addEnvironmentItem);
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
},
|
||||
[actions, baseEnvironments.length, handleDeleteEnvironment],
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback(async function handleDragEnd({
|
||||
items,
|
||||
children,
|
||||
insertAt,
|
||||
}: {
|
||||
items: TreeModel[];
|
||||
children: TreeModel[];
|
||||
insertAt: number;
|
||||
}) {
|
||||
const prev = children[insertAt - 1] as Exclude<TreeModel, Workspace>;
|
||||
const next = children[insertAt] as Exclude<TreeModel, Workspace>;
|
||||
|
||||
const beforePriority = prev?.sortPriority ?? 0;
|
||||
const afterPriority = next?.sortPriority ?? 0;
|
||||
const shouldUpdateAll = afterPriority - beforePriority < 1;
|
||||
|
||||
try {
|
||||
if (shouldUpdateAll) {
|
||||
// Add items to children at insertAt
|
||||
children.splice(insertAt, 0, ...items);
|
||||
await Promise.all(children.map((m, i) => patchModel(m, { sortPriority: i * 1000 })));
|
||||
} else {
|
||||
const range = afterPriority - beforePriority;
|
||||
const increment = range / (items.length + 2);
|
||||
await Promise.all(
|
||||
items.map((m, i) => {
|
||||
const sortPriority = beforePriority + (i + 1) * increment;
|
||||
// Spread item sortPriority out over before/after range
|
||||
return patchModel(m, { sortPriority });
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleActivate = useCallback(
|
||||
(item: TreeModel) => {
|
||||
setSelectedEnvironmentId(item.id);
|
||||
},
|
||||
[setSelectedEnvironmentId],
|
||||
);
|
||||
|
||||
const tree = useAtomValue(treeAtom);
|
||||
return (
|
||||
<aside className="x-theme-sidebar h-full w-full min-w-0 grid overflow-y-auto border-r border-border-subtle ">
|
||||
{tree != null && (
|
||||
<div className="pt-2">
|
||||
<Tree
|
||||
ref={treeRef}
|
||||
treeId={treeId}
|
||||
className="px-2 pb-10"
|
||||
hotkeys={hotkeys}
|
||||
root={tree}
|
||||
getContextMenu={getContextMenu}
|
||||
onDragEnd={handleDragEnd}
|
||||
getItemKey={(i) => `${i.id}::${i.name}`}
|
||||
ItemLeftSlotInner={ItemLeftSlotInner}
|
||||
ItemRightSlot={ItemRightSlot}
|
||||
ItemInner={ItemInner}
|
||||
onActivate={handleActivate}
|
||||
getEditOptions={getEditOptions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
const treeAtom = atom<TreeNode<TreeModel> | null>((get) => {
|
||||
const activeWorkspace = get(activeWorkspaceAtom);
|
||||
const { baseEnvironment, baseEnvironments, subEnvironments } = get(environmentsBreakdownAtom);
|
||||
if (activeWorkspace == null || baseEnvironment == null) return null;
|
||||
|
||||
const root: TreeNode<TreeModel> = {
|
||||
item: activeWorkspace,
|
||||
parent: null,
|
||||
children: [],
|
||||
depth: 0,
|
||||
};
|
||||
|
||||
for (const item of baseEnvironments) {
|
||||
root.children?.push({
|
||||
item,
|
||||
parent: root,
|
||||
depth: 0,
|
||||
draggable: false,
|
||||
});
|
||||
}
|
||||
|
||||
const parent = root.children?.[0];
|
||||
if (baseEnvironments.length <= 1 && parent != null) {
|
||||
parent.children = subEnvironments.map((item) => ({
|
||||
item,
|
||||
parent,
|
||||
depth: 1,
|
||||
localDrag: true,
|
||||
}));
|
||||
}
|
||||
|
||||
return root;
|
||||
});
|
||||
|
||||
function ItemLeftSlotInner({ item }: { item: TreeModel }) {
|
||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
||||
return baseEnvironments.length > 1 ? (
|
||||
<Icon icon="alert_triangle" color="notice" />
|
||||
) : (
|
||||
item.model === 'environment' && item.color && <EnvironmentColorIndicator environment={item} />
|
||||
);
|
||||
}
|
||||
|
||||
function ItemRightSlot({ item }: { item: TreeModel }) {
|
||||
const { baseEnvironments } = useEnvironmentsBreakdown();
|
||||
return (
|
||||
<>
|
||||
{item.model === 'environment' && baseEnvironments.length <= 1 && isBaseEnvironment(item) && (
|
||||
<IconButton
|
||||
size="sm"
|
||||
color="custom"
|
||||
iconSize="sm"
|
||||
icon="plus_circle"
|
||||
className="opacity-50 hover:opacity-100"
|
||||
title="Add Sub-Environment"
|
||||
onClick={createSubEnvironment}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemInner({ item }: { item: TreeModel }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[auto_minmax(0,1fr)] w-full items-center">
|
||||
{item.model === 'environment' && item.public ? (
|
||||
<div className="mr-2 flex items-center">{sharableTooltip}</div>
|
||||
) : (
|
||||
<span aria-hidden />
|
||||
)}
|
||||
<div className="truncate min-w-0 text-left">{resolvedModelName(item)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function createSubEnvironment() {
|
||||
const { baseEnvironment } = jotaiStore.get(environmentsBreakdownAtom);
|
||||
if (baseEnvironment == null) return;
|
||||
const id = await createSubEnvironmentAndActivate.mutateAsync(baseEnvironment);
|
||||
return id;
|
||||
}
|
||||
|
||||
function getEditOptions(item: TreeModel) {
|
||||
const options: ReturnType<NonNullable<TreeProps<TreeModel>['getEditOptions']>> = {
|
||||
defaultValue: item.name,
|
||||
placeholder: 'Name',
|
||||
async onChange(item, name) {
|
||||
await patchModel(item, { name });
|
||||
},
|
||||
};
|
||||
return options;
|
||||
}
|
||||
175
apps/yaak-client/components/EnvironmentEditor.tsx
Normal file
175
apps/yaak-client/components/EnvironmentEditor.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Environment } from '@yaakapp-internal/models';
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
|
||||
import { useIsEncryptionEnabled } from '../hooks/useIsEncryptionEnabled';
|
||||
import { useKeyValue } from '../hooks/useKeyValue';
|
||||
import { useRandomKey } from '../hooks/useRandomKey';
|
||||
import { analyzeTemplate, convertTemplateToSecure } from '../lib/encryption';
|
||||
import { isBaseEnvironment } from '../lib/model_util';
|
||||
import {
|
||||
setupOrConfigureEncryption,
|
||||
withEncryptionEnabled,
|
||||
} from '../lib/setupOrConfigureEncryption';
|
||||
import { DismissibleBanner } from './core/DismissibleBanner';
|
||||
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
|
||||
import { Heading } from './core/Heading';
|
||||
import type { PairEditorHandle, PairWithId } from './core/PairEditor';
|
||||
import { ensurePairId } from './core/PairEditor.util';
|
||||
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
|
||||
import { PillButton } from './core/PillButton';
|
||||
import { EnvironmentColorIndicator } from './EnvironmentColorIndicator';
|
||||
import { EnvironmentSharableTooltip } from './EnvironmentSharableTooltip';
|
||||
|
||||
interface Props {
|
||||
environment: Environment;
|
||||
hideName?: boolean;
|
||||
className?: string;
|
||||
setRef?: (n: PairEditorHandle | null) => void;
|
||||
}
|
||||
|
||||
export function EnvironmentEditor({ environment, hideName, className, setRef }: Props) {
|
||||
const workspaceId = environment.workspaceId;
|
||||
const isEncryptionEnabled = useIsEncryptionEnabled();
|
||||
const valueVisibility = useKeyValue<boolean>({
|
||||
namespace: 'global',
|
||||
key: ['environmentValueVisibility', workspaceId],
|
||||
fallback: false,
|
||||
});
|
||||
const { allEnvironments } = useEnvironmentsBreakdown();
|
||||
const handleChange = useCallback(
|
||||
(variables: PairWithId[]) => patchModel(environment, { variables }),
|
||||
[environment],
|
||||
);
|
||||
const [forceUpdateKey, regenerateForceUpdateKey] = useRandomKey();
|
||||
|
||||
// Gather a list of env names from other environments to help the user get them aligned
|
||||
const nameAutocomplete = useMemo<GenericCompletionConfig>(() => {
|
||||
const options: GenericCompletionOption[] = [];
|
||||
if (isBaseEnvironment(environment)) {
|
||||
return { options };
|
||||
}
|
||||
|
||||
const allVariables = allEnvironments.flatMap((e) => e?.variables);
|
||||
const allVariableNames = new Set(allVariables.map((v) => v?.name));
|
||||
for (const name of allVariableNames) {
|
||||
const containingEnvs = allEnvironments.filter((e) =>
|
||||
e.variables.some((v) => v.name === name),
|
||||
);
|
||||
const isAlreadyInActive = containingEnvs.find((e) => e.id === environment.id);
|
||||
if (isAlreadyInActive) {
|
||||
continue;
|
||||
}
|
||||
options.push({
|
||||
label: name,
|
||||
type: 'constant',
|
||||
detail: containingEnvs.map((e) => e.name).join(', '),
|
||||
});
|
||||
}
|
||||
return { options };
|
||||
}, [environment, allEnvironments]);
|
||||
|
||||
const validateName = useCallback((name: string) => {
|
||||
// Empty just means the variable doesn't have a name yet and is unusable
|
||||
if (name === '') return true;
|
||||
return name.match(/^[a-z_][a-z0-9_.-]*$/i) != null;
|
||||
}, []);
|
||||
|
||||
const valueType = !isEncryptionEnabled && valueVisibility.value ? 'text' : 'password';
|
||||
const allVariableAreEncrypted = useMemo(
|
||||
() =>
|
||||
environment.variables.every((v) => v.value === '' || analyzeTemplate(v.value) !== 'insecure'),
|
||||
[environment.variables],
|
||||
);
|
||||
|
||||
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 (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
'h-full grid grid-rows-[auto_minmax(0,1fr)] gap-2 pr-3 pb-3',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Heading className="w-full flex items-center gap-0.5">
|
||||
<EnvironmentColorIndicator
|
||||
className="mr-2"
|
||||
clickToEdit
|
||||
environment={environment ?? null}
|
||||
/>
|
||||
{!hideName && <div className="mr-2">{environment?.name}</div>}
|
||||
{isEncryptionEnabled ? (
|
||||
!allVariableAreEncrypted ? (
|
||||
<PillButton color="notice" onClick={() => encryptEnvironment(environment)}>
|
||||
Encrypt All Variables
|
||||
</PillButton>
|
||||
) : (
|
||||
<PillButton color="secondary" onClick={setupOrConfigureEncryption}>
|
||||
Encryption Settings
|
||||
</PillButton>
|
||||
)
|
||||
) : (
|
||||
<PillButton color="secondary" onClick={() => valueVisibility.set((v) => !v)}>
|
||||
{valueVisibility.value ? 'Hide Values' : 'Show Values'}
|
||||
</PillButton>
|
||||
)}
|
||||
<PillButton
|
||||
color="secondary"
|
||||
rightSlot={<EnvironmentSharableTooltip />}
|
||||
onClick={async () => {
|
||||
await patchModel(environment, { public: !environment.public });
|
||||
}}
|
||||
>
|
||||
{environment.public ? 'Sharable' : 'Private'}
|
||||
</PillButton>
|
||||
</Heading>
|
||||
{environment.public && (!isEncryptionEnabled || !allVariableAreEncrypted) && (
|
||||
<DismissibleBanner
|
||||
id={`warn-unencrypted-${environment.id}`}
|
||||
color="notice"
|
||||
className="mr-3"
|
||||
actions={[
|
||||
{
|
||||
label: 'Encrypt Variables',
|
||||
onClick: () => encryptEnvironment(environment),
|
||||
color: 'success',
|
||||
},
|
||||
]}
|
||||
>
|
||||
This sharable environment contains plain-text secrets
|
||||
</DismissibleBanner>
|
||||
)}
|
||||
</div>
|
||||
<PairOrBulkEditor
|
||||
setRef={setRef}
|
||||
className="h-full"
|
||||
allowMultilineValues
|
||||
preferenceName="environment"
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
namePlaceholder="VAR_NAME"
|
||||
nameValidate={validateName}
|
||||
valueType={valueType}
|
||||
valueAutocompleteVariables="environment"
|
||||
valueAutocompleteFunctions
|
||||
forceUpdateKey={`${environment.id}::${forceUpdateKey}`}
|
||||
pairs={environment.variables}
|
||||
onChange={handleChange}
|
||||
stateKey={`environment.${environment.id}`}
|
||||
forcedEnvironmentId={environment.id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IconTooltip } from './core/IconTooltip';
|
||||
|
||||
export function EnvironmentSharableTooltip() {
|
||||
return (
|
||||
<IconTooltip content="Sharable environments are included in Directory Sync and data export." />
|
||||
);
|
||||
}
|
||||
68
apps/yaak-client/components/ErrorBoundary.tsx
Normal file
68
apps/yaak-client/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
import { Component, useEffect } from 'react';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import RouteError from './RouteError';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
name: string;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
this.state = { hasError: false, error: null };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.warn('Error caught by ErrorBoundary:', error, info);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<Banner color="danger" className="flex items-center gap-2 overflow-auto">
|
||||
<div>
|
||||
Error rendering <InlineCode>{this.props.name}</InlineCode> component
|
||||
</div>
|
||||
<Button
|
||||
className="inline-flex"
|
||||
variant="border"
|
||||
color="danger"
|
||||
size="2xs"
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
id: 'error-boundary',
|
||||
render: () => <RouteError error={this.state.error} />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
Show
|
||||
</Button>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export function ErrorBoundaryTestThrow() {
|
||||
useEffect(() => {
|
||||
throw new Error('test error');
|
||||
});
|
||||
|
||||
return <div>Hello</div>;
|
||||
}
|
||||
165
apps/yaak-client/components/ExportDataDialog.tsx
Normal file
165
apps/yaak-client/components/ExportDataDialog.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import { save } from '@tauri-apps/plugin-dialog';
|
||||
import type { Workspace } from '@yaakapp-internal/models';
|
||||
import { workspacesAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import slugify from 'slugify';
|
||||
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
|
||||
import { pluralizeCount } from '../lib/pluralize';
|
||||
import { invokeCmd } from '../lib/tauri';
|
||||
import { Button } from './core/Button';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { DetailsBanner } from './core/DetailsBanner';
|
||||
import { Link } from './core/Link';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
onHide: () => void;
|
||||
onSuccess: (path: string) => void;
|
||||
}
|
||||
|
||||
export function ExportDataDialog({ onHide, onSuccess }: Props) {
|
||||
const allWorkspaces = useAtomValue(workspacesAtom);
|
||||
const activeWorkspace = useAtomValue(activeWorkspaceAtom);
|
||||
if (activeWorkspace == null || allWorkspaces.length === 0) return null;
|
||||
|
||||
return (
|
||||
<ExportDataDialogContent
|
||||
onHide={onHide}
|
||||
onSuccess={onSuccess}
|
||||
allWorkspaces={allWorkspaces}
|
||||
activeWorkspace={activeWorkspace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ExportDataDialogContent({
|
||||
onHide,
|
||||
onSuccess,
|
||||
activeWorkspace,
|
||||
allWorkspaces,
|
||||
}: Props & {
|
||||
allWorkspaces: Workspace[];
|
||||
activeWorkspace: Workspace;
|
||||
}) {
|
||||
const [includePrivateEnvironments, setIncludePrivateEnvironments] = useState<boolean>(false);
|
||||
const [selectedWorkspaces, setSelectedWorkspaces] = useState<Record<string, boolean>>({
|
||||
[activeWorkspace.id]: true,
|
||||
});
|
||||
|
||||
// Put the active workspace first
|
||||
const workspaces = useMemo(
|
||||
() => [activeWorkspace, ...allWorkspaces.filter((w) => w.id !== activeWorkspace.id)],
|
||||
[activeWorkspace, allWorkspaces],
|
||||
);
|
||||
|
||||
const handleToggleAll = () => {
|
||||
setSelectedWorkspaces(
|
||||
// biome-ignore lint/performance/noAccumulatingSpread: none
|
||||
allSelected ? {} : workspaces.reduce((acc, w) => ({ ...acc, [w.id]: true }), {}),
|
||||
);
|
||||
};
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
const ids = Object.keys(selectedWorkspaces).filter((k) => selectedWorkspaces[k]);
|
||||
const workspace = ids.length === 1 ? workspaces.find((w) => w.id === ids[0]) : undefined;
|
||||
const slug = workspace ? slugify(workspace.name, { lower: true }) : 'workspaces';
|
||||
const exportPath = await save({
|
||||
title: 'Export Data',
|
||||
defaultPath: `yaak.${slug}.json`,
|
||||
});
|
||||
if (exportPath == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await invokeCmd('cmd_export_data', {
|
||||
workspaceIds: ids,
|
||||
exportPath,
|
||||
includePrivateEnvironments: includePrivateEnvironments,
|
||||
});
|
||||
onHide();
|
||||
onSuccess(exportPath);
|
||||
}, [includePrivateEnvironments, onHide, onSuccess, selectedWorkspaces, workspaces]);
|
||||
|
||||
const allSelected = workspaces.every((w) => selectedWorkspaces[w.id]);
|
||||
const numSelected = Object.values(selectedWorkspaces).filter(Boolean).length;
|
||||
const noneSelected = numSelected === 0;
|
||||
return (
|
||||
<div className="w-full grid grid-rows-[minmax(0,1fr)_auto]">
|
||||
<VStack space={3} className="overflow-auto px-5 pb-6">
|
||||
<table className="w-full mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-6 min-w-0 py-2 text-left pl-1">
|
||||
<Checkbox
|
||||
checked={!allSelected && !noneSelected ? 'indeterminate' : allSelected}
|
||||
hideLabel
|
||||
title="All workspaces"
|
||||
onChange={handleToggleAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="py-2 text-left pl-4" onClick={handleToggleAll}>
|
||||
Workspace
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{workspaces.map((w) => (
|
||||
<tr key={w.id}>
|
||||
<td className="min-w-0 py-1 pl-1">
|
||||
<Checkbox
|
||||
checked={selectedWorkspaces[w.id] ?? false}
|
||||
title={w.name}
|
||||
hideLabel
|
||||
onChange={() =>
|
||||
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className="py-1 pl-4 text whitespace-nowrap overflow-x-auto hide-scrollbars"
|
||||
onClick={() =>
|
||||
setSelectedWorkspaces((prev) => ({ ...prev, [w.id]: !prev[w.id] }))
|
||||
}
|
||||
>
|
||||
{w.name} {w.id === activeWorkspace.id ? '(current workspace)' : ''}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<DetailsBanner color="secondary" defaultOpen summary="Extra Settings">
|
||||
<Checkbox
|
||||
checked={includePrivateEnvironments}
|
||||
onChange={setIncludePrivateEnvironments}
|
||||
title="Include private environments"
|
||||
help='Environments marked as "sharable" will be exported by default'
|
||||
/>
|
||||
</DetailsBanner>
|
||||
</VStack>
|
||||
<footer className="px-5 grid grid-cols-[1fr_auto] items-center bg-surface-highlight py-2 border-t border-border-subtle">
|
||||
<div>
|
||||
<Link href="https://yaak.app/button/new" noUnderline className="text-text-subtle">
|
||||
Create Run Button
|
||||
</Link>
|
||||
</div>
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button size="sm" className="focus" variant="border" onClick={onHide}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
className="focus"
|
||||
color="primary"
|
||||
disabled={noneSelected}
|
||||
onClick={() => handleExport()}
|
||||
>
|
||||
Export{' '}
|
||||
{pluralizeCount('Workspace', numSelected, { omitSingle: true, noneWord: 'Nothing' })}
|
||||
</Button>
|
||||
</HStack>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
215
apps/yaak-client/components/FolderLayout.tsx
Normal file
215
apps/yaak-client/components/FolderLayout.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import type { Folder, GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||
import { foldersAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties, ReactNode } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { allRequestsAtom } from '../hooks/useAllRequests';
|
||||
import { useFolderActions } from '../hooks/useFolderActions';
|
||||
import { useLatestHttpResponse } from '../hooks/useLatestHttpResponse';
|
||||
import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { router } from '../lib/router';
|
||||
import { Button } from './core/Button';
|
||||
import { Heading } from './core/Heading';
|
||||
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
|
||||
import { HttpStatusTag } from './core/HttpStatusTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { LoadingIcon } from './core/LoadingIcon';
|
||||
import { Separator } from './core/Separator';
|
||||
import { SizeTag } from './core/SizeTag';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { HttpResponsePane } from './HttpResponsePane';
|
||||
|
||||
interface Props {
|
||||
folder: Folder;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
export function FolderLayout({ folder, style }: Props) {
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
const requests = useAtomValue(allRequestsAtom);
|
||||
const folderActions = useFolderActions();
|
||||
const sendAllAction = useMemo(
|
||||
() => folderActions.find((a) => a.label === 'Send All'),
|
||||
[folderActions],
|
||||
);
|
||||
|
||||
const children = useMemo(() => {
|
||||
return [
|
||||
...folders.filter((f) => f.folderId === folder.id),
|
||||
...requests.filter((r) => r.folderId === folder.id),
|
||||
];
|
||||
}, [folder.id, folders, requests]);
|
||||
|
||||
const handleSendAll = useCallback(() => {
|
||||
sendAllAction?.call(folder);
|
||||
}, [sendAllAction, folder]);
|
||||
|
||||
return (
|
||||
<div style={style} className="p-6 pt-4 overflow-y-auto @container">
|
||||
<HStack space={2} alignItems="center">
|
||||
<Icon icon="folder" size="xl" color="secondary" />
|
||||
<Heading level={1}>{resolvedModelName(folder)}</Heading>
|
||||
<HStack className="ml-auto" alignItems="center">
|
||||
<Button
|
||||
rightSlot={<Icon icon="send_horizontal" />}
|
||||
color="secondary"
|
||||
size="sm"
|
||||
variant="border"
|
||||
onClick={handleSendAll}
|
||||
disabled={sendAllAction == null}
|
||||
>
|
||||
Send All
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<Separator className="mt-3 mb-8" />
|
||||
<div className="grid grid-cols-1 @lg:grid-cols-2 @4xl:grid-cols-3 gap-4 min-w-0">
|
||||
{children.map((child) => (
|
||||
<ChildCard key={child.id} child={child} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChildCard({ child }: { child: Folder | HttpRequest | GrpcRequest | WebsocketRequest }) {
|
||||
let card: ReactNode;
|
||||
if (child.model === 'folder') {
|
||||
card = <FolderCard folder={child} />;
|
||||
} else if (child.model === 'http_request') {
|
||||
card = <HttpRequestCard request={child} />;
|
||||
} else if (child.model === 'grpc_request') {
|
||||
card = <RequestCard request={child} />;
|
||||
} else if (child.model === 'websocket_request') {
|
||||
card = <RequestCard request={child} />;
|
||||
} else {
|
||||
card = <div>Unknown model</div>;
|
||||
}
|
||||
|
||||
const navigate = useCallback(async () => {
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId: child.workspaceId },
|
||||
search: (prev) => ({ ...prev, request_id: child.id }),
|
||||
});
|
||||
}, [child.id, child.workspaceId]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'rounded-lg bg-surface-highlight p-3 pt-1 border border-border',
|
||||
'flex flex-col gap-3',
|
||||
)}
|
||||
>
|
||||
<HStack space={2}>
|
||||
{child.model === 'folder' && <Icon icon="folder" size="lg" />}
|
||||
<Heading className="truncate" level={2}>
|
||||
{resolvedModelName(child)}
|
||||
</Heading>
|
||||
<HStack space={0.5} className="ml-auto -mr-1.5">
|
||||
<IconButton
|
||||
color="custom"
|
||||
title="Send Request"
|
||||
size="sm"
|
||||
icon="external_link"
|
||||
className="opacity-70 hover:opacity-100"
|
||||
onClick={navigate}
|
||||
/>
|
||||
<IconButton
|
||||
color="custom"
|
||||
title="Send Request"
|
||||
size="sm"
|
||||
icon="send_horizontal"
|
||||
className="opacity-70 hover:opacity-100"
|
||||
onClick={() => {
|
||||
sendAnyHttpRequest.mutate(child.id);
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
<div className="text-text-subtle">{card}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderCard({ folder }: { folder: Folder }) {
|
||||
return (
|
||||
<div>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId: folder.workspaceId },
|
||||
search: (prev) => {
|
||||
return { ...prev, request_id: null, folder_id: folder.id };
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestCard({ request }: { request: HttpRequest | GrpcRequest | WebsocketRequest }) {
|
||||
return <div>TODO {request.id}</div>;
|
||||
}
|
||||
|
||||
function HttpRequestCard({ request }: { request: HttpRequest }) {
|
||||
const latestResponse = useLatestHttpResponse(request.id);
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-2 grid-cols-[minmax(0,1fr)] gap-2 overflow-hidden">
|
||||
<code className="font-mono text-editor text-info border border-info rounded px-2.5 py-0.5 truncate w-full min-w-0">
|
||||
{request.method} {request.url}
|
||||
</code>
|
||||
{latestResponse ? (
|
||||
<button
|
||||
className="block mr-auto"
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
showDialog({
|
||||
id: 'response-preview',
|
||||
title: 'Response Preview',
|
||||
size: 'md',
|
||||
className: 'h-full',
|
||||
render: () => {
|
||||
return <HttpResponsePane activeRequestId={request.id} />;
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<HStack
|
||||
space={2}
|
||||
alignItems="center"
|
||||
className={classNames(
|
||||
'cursor-default select-none',
|
||||
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
|
||||
'font-mono text-editor border rounded px-1.5 py-0.5 truncate w-full',
|
||||
)}
|
||||
>
|
||||
{latestResponse.state !== 'closed' && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTag showReason response={latestResponse} />
|
||||
<span>•</span>
|
||||
<HttpResponseDurationTag response={latestResponse} />
|
||||
<span>•</span>
|
||||
<SizeTag
|
||||
contentLength={latestResponse.contentLength ?? 0}
|
||||
contentLengthCompressed={latestResponse.contentLength}
|
||||
/>
|
||||
</HStack>
|
||||
</button>
|
||||
) : (
|
||||
<div>No Responses</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
210
apps/yaak-client/components/FolderSettingsDialog.tsx
Normal file
210
apps/yaak-client/components/FolderSettingsDialog.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
createWorkspaceModel,
|
||||
foldersAtom,
|
||||
patchModel,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Fragment, useMemo } from 'react';
|
||||
import { useAuthTab } from '../hooks/useAuthTab';
|
||||
import { useEnvironmentsBreakdown } from '../hooks/useEnvironmentsBreakdown';
|
||||
import { useHeadersTab } from '../hooks/useHeadersTab';
|
||||
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
|
||||
import { useModelAncestors } from '../hooks/useModelAncestors';
|
||||
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
||||
import { hideDialog } from '../lib/dialog';
|
||||
import { CopyIconButton } from './CopyIconButton';
|
||||
import { Button } from './core/Button';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { Icon } from './core/Icon';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { Input } from './core/Input';
|
||||
import { Link } from './core/Link';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import type { TabItem } from './core/Tabs/Tabs';
|
||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
import { EnvironmentEditor } from './EnvironmentEditor';
|
||||
import { HeadersEditor } from './HeadersEditor';
|
||||
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
||||
import { MarkdownEditor } from './MarkdownEditor';
|
||||
|
||||
interface Props {
|
||||
folderId: string | null;
|
||||
tab?: FolderSettingsTab;
|
||||
}
|
||||
|
||||
const TAB_AUTH = 'auth';
|
||||
const TAB_HEADERS = 'headers';
|
||||
const TAB_VARIABLES = 'variables';
|
||||
const TAB_GENERAL = 'general';
|
||||
|
||||
export type FolderSettingsTab =
|
||||
| typeof TAB_AUTH
|
||||
| typeof TAB_HEADERS
|
||||
| typeof TAB_GENERAL
|
||||
| typeof TAB_VARIABLES;
|
||||
|
||||
export function FolderSettingsDialog({ folderId, tab }: Props) {
|
||||
const folders = useAtomValue(foldersAtom);
|
||||
const folder = folders.find((f) => f.id === folderId) ?? null;
|
||||
const ancestors = useModelAncestors(folder);
|
||||
const breadcrumbs = useMemo(() => ancestors.toReversed(), [ancestors]);
|
||||
const authTab = useAuthTab(TAB_AUTH, folder);
|
||||
const headersTab = useHeadersTab(TAB_HEADERS, folder);
|
||||
const inheritedHeaders = useInheritedHeaders(folder);
|
||||
const environments = useEnvironmentsBreakdown();
|
||||
const folderEnvironment = environments.allEnvironments.find(
|
||||
(e) => e.parentModel === 'folder' && e.parentId === folderId,
|
||||
);
|
||||
const numVars = (folderEnvironment?.variables ?? []).filter((v) => v.name).length;
|
||||
|
||||
const tabs = useMemo<TabItem[]>(() => {
|
||||
if (folder == null) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
value: TAB_GENERAL,
|
||||
label: 'General',
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_VARIABLES,
|
||||
label: 'Variables',
|
||||
rightSlot: numVars > 0 ? <CountBadge count={numVars} /> : null,
|
||||
},
|
||||
];
|
||||
}, [authTab, folder, headersTab, numVars]);
|
||||
|
||||
if (folder == null) return null;
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex items-center gap-3 px-6 pr-10 mt-4 mb-2 min-w-0 text-xl">
|
||||
<Icon icon="folder_cog" size="lg" color="secondary" className="flex-shrink-0" />
|
||||
<div className="flex items-center gap-1.5 font-semibold text-text min-w-0 overflow-hidden flex-1">
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<Fragment key={item.id}>
|
||||
{index > 0 && (
|
||||
<Icon
|
||||
icon="chevron_right"
|
||||
size="lg"
|
||||
className="opacity-50 flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<span className="text-text-subtle truncate min-w-0" title={item.name}>
|
||||
{item.name}
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
{breadcrumbs.length > 0 && (
|
||||
<Icon icon="chevron_right" size="lg" className="opacity-50 flex-shrink-0" />
|
||||
)}
|
||||
<span
|
||||
className="whitespace-nowrap"
|
||||
title={folder.name}
|
||||
>
|
||||
{folder.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
defaultValue={tab ?? TAB_GENERAL}
|
||||
label="Folder Settings"
|
||||
className="pt-2 pb-2 pl-3 pr-1 flex-1"
|
||||
layout="horizontal"
|
||||
addBorders
|
||||
tabs={tabs}
|
||||
>
|
||||
<TabContent value={TAB_AUTH} className="overflow-y-auto h-full px-4">
|
||||
<HttpAuthenticationEditor model={folder} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-4">
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)_auto] gap-3 pb-3 h-full">
|
||||
<Input
|
||||
label="Folder Name"
|
||||
defaultValue={folder.name}
|
||||
onChange={(name) => patchModel(folder, { name })}
|
||||
stateKey={`name.${folder.id}`}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="folder-description"
|
||||
placeholder="Folder description"
|
||||
className="border border-border px-2"
|
||||
defaultValue={folder.description}
|
||||
stateKey={`description.${folder.id}`}
|
||||
onChange={(description) => patchModel(folder, { description })}
|
||||
/>
|
||||
<HStack alignItems="center" justifyContent="between" className="w-full">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const didDelete = await deleteModelWithConfirm(folder);
|
||||
if (didDelete) {
|
||||
hideDialog('folder-settings');
|
||||
}
|
||||
}}
|
||||
color="danger"
|
||||
variant="border"
|
||||
size="xs"
|
||||
>
|
||||
Delete Folder
|
||||
</Button>
|
||||
<InlineCode className="flex gap-1 items-center text-primary pl-2.5">
|
||||
{folder.id}
|
||||
<CopyIconButton
|
||||
className="opacity-70 !text-primary"
|
||||
size="2xs"
|
||||
iconSize="sm"
|
||||
title="Copy folder ID"
|
||||
text={folder.id}
|
||||
/>
|
||||
</InlineCode>
|
||||
</HStack>
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS} className="overflow-y-auto h-full px-4">
|
||||
<HeadersEditor
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
forceUpdateKey={folder.id}
|
||||
headers={folder.headers}
|
||||
onChange={(headers) => patchModel(folder, { headers })}
|
||||
stateKey={`headers.${folder.id}`}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_VARIABLES} className="overflow-y-auto h-full px-4">
|
||||
{folderEnvironment == null ? (
|
||||
<EmptyStateText>
|
||||
<VStack alignItems="center" space={1.5}>
|
||||
<p>
|
||||
Override{' '}
|
||||
<Link href="https://yaak.app/docs/using-yaak/environments-and-variables">
|
||||
Variables
|
||||
</Link>{' '}
|
||||
for requests within this folder.
|
||||
</p>
|
||||
<Button
|
||||
variant="border"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await createWorkspaceModel({
|
||||
workspaceId: folder.workspaceId,
|
||||
parentModel: 'folder',
|
||||
parentId: folder.id,
|
||||
model: 'environment',
|
||||
name: 'Folder Environment',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Create Folder Environment
|
||||
</Button>
|
||||
</VStack>
|
||||
</EmptyStateText>
|
||||
) : (
|
||||
<EnvironmentEditor hideName environment={folderEnvironment} />
|
||||
)}
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
57
apps/yaak-client/components/FormMultipartEditor.tsx
Normal file
57
apps/yaak-client/components/FormMultipartEditor.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { Pair, PairEditorProps } from './core/PairEditor';
|
||||
import { PairEditor } from './core/PairEditor';
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
request: HttpRequest;
|
||||
onChange: (body: HttpRequest['body']) => void;
|
||||
};
|
||||
|
||||
export function FormMultipartEditor({ request, forceUpdateKey, onChange }: Props) {
|
||||
const pairs = useMemo<Pair[]>(
|
||||
() =>
|
||||
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
|
||||
enabled: p.enabled,
|
||||
name: p.name,
|
||||
value: p.file ?? p.value,
|
||||
contentType: p.contentType,
|
||||
filename: p.filename,
|
||||
isFile: !!p.file,
|
||||
id: p.id,
|
||||
})),
|
||||
[request.body.form],
|
||||
);
|
||||
|
||||
const handleChange = useCallback<PairEditorProps['onChange']>(
|
||||
(pairs) =>
|
||||
onChange({
|
||||
form: pairs.map((p) => ({
|
||||
enabled: p.enabled,
|
||||
name: p.name,
|
||||
contentType: p.contentType,
|
||||
filename: p.filename,
|
||||
file: p.isFile ? p.value : undefined,
|
||||
value: p.isFile ? undefined : p.value,
|
||||
id: p.id,
|
||||
})),
|
||||
}),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<PairEditor
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
nameAutocompleteVariables
|
||||
nameAutocompleteFunctions
|
||||
allowFileValues
|
||||
allowMultilineValues
|
||||
pairs={pairs}
|
||||
onChange={handleChange}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
stateKey={`multipart.${request.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
46
apps/yaak-client/components/FormUrlencodedEditor.tsx
Normal file
46
apps/yaak-client/components/FormUrlencodedEditor.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { Pair, PairEditorProps } from './core/PairEditor';
|
||||
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
request: HttpRequest;
|
||||
onChange: (headers: HttpRequest['body']) => void;
|
||||
};
|
||||
|
||||
export function FormUrlencodedEditor({ request, forceUpdateKey, onChange }: Props) {
|
||||
const pairs = useMemo<Pair[]>(
|
||||
() =>
|
||||
(Array.isArray(request.body.form) ? request.body.form : []).map((p) => ({
|
||||
enabled: !!p.enabled,
|
||||
name: p.name || '',
|
||||
value: p.value || '',
|
||||
id: p.id,
|
||||
})),
|
||||
[request.body.form],
|
||||
);
|
||||
|
||||
const handleChange = useCallback<PairEditorProps['onChange']>(
|
||||
(pairs) =>
|
||||
onChange({ form: pairs.map((p) => ({ enabled: p.enabled, name: p.name, value: p.value })) }),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<PairOrBulkEditor
|
||||
allowMultilineValues
|
||||
preferenceName="form_urlencoded"
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="entry_name"
|
||||
valuePlaceholder="Value"
|
||||
pairs={pairs}
|
||||
onChange={handleChange}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
stateKey={`urlencoded.${request.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
38
apps/yaak-client/components/GlobalHooks.tsx
Normal file
38
apps/yaak-client/components/GlobalHooks.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { activeRequestAtom } from '../hooks/useActiveRequest';
|
||||
import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace';
|
||||
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
|
||||
import { useHotKey, useSubscribeHotKeys } from '../hooks/useHotKey';
|
||||
import { useSubscribeHttpAuthentication } from '../hooks/useHttpAuthentication';
|
||||
import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting';
|
||||
import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels';
|
||||
import { useSyncZoomSetting } from '../hooks/useSyncZoomSetting';
|
||||
import { useSubscribeTemplateFunctions } from '../hooks/useTemplateFunctions';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { renameModelWithPrompt } from '../lib/renameModelWithPrompt';
|
||||
|
||||
export function GlobalHooks() {
|
||||
useSyncZoomSetting();
|
||||
useSyncFontSizeSetting();
|
||||
|
||||
useSubscribeActiveWorkspaceId();
|
||||
|
||||
useSyncWorkspaceChildModels();
|
||||
useSubscribeTemplateFunctions();
|
||||
useSubscribeHttpAuthentication();
|
||||
|
||||
// Other useful things
|
||||
useActiveWorkspaceChangedToast();
|
||||
useSubscribeHotKeys();
|
||||
|
||||
useHotKey(
|
||||
'request.rename',
|
||||
async () => {
|
||||
const model = jotaiStore.get(activeRequestAtom);
|
||||
if (model == null) return;
|
||||
await renameModelWithPrompt(model);
|
||||
},
|
||||
{ allowDefault: true },
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
127
apps/yaak-client/components/GrpcConnectionLayout.tsx
Normal file
127
apps/yaak-client/components/GrpcConnectionLayout.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useGrpc } from '../hooks/useGrpc';
|
||||
import { useGrpcProtoFiles } from '../hooks/useGrpcProtoFiles';
|
||||
import { activeGrpcConnectionAtom, useGrpcEvents } from '../hooks/usePinnedGrpcConnection';
|
||||
import { workspaceLayoutAtom } from '../lib/atoms';
|
||||
import { Banner } from './core/Banner';
|
||||
import { HotkeyList } from './core/HotkeyList';
|
||||
import { SplitLayout } from './core/SplitLayout';
|
||||
import { GrpcRequestPane } from './GrpcRequestPane';
|
||||
import { GrpcResponsePane } from './GrpcResponsePane';
|
||||
|
||||
interface Props {
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
const emptyArray: string[] = [];
|
||||
|
||||
export function GrpcConnectionLayout({ style }: Props) {
|
||||
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
|
||||
const activeRequest = useActiveRequest('grpc_request');
|
||||
const activeConnection = useAtomValue(activeGrpcConnectionAtom);
|
||||
const grpcEvents = useGrpcEvents(activeConnection?.id ?? null);
|
||||
const protoFilesKv = useGrpcProtoFiles(activeRequest?.id ?? null);
|
||||
const protoFiles = protoFilesKv.value ?? emptyArray;
|
||||
const grpc = useGrpc(activeRequest, activeConnection, protoFiles);
|
||||
|
||||
const services = grpc.reflect.data ?? null;
|
||||
useEffect(() => {
|
||||
if (services == null || activeRequest == null) return;
|
||||
const s = services.find((s) => s.name === activeRequest.service);
|
||||
if (s == null) {
|
||||
patchModel(activeRequest, {
|
||||
service: services[0]?.name ?? null,
|
||||
method: services[0]?.methods[0]?.name ?? null,
|
||||
}).catch(console.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const m = s.methods.find((m) => m.name === activeRequest.method);
|
||||
if (m == null) {
|
||||
patchModel(activeRequest, {
|
||||
method: s.methods[0]?.name ?? null,
|
||||
}).catch(console.error);
|
||||
return;
|
||||
}
|
||||
}, [activeRequest, services]);
|
||||
|
||||
const activeMethod = useMemo(() => {
|
||||
if (services == null || activeRequest == null) return null;
|
||||
|
||||
const s = services.find((s) => s.name === activeRequest.service);
|
||||
if (s == null) return null;
|
||||
return s.methods.find((m) => m.name === activeRequest.method);
|
||||
}, [activeRequest, services]);
|
||||
|
||||
const methodType:
|
||||
| 'unary'
|
||||
| 'server_streaming'
|
||||
| 'client_streaming'
|
||||
| 'streaming'
|
||||
| 'no-schema'
|
||||
| 'no-method' = useMemo(() => {
|
||||
if (services == null) return 'no-schema';
|
||||
if (activeMethod == null) return 'no-method';
|
||||
if (activeMethod.clientStreaming && activeMethod.serverStreaming) return 'streaming';
|
||||
if (activeMethod.clientStreaming) return 'client_streaming';
|
||||
if (activeMethod.serverStreaming) return 'server_streaming';
|
||||
return 'unary';
|
||||
}, [activeMethod, services]);
|
||||
|
||||
if (activeRequest == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SplitLayout
|
||||
name="grpc_layout"
|
||||
className="p-3 gap-1.5"
|
||||
style={style}
|
||||
layout={workspaceLayout}
|
||||
firstSlot={({ style }) => (
|
||||
<GrpcRequestPane
|
||||
style={style}
|
||||
activeRequest={activeRequest}
|
||||
protoFiles={protoFiles}
|
||||
methodType={methodType}
|
||||
isStreaming={grpc.isStreaming}
|
||||
onGo={grpc.go.mutate}
|
||||
onCommit={grpc.commit.mutate}
|
||||
onCancel={grpc.cancel.mutate}
|
||||
onSend={grpc.send.mutate}
|
||||
services={services ?? null}
|
||||
reflectionError={grpc.reflect.error as string | undefined}
|
||||
reflectionLoading={grpc.reflect.isFetching}
|
||||
/>
|
||||
)}
|
||||
secondSlot={({ style }) =>
|
||||
!grpc.go.isPending && (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
'x-theme-responsePane',
|
||||
'max-h-full h-full grid grid-rows-[minmax(0,1fr)] grid-cols-1',
|
||||
'bg-surface rounded-md border border-border-subtle',
|
||||
'shadow relative',
|
||||
)}
|
||||
>
|
||||
{grpc.go.error ? (
|
||||
<Banner color="danger" className="m-2">
|
||||
{grpc.go.error}
|
||||
</Banner>
|
||||
) : grpcEvents.length >= 0 ? (
|
||||
<GrpcResponsePane activeRequest={activeRequest} methodType={methodType} />
|
||||
) : (
|
||||
<HotkeyList hotkeys={['request.send', 'sidebar.focus', 'url_bar.focus']} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
187
apps/yaak-client/components/GrpcEditor.tsx
Normal file
187
apps/yaak-client/components/GrpcEditor.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { jsonLanguage } from '@codemirror/lang-json';
|
||||
import { linter } from '@codemirror/lint';
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type { GrpcRequest } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import {
|
||||
handleRefresh,
|
||||
jsonCompletion,
|
||||
jsonSchemaLinter,
|
||||
stateExtensions,
|
||||
updateSchema,
|
||||
} from 'codemirror-json-schema';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReflectResponseService } from '../hooks/useGrpc';
|
||||
import { showAlert } from '../lib/alert';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { pluralizeCount } from '../lib/pluralize';
|
||||
import { Button } from './core/Button';
|
||||
import type { EditorProps } from './core/Editor/Editor';
|
||||
import { Editor } from './core/Editor/LazyEditor';
|
||||
import { FormattedError } from './core/FormattedError';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { GrpcProtoSelectionDialog } from './GrpcProtoSelectionDialog';
|
||||
|
||||
type Props = Pick<EditorProps, 'heightMode' | 'onChange' | 'className' | 'forceUpdateKey'> & {
|
||||
services: ReflectResponseService[] | null;
|
||||
reflectionError?: string;
|
||||
reflectionLoading?: boolean;
|
||||
request: GrpcRequest;
|
||||
protoFiles: string[];
|
||||
};
|
||||
|
||||
export function GrpcEditor({
|
||||
services,
|
||||
reflectionError,
|
||||
reflectionLoading,
|
||||
request,
|
||||
protoFiles,
|
||||
...extraEditorProps
|
||||
}: Props) {
|
||||
const [editorView, setEditorView] = useState<EditorView | null>(null);
|
||||
const handleInitEditorViewRef = useCallback((h: EditorView | null) => {
|
||||
setEditorView(h);
|
||||
}, []);
|
||||
|
||||
// Find the schema for the selected service and method and update the editor
|
||||
useEffect(() => {
|
||||
if (
|
||||
editorView == null ||
|
||||
services === null ||
|
||||
request.service === null ||
|
||||
request.method === null
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const s = services.find((s) => s.name === request.service);
|
||||
if (s == null) {
|
||||
console.log('Failed to find service', { service: request.service, services });
|
||||
showAlert({
|
||||
id: 'grpc-find-service-error',
|
||||
title: "Couldn't Find Service",
|
||||
body: (
|
||||
<>
|
||||
Failed to find service <InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const schema = s.methods.find((m) => m.name === request.method)?.schema;
|
||||
if (request.method != null && schema == null) {
|
||||
console.log('Failed to find method', { method: request.method, methods: s?.methods });
|
||||
showAlert({
|
||||
id: 'grpc-find-schema-error',
|
||||
title: "Couldn't Find Method",
|
||||
body: (
|
||||
<>
|
||||
Failed to find method <InlineCode>{request.method}</InlineCode> for{' '}
|
||||
<InlineCode>{request.service}</InlineCode> in schema
|
||||
</>
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (schema == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
updateSchema(editorView, JSON.parse(schema));
|
||||
} catch (err) {
|
||||
showAlert({
|
||||
id: 'grpc-parse-schema-error',
|
||||
title: 'Failed to Parse Schema',
|
||||
body: (
|
||||
<VStack space={4}>
|
||||
<p>
|
||||
For service <InlineCode>{request.service}</InlineCode> and method{' '}
|
||||
<InlineCode>{request.method}</InlineCode>
|
||||
</p>
|
||||
<FormattedError>{String(err)}</FormattedError>
|
||||
</VStack>
|
||||
),
|
||||
});
|
||||
}
|
||||
}, [editorView, services, request.method, request.service]);
|
||||
|
||||
const extraExtensions = useMemo(
|
||||
() => [
|
||||
linter(jsonSchemaLinter(), {
|
||||
delay: 200,
|
||||
needsRefresh: handleRefresh,
|
||||
}),
|
||||
jsonLanguage.data.of({
|
||||
autocomplete: jsonCompletion(),
|
||||
}),
|
||||
stateExtensions({}),
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const reflectionUnavailable = reflectionError?.match(/unimplemented/i);
|
||||
reflectionError = reflectionUnavailable ? undefined : reflectionError;
|
||||
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
<div key="reflection" className={classNames(services == null && '!opacity-100')}>
|
||||
<Button
|
||||
size="xs"
|
||||
color={
|
||||
reflectionLoading
|
||||
? 'secondary'
|
||||
: reflectionUnavailable
|
||||
? 'info'
|
||||
: reflectionError
|
||||
? 'danger'
|
||||
: 'secondary'
|
||||
}
|
||||
isLoading={reflectionLoading}
|
||||
onClick={() => {
|
||||
showDialog({
|
||||
title: 'Configure Schema',
|
||||
size: 'md',
|
||||
id: 'reflection-failed',
|
||||
render: ({ hide }) => <GrpcProtoSelectionDialog onDone={hide} />,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{reflectionLoading
|
||||
? 'Inspecting Schema'
|
||||
: reflectionUnavailable
|
||||
? 'Select Proto Files'
|
||||
: reflectionError
|
||||
? 'Server Error'
|
||||
: protoFiles.length > 0
|
||||
? pluralizeCount('File', protoFiles.length)
|
||||
: services != null && protoFiles.length === 0
|
||||
? 'Schema Detected'
|
||||
: 'Select Schema'}
|
||||
</Button>
|
||||
</div>,
|
||||
],
|
||||
[protoFiles.length, reflectionError, reflectionLoading, reflectionUnavailable, services],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full grid grid-cols-1 grid-rows-[minmax(0,100%)_auto_auto_minmax(0,auto)]">
|
||||
<Editor
|
||||
setRef={handleInitEditorViewRef}
|
||||
language="json"
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
defaultValue={request.message}
|
||||
heightMode="auto"
|
||||
placeholder="..."
|
||||
extraExtensions={extraExtensions}
|
||||
actions={actions}
|
||||
stateKey={`grpc_message.${request.id}`}
|
||||
{...extraEditorProps}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
186
apps/yaak-client/components/GrpcProtoSelectionDialog.tsx
Normal file
186
apps/yaak-client/components/GrpcProtoSelectionDialog.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import type { GrpcRequest } from '@yaakapp-internal/models';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { useGrpc } from '../hooks/useGrpc';
|
||||
import { useGrpcProtoFiles } from '../hooks/useGrpcProtoFiles';
|
||||
import { pluralizeCount } from '../lib/pluralize';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { Link } from './core/Link';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function GrpcProtoSelectionDialog(props: Props) {
|
||||
const request = useActiveRequest();
|
||||
if (request?.model !== 'grpc_request') return null;
|
||||
|
||||
return <GrpcProtoSelectionDialogWithRequest request={request} {...props} />;
|
||||
}
|
||||
|
||||
function GrpcProtoSelectionDialogWithRequest({ request }: Props & { request: GrpcRequest }) {
|
||||
const protoFilesKv = useGrpcProtoFiles(request.id);
|
||||
const protoFiles = protoFilesKv.value ?? [];
|
||||
const grpc = useGrpc(request, null, protoFiles);
|
||||
const services = grpc.reflect.data;
|
||||
const serverReflection = protoFiles.length === 0 && services != null;
|
||||
let reflectError = grpc.reflect.error ?? null;
|
||||
const reflectionUnimplemented = `${reflectError}`.match(/unimplemented/i);
|
||||
|
||||
if (reflectionUnimplemented) {
|
||||
reflectError = null;
|
||||
}
|
||||
|
||||
if (request == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack className="flex-col-reverse mb-3" space={3}>
|
||||
{/* Buttons on top so they get focus first */}
|
||||
<HStack space={2} justifyContent="start" className="flex-row-reverse mt-3">
|
||||
<Button
|
||||
color="primary"
|
||||
variant="border"
|
||||
onClick={async () => {
|
||||
const selected = await open({
|
||||
title: 'Select Proto Files',
|
||||
multiple: true,
|
||||
filters: [{ name: 'Proto Files', extensions: ['proto'] }],
|
||||
});
|
||||
if (selected == null) return;
|
||||
|
||||
const newFiles = selected.filter((p) => !protoFiles.includes(p));
|
||||
await protoFilesKv.set([...protoFiles, ...newFiles]);
|
||||
await grpc.reflect.refetch();
|
||||
}}
|
||||
>
|
||||
Add Proto Files
|
||||
</Button>
|
||||
<Button
|
||||
variant="border"
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
const selected = await open({
|
||||
title: 'Select Proto Directory',
|
||||
directory: true,
|
||||
});
|
||||
if (selected == null) return;
|
||||
|
||||
await protoFilesKv.set([...protoFiles.filter((f) => f !== selected), selected]);
|
||||
await grpc.reflect.refetch();
|
||||
}}
|
||||
>
|
||||
Add Import Folders
|
||||
</Button>
|
||||
<Button
|
||||
isLoading={grpc.reflect.isFetching}
|
||||
disabled={grpc.reflect.isFetching}
|
||||
variant="border"
|
||||
color="secondary"
|
||||
onClick={() => grpc.reflect.refetch()}
|
||||
>
|
||||
Refresh Schema
|
||||
</Button>
|
||||
</HStack>
|
||||
<VStack space={5}>
|
||||
{reflectError && (
|
||||
<Banner color="warning">
|
||||
<h1 className="font-bold">
|
||||
Reflection failed on URL <InlineCode>{request.url || 'n/a'}</InlineCode>
|
||||
</h1>
|
||||
<p>{reflectError.trim()}</p>
|
||||
</Banner>
|
||||
)}
|
||||
{!serverReflection && services != null && services.length > 0 && (
|
||||
<Banner className="flex flex-col gap-2">
|
||||
<p>
|
||||
Found services{' '}
|
||||
{services?.slice(0, 5).map((s, i) => {
|
||||
return (
|
||||
<span key={s.name + s.methods.join(',')}>
|
||||
<InlineCode>{s.name}</InlineCode>
|
||||
{i === services.length - 1 ? '' : i === services.length - 2 ? ' and ' : ', '}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
{services?.length > 5 && pluralizeCount('other', services?.length - 5)}
|
||||
</p>
|
||||
</Banner>
|
||||
)}
|
||||
{serverReflection && services != null && services.length > 0 && (
|
||||
<Banner className="flex flex-col gap-2">
|
||||
<p>
|
||||
Server reflection found services
|
||||
{services?.map((s, i) => {
|
||||
return (
|
||||
<span key={s.name + s.methods.join(',')}>
|
||||
<InlineCode>{s.name}</InlineCode>
|
||||
{i === services.length - 1 ? '' : i === services.length - 2 ? ' and ' : ', '}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
. You can override this schema by manually selecting <InlineCode>*.proto</InlineCode>{' '}
|
||||
files.
|
||||
</p>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
{protoFiles.length > 0 && (
|
||||
<table className="w-full divide-y divide-surface-highlight">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-text-subtlest" colSpan={3}>
|
||||
Added File Paths
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{protoFiles.map((f, i) => {
|
||||
const parts = f.split('/');
|
||||
return (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
<tr key={f + i} className="group">
|
||||
<td>
|
||||
<Icon icon={f.endsWith('.proto') ? 'file_code' : 'folder_code'} />
|
||||
</td>
|
||||
<td className="pl-1 font-mono text-sm" title={f}>
|
||||
{parts.length > 3 && '.../'}
|
||||
{parts.slice(-3).join('/')}
|
||||
</td>
|
||||
<td className="w-0 py-0.5">
|
||||
<IconButton
|
||||
title="Remove file"
|
||||
variant="border"
|
||||
size="xs"
|
||||
icon="trash"
|
||||
className="my-0.5 ml-auto opacity-50 transition-opacity group-hover:opacity-100"
|
||||
onClick={async () => {
|
||||
await protoFilesKv.set(protoFiles.filter((p) => p !== f));
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{reflectionUnimplemented && protoFiles.length === 0 && (
|
||||
<Banner>
|
||||
<InlineCode>{request.url}</InlineCode> doesn't implement{' '}
|
||||
<Link href="https://github.com/grpc/grpc/blob/9aa3c5835a4ed6afae9455b63ed45c761d695bca/doc/server-reflection.md">
|
||||
Server Reflection
|
||||
</Link>{' '}
|
||||
. Please manually add the <InlineCode>.proto</InlineCode> file to get started.
|
||||
</Banner>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
307
apps/yaak-client/components/GrpcRequestPane.tsx
Normal file
307
apps/yaak-client/components/GrpcRequestPane.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import { type GrpcRequest, type HttpRequestHeader, patchModel } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { useAuthTab } from '../hooks/useAuthTab';
|
||||
import { useContainerSize } from '../hooks/useContainerQuery';
|
||||
import type { ReflectResponseService } from '../hooks/useGrpc';
|
||||
import { useHeadersTab } from '../hooks/useHeadersTab';
|
||||
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { Button } from './core/Button';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import { RadioDropdown } from './core/RadioDropdown';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import type { TabItem } from './core/Tabs/Tabs';
|
||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { GrpcEditor } from './GrpcEditor';
|
||||
import { HeadersEditor } from './HeadersEditor';
|
||||
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
||||
import { MarkdownEditor } from './MarkdownEditor';
|
||||
import { UrlBar } from './UrlBar';
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
activeRequest: GrpcRequest;
|
||||
protoFiles: string[];
|
||||
reflectionError?: string;
|
||||
reflectionLoading?: boolean;
|
||||
methodType:
|
||||
| 'unary'
|
||||
| 'client_streaming'
|
||||
| 'server_streaming'
|
||||
| 'streaming'
|
||||
| 'no-schema'
|
||||
| 'no-method';
|
||||
isStreaming: boolean;
|
||||
onCommit: () => void;
|
||||
onCancel: () => void;
|
||||
onSend: (v: { message: string }) => void;
|
||||
onGo: () => void;
|
||||
services: ReflectResponseService[] | null;
|
||||
}
|
||||
|
||||
const TAB_MESSAGE = 'message';
|
||||
const TAB_METADATA = 'metadata';
|
||||
const TAB_AUTH = 'auth';
|
||||
const TAB_DESCRIPTION = 'description';
|
||||
|
||||
export function GrpcRequestPane({
|
||||
style,
|
||||
services,
|
||||
methodType,
|
||||
activeRequest,
|
||||
protoFiles,
|
||||
reflectionError,
|
||||
reflectionLoading,
|
||||
isStreaming,
|
||||
onGo,
|
||||
onCommit,
|
||||
onCancel,
|
||||
onSend,
|
||||
}: Props) {
|
||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
||||
const metadataTab = useHeadersTab(TAB_METADATA, activeRequest, 'Metadata');
|
||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
||||
|
||||
const urlContainerEl = useRef<HTMLDivElement>(null);
|
||||
const { width: paneWidth } = useContainerSize(urlContainerEl);
|
||||
|
||||
const handleChangeUrl = useCallback(
|
||||
(url: string) => patchModel(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleChangeMessage = useCallback(
|
||||
(message: string) => patchModel(activeRequest, { message }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const select = useMemo(() => {
|
||||
const options =
|
||||
services?.flatMap((s) =>
|
||||
s.methods.map((m) => ({
|
||||
label: `${s.name.split('.').pop() ?? s.name}/${m.name}`,
|
||||
value: `${s.name}/${m.name}`,
|
||||
})),
|
||||
) ?? [];
|
||||
const value = `${activeRequest?.service ?? ''}/${activeRequest?.method ?? ''}`;
|
||||
return { value, options };
|
||||
}, [activeRequest?.method, activeRequest?.service, services]);
|
||||
|
||||
const handleChangeService = useCallback(
|
||||
async (v: string) => {
|
||||
const [serviceName, methodName] = v.split('/', 2);
|
||||
if (serviceName == null || methodName == null) throw new Error('Should never happen');
|
||||
await patchModel(activeRequest, {
|
||||
service: serviceName,
|
||||
method: methodName,
|
||||
});
|
||||
},
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleConnect = useCallback(async () => {
|
||||
if (activeRequest == null) return;
|
||||
|
||||
if (activeRequest.service == null || activeRequest.method == null) {
|
||||
alert({
|
||||
id: 'grpc-invalid-service-method',
|
||||
title: 'Error',
|
||||
body: 'Service or method not selected',
|
||||
});
|
||||
}
|
||||
onGo();
|
||||
}, [activeRequest, onGo]);
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
if (activeRequest == null) return;
|
||||
onSend({ message: activeRequest.message });
|
||||
}, [activeRequest, onSend]);
|
||||
|
||||
const tabs: TabItem[] = useMemo(
|
||||
() => [
|
||||
{ value: TAB_MESSAGE, label: 'Message' },
|
||||
...metadataTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: 'Info',
|
||||
rightSlot: activeRequest.description && <CountBadge count={true} />,
|
||||
},
|
||||
],
|
||||
[activeRequest.description, authTab, metadataTab],
|
||||
);
|
||||
|
||||
const handleMetadataChange = useCallback(
|
||||
(metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(description: string) => patchModel(activeRequest, { description }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack style={style}>
|
||||
<div
|
||||
ref={urlContainerEl}
|
||||
className={classNames(
|
||||
'grid grid-cols-[minmax(0,1fr)_auto] gap-1.5',
|
||||
paneWidth === 0 && 'opacity-0',
|
||||
paneWidth > 0 && paneWidth < 400 && '!grid-cols-1',
|
||||
)}
|
||||
>
|
||||
<UrlBar
|
||||
key={forceUpdateKey}
|
||||
url={activeRequest.url ?? ''}
|
||||
submitIcon={null}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
placeholder="localhost:50051"
|
||||
onSend={handleConnect}
|
||||
onUrlChange={handleChangeUrl}
|
||||
onCancel={onCancel}
|
||||
isLoading={isStreaming}
|
||||
stateKey={`grpc_url.${activeRequest.id}`}
|
||||
/>
|
||||
<HStack space={1.5}>
|
||||
<RadioDropdown
|
||||
value={select.value}
|
||||
onChange={handleChangeService}
|
||||
items={select.options.map((o) => ({
|
||||
label: o.label,
|
||||
value: o.value,
|
||||
type: 'default',
|
||||
shortLabel: o.label,
|
||||
}))}
|
||||
itemsAfter={[
|
||||
{
|
||||
label: 'Refresh',
|
||||
type: 'default',
|
||||
leftSlot: <Icon size="sm" icon="refresh" />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="border"
|
||||
rightSlot={<Icon size="sm" icon="chevron_down" />}
|
||||
disabled={isStreaming || services == null}
|
||||
className={classNames(
|
||||
'font-mono text-editor min-w-[5rem] !ring-0',
|
||||
paneWidth < 400 && 'flex-1',
|
||||
)}
|
||||
>
|
||||
{select.options.find((o) => o.value === select.value)?.label ?? 'No Schema'}
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
{methodType === 'client_streaming' || methodType === 'streaming' ? (
|
||||
<>
|
||||
{isStreaming && (
|
||||
<>
|
||||
<IconButton
|
||||
variant="border"
|
||||
size="sm"
|
||||
title="Cancel"
|
||||
onClick={onCancel}
|
||||
icon="x"
|
||||
/>
|
||||
<IconButton
|
||||
variant="border"
|
||||
size="sm"
|
||||
title="Commit"
|
||||
onClick={onCommit}
|
||||
icon="check"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="border"
|
||||
title={isStreaming ? 'Connect' : 'Send'}
|
||||
hotkeyAction="request.send"
|
||||
onClick={isStreaming ? handleSend : handleConnect}
|
||||
icon={isStreaming ? 'send_horizontal' : 'arrow_up_down'}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="border"
|
||||
title={methodType === 'unary' ? 'Send' : 'Connect'}
|
||||
hotkeyAction="request.send"
|
||||
onClick={isStreaming ? onCancel : handleConnect}
|
||||
disabled={methodType === 'no-schema' || methodType === 'no-method'}
|
||||
icon={
|
||||
isStreaming
|
||||
? 'x'
|
||||
: methodType.includes('streaming')
|
||||
? 'arrow_up_down'
|
||||
: 'send_horizontal'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
<Tabs
|
||||
label="Request"
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 !mb-1.5"
|
||||
storageKey="grpc_request_tabs"
|
||||
activeTabKey={activeRequest.id}
|
||||
>
|
||||
<TabContent value="message">
|
||||
<GrpcEditor
|
||||
onChange={handleChangeMessage}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
services={services}
|
||||
reflectionError={reflectionError}
|
||||
reflectionLoading={reflectionLoading}
|
||||
request={activeRequest}
|
||||
protoFiles={protoFiles}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_AUTH}>
|
||||
<HttpAuthenticationEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_METADATA}>
|
||||
<HeadersEditor
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
headers={activeRequest.metadata}
|
||||
stateKey={`headers.${activeRequest.id}`}
|
||||
onChange={handleMetadataChange}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_DESCRIPTION}>
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||
<PlainInput
|
||||
label="Request Name"
|
||||
hideLabel
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
defaultValue={activeRequest.name}
|
||||
className="font-sans !text-xl !px-0"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
placeholder="Request description"
|
||||
defaultValue={activeRequest.description}
|
||||
stateKey={`description.${activeRequest.id}`}
|
||||
onChange={handleDescriptionChange}
|
||||
/>
|
||||
</div>
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
250
apps/yaak-client/components/GrpcResponsePane.tsx
Normal file
250
apps/yaak-client/components/GrpcResponsePane.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
import type { GrpcEvent, GrpcRequest } from '@yaakapp-internal/models';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
activeGrpcConnectionAtom,
|
||||
activeGrpcConnections,
|
||||
pinnedGrpcConnectionIdAtom,
|
||||
useGrpcEvents,
|
||||
} from '../hooks/usePinnedGrpcConnection';
|
||||
import { useStateWithDeps } from '../hooks/useStateWithDeps';
|
||||
import { Button } from './core/Button';
|
||||
import { Editor } from './core/Editor/LazyEditor';
|
||||
import { EventDetailHeader, EventViewer } from './core/EventViewer';
|
||||
import { EventViewerRow } from './core/EventViewerRow';
|
||||
import { HotkeyList } from './core/HotkeyList';
|
||||
import { Icon, type IconProps } from './core/Icon';
|
||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||
import { LoadingIcon } from './core/LoadingIcon';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { RecentGrpcConnectionsDropdown } from './RecentGrpcConnectionsDropdown';
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
activeRequest: GrpcRequest;
|
||||
methodType:
|
||||
| 'unary'
|
||||
| 'client_streaming'
|
||||
| 'server_streaming'
|
||||
| 'streaming'
|
||||
| 'no-schema'
|
||||
| 'no-method';
|
||||
}
|
||||
|
||||
export function GrpcResponsePane({ style, methodType, activeRequest }: Props) {
|
||||
const [activeEventIndex, setActiveEventIndex] = useState<number | null>(null);
|
||||
const [showLarge, setShowLarge] = useStateWithDeps<boolean>(false, [activeRequest.id]);
|
||||
const [showingLarge, setShowingLarge] = useState<boolean>(false);
|
||||
const connections = useAtomValue(activeGrpcConnections);
|
||||
const activeConnection = useAtomValue(activeGrpcConnectionAtom);
|
||||
const events = useGrpcEvents(activeConnection?.id ?? null);
|
||||
const setPinnedGrpcConnectionId = useSetAtom(pinnedGrpcConnectionIdAtom);
|
||||
|
||||
const activeEvent = useMemo(
|
||||
() => (activeEventIndex != null ? events[activeEventIndex] : null),
|
||||
[activeEventIndex, events],
|
||||
);
|
||||
|
||||
// Set the active message to the first message received if unary
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
useEffect(() => {
|
||||
if (events.length === 0 || activeEvent != null || methodType !== 'unary') {
|
||||
return;
|
||||
}
|
||||
const firstServerMessageIndex = events.findIndex((m) => m.eventType === 'server_message');
|
||||
if (firstServerMessageIndex !== -1) {
|
||||
setActiveEventIndex(firstServerMessageIndex);
|
||||
}
|
||||
}, [events.length]);
|
||||
|
||||
if (activeConnection == null) {
|
||||
return (
|
||||
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
|
||||
);
|
||||
}
|
||||
|
||||
const header = (
|
||||
<HStack className="pl-3 mb-1 font-mono text-sm text-text-subtle overflow-x-auto hide-scrollbars">
|
||||
<HStack space={2}>
|
||||
<span className="whitespace-nowrap">{events.length} Messages</span>
|
||||
{activeConnection.state !== 'closed' && (
|
||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||
)}
|
||||
</HStack>
|
||||
<div className="ml-auto">
|
||||
<RecentGrpcConnectionsDropdown
|
||||
connections={connections}
|
||||
activeConnection={activeConnection}
|
||||
onPinnedConnectionId={setPinnedGrpcConnectionId}
|
||||
/>
|
||||
</div>
|
||||
</HStack>
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={style} className="h-full">
|
||||
<ErrorBoundary name="GRPC Events">
|
||||
<EventViewer
|
||||
events={events}
|
||||
getEventKey={(event) => event.id}
|
||||
error={activeConnection.error}
|
||||
header={header}
|
||||
splitLayoutName="grpc_events"
|
||||
defaultRatio={0.4}
|
||||
renderRow={({ event, isActive, onClick }) => (
|
||||
<GrpcEventRow event={event} isActive={isActive} onClick={onClick} />
|
||||
)}
|
||||
renderDetail={({ event, onClose }) => (
|
||||
<GrpcEventDetail
|
||||
event={event}
|
||||
showLarge={showLarge}
|
||||
showingLarge={showingLarge}
|
||||
setShowLarge={setShowLarge}
|
||||
setShowingLarge={setShowingLarge}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GrpcEventRow({
|
||||
event,
|
||||
isActive,
|
||||
onClick,
|
||||
}: {
|
||||
event: GrpcEvent;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { eventType, status, content, error } = event;
|
||||
const display = getEventDisplay(eventType, status);
|
||||
|
||||
return (
|
||||
<EventViewerRow
|
||||
isActive={isActive}
|
||||
onClick={onClick}
|
||||
icon={<Icon color={display.color} title={display.title} icon={display.icon} />}
|
||||
content={
|
||||
<span className="text-xs">
|
||||
{content.slice(0, 1000)}
|
||||
{error && <span className="text-warning"> ({error})</span>}
|
||||
</span>
|
||||
}
|
||||
timestamp={event.createdAt}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function GrpcEventDetail({
|
||||
event,
|
||||
showLarge,
|
||||
showingLarge,
|
||||
setShowLarge,
|
||||
setShowingLarge,
|
||||
onClose,
|
||||
}: {
|
||||
event: GrpcEvent;
|
||||
showLarge: boolean;
|
||||
showingLarge: boolean;
|
||||
setShowLarge: (v: boolean) => void;
|
||||
setShowingLarge: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (event.eventType === 'client_message' || event.eventType === 'server_message') {
|
||||
const title = `Message ${event.eventType === 'client_message' ? 'Sent' : 'Received'}`;
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<EventDetailHeader
|
||||
title={title}
|
||||
timestamp={event.createdAt}
|
||||
copyText={event.content}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{!showLarge && event.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>
|
||||
) : (
|
||||
<Editor
|
||||
language="json"
|
||||
defaultValue={event.content ?? ''}
|
||||
wrapLines={false}
|
||||
readOnly={true}
|
||||
stateKey={null}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error or connection_end - show metadata/trailers
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)]">
|
||||
<EventDetailHeader title={event.content} timestamp={event.createdAt} onClose={onClose} />
|
||||
{event.error && (
|
||||
<div className="select-text cursor-text text-sm font-mono py-1 text-warning">
|
||||
{event.error}
|
||||
</div>
|
||||
)}
|
||||
<div className="py-2 h-full">
|
||||
{Object.keys(event.metadata).length === 0 ? (
|
||||
<EmptyStateText>
|
||||
No {event.eventType === 'connection_end' ? 'trailers' : 'metadata'}
|
||||
</EmptyStateText>
|
||||
) : (
|
||||
<KeyValueRows>
|
||||
{Object.entries(event.metadata).map(([key, value]) => (
|
||||
<KeyValueRow key={key} label={key}>
|
||||
{value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getEventDisplay(
|
||||
eventType: GrpcEvent['eventType'],
|
||||
status: GrpcEvent['status'],
|
||||
): { icon: IconProps['icon']; color: IconProps['color']; title: string } {
|
||||
if (eventType === 'server_message') {
|
||||
return { icon: 'arrow_big_down_dash', color: 'info', title: 'Server message' };
|
||||
}
|
||||
if (eventType === 'client_message') {
|
||||
return { icon: 'arrow_big_up_dash', color: 'primary', title: 'Client message' };
|
||||
}
|
||||
if (eventType === 'error' || (status != null && status > 0)) {
|
||||
return { icon: 'alert_triangle', color: 'danger', title: 'Error' };
|
||||
}
|
||||
if (eventType === 'connection_end') {
|
||||
return { icon: 'check', color: 'success', title: 'Connection response' };
|
||||
}
|
||||
return { icon: 'info', color: undefined, title: 'Event' };
|
||||
}
|
||||
83
apps/yaak-client/components/HeaderSize.tsx
Normal file
83
apps/yaak-client/components/HeaderSize.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useIsFullscreen } from '../hooks/useIsFullscreen';
|
||||
import { HEADER_SIZE_LG, HEADER_SIZE_MD, WINDOW_CONTROLS_WIDTH } from '../lib/constants';
|
||||
import { WindowControls } from './WindowControls';
|
||||
|
||||
interface HeaderSizeProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children?: ReactNode;
|
||||
size: 'md' | 'lg';
|
||||
ignoreControlsSpacing?: boolean;
|
||||
onlyXWindowControl?: boolean;
|
||||
hideControls?: boolean;
|
||||
}
|
||||
|
||||
export function HeaderSize({
|
||||
className,
|
||||
style,
|
||||
size,
|
||||
ignoreControlsSpacing,
|
||||
onlyXWindowControl,
|
||||
children,
|
||||
hideControls,
|
||||
}: HeaderSizeProps) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const isFullscreen = useIsFullscreen();
|
||||
const nativeTitlebar = settings.useNativeTitlebar;
|
||||
const finalStyle = useMemo<CSSProperties>(() => {
|
||||
const s = { ...style };
|
||||
|
||||
// Set the height (use min-height because scaling font size may make it larger
|
||||
if (size === 'md') s.minHeight = HEADER_SIZE_MD;
|
||||
if (size === 'lg') s.minHeight = HEADER_SIZE_LG;
|
||||
|
||||
if (nativeTitlebar) {
|
||||
// No style updates when using native titlebar
|
||||
} else if (type() === 'macos') {
|
||||
if (!isFullscreen) {
|
||||
// Add large padding for window controls
|
||||
s.paddingLeft = 72 / settings.interfaceScale;
|
||||
}
|
||||
} else if (!ignoreControlsSpacing && !settings.hideWindowControls) {
|
||||
s.paddingRight = WINDOW_CONTROLS_WIDTH;
|
||||
}
|
||||
|
||||
return s;
|
||||
}, [
|
||||
ignoreControlsSpacing,
|
||||
isFullscreen,
|
||||
settings.hideWindowControls,
|
||||
settings.interfaceScale,
|
||||
size,
|
||||
style,
|
||||
nativeTitlebar,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
style={finalStyle}
|
||||
className={classNames(
|
||||
className,
|
||||
'pt-[1px]', // Make up for bottom border
|
||||
'select-none relative',
|
||||
'w-full border-b border-border-subtle min-w-0',
|
||||
)}
|
||||
>
|
||||
{/* NOTE: This needs display:grid or else the element shrinks (even though scrollable) */}
|
||||
<div
|
||||
className={classNames(
|
||||
'pointer-events-none h-full w-full overflow-x-auto hide-scrollbars grid',
|
||||
'px-1', // Give it some space on either end for focus outlines
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{!hideControls && !nativeTitlebar && <WindowControls onlyX={onlyXWindowControl} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
apps/yaak-client/components/HeadersEditor.tsx
Normal file
166
apps/yaak-client/components/HeadersEditor.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import type { HttpRequestHeader } from '@yaakapp-internal/models';
|
||||
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
|
||||
import { charsets } from '../lib/data/charsets';
|
||||
import { connections } from '../lib/data/connections';
|
||||
import { encodings } from '../lib/data/encodings';
|
||||
import { headerNames } from '../lib/data/headerNames';
|
||||
import { mimeTypes } from '../lib/data/mimetypes';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { DetailsBanner } from './core/DetailsBanner';
|
||||
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
|
||||
import type { InputProps } from './core/Input';
|
||||
import type { Pair, PairEditorProps } from './core/PairEditor';
|
||||
import { PairEditorRow } from './core/PairEditor';
|
||||
import { ensurePairId } from './core/PairEditor.util';
|
||||
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
headers: HttpRequestHeader[];
|
||||
inheritedHeaders?: HttpRequestHeader[];
|
||||
inheritedHeadersLabel?: string;
|
||||
stateKey: string;
|
||||
onChange: (headers: HttpRequestHeader[]) => void;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function HeadersEditor({
|
||||
stateKey,
|
||||
headers,
|
||||
inheritedHeaders,
|
||||
inheritedHeadersLabel = 'Inherited',
|
||||
onChange,
|
||||
forceUpdateKey,
|
||||
}: Props) {
|
||||
// Get header names defined at current level (case-insensitive)
|
||||
const currentHeaderNames = new Set(
|
||||
headers.filter((h) => h.name).map((h) => h.name.toLowerCase()),
|
||||
);
|
||||
// Filter inherited headers: must be enabled, have content, and not be overridden by current level
|
||||
const validInheritedHeaders =
|
||||
inheritedHeaders?.filter(
|
||||
(pair) =>
|
||||
pair.enabled && (pair.name || pair.value) && !currentHeaderNames.has(pair.name.toLowerCase()),
|
||||
) ?? [];
|
||||
const hasInheritedHeaders = validInheritedHeaders.length > 0;
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
hasInheritedHeaders
|
||||
? '@container w-full h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-1.5'
|
||||
: '@container w-full h-full'
|
||||
}
|
||||
>
|
||||
{hasInheritedHeaders && (
|
||||
<DetailsBanner
|
||||
color="secondary"
|
||||
className="text-sm"
|
||||
summary={
|
||||
<HStack>
|
||||
{inheritedHeadersLabel} <CountBadge count={validInheritedHeaders.length} />
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<div className="pb-2">
|
||||
{validInheritedHeaders?.map((pair, i) => (
|
||||
<PairEditorRow
|
||||
key={`${pair.id}.${i}`}
|
||||
index={i}
|
||||
disabled
|
||||
disableDrag
|
||||
className="py-1"
|
||||
pair={ensurePairId(pair)}
|
||||
stateKey={null}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DetailsBanner>
|
||||
)}
|
||||
<PairOrBulkEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
nameAutocomplete={nameAutocomplete}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="Header-Name"
|
||||
nameValidate={validateHttpHeader}
|
||||
onChange={onChange}
|
||||
pairs={headers}
|
||||
preferenceName="headers"
|
||||
stateKey={stateKey}
|
||||
valueType={valueType}
|
||||
valueAutocomplete={valueAutocomplete}
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MIN_MATCH = 3;
|
||||
|
||||
const headerOptionsMap: Record<string, string[]> = {
|
||||
'content-type': mimeTypes,
|
||||
accept: ['*/*', ...mimeTypes],
|
||||
'accept-encoding': encodings,
|
||||
connection: connections,
|
||||
'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';
|
||||
}
|
||||
return 'text';
|
||||
};
|
||||
|
||||
const valueAutocomplete = (headerName: string): GenericCompletionConfig | undefined => {
|
||||
const name = headerName.toLowerCase().trim();
|
||||
const options: GenericCompletionOption[] =
|
||||
headerOptionsMap[name]?.map((o) => ({
|
||||
label: o,
|
||||
type: 'constant',
|
||||
boost: 1, // Put above other completions
|
||||
})) ?? [];
|
||||
return { minMatch: MIN_MATCH, options };
|
||||
};
|
||||
|
||||
const nameAutocomplete: PairEditorProps['nameAutocomplete'] = {
|
||||
minMatch: MIN_MATCH,
|
||||
options: headerNames.map((t) =>
|
||||
typeof t === 'string'
|
||||
? {
|
||||
label: t,
|
||||
type: 'constant',
|
||||
boost: 1, // Put above other completions
|
||||
}
|
||||
: {
|
||||
...t,
|
||||
boost: 1, // Put above other completions
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
const validateHttpHeader = (v: string) => {
|
||||
if (v === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Template strings are not allowed so we replace them with a valid example string
|
||||
const withoutTemplateStrings = v.replace(/\$\{\[\s*[^\]\s]+\s*]}/gi, '123');
|
||||
return withoutTemplateStrings.match(/^[a-zA-Z0-9-_]+$/) !== null;
|
||||
};
|
||||
213
apps/yaak-client/components/HttpAuthenticationEditor.tsx
Normal file
213
apps/yaak-client/components/HttpAuthenticationEditor.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import { useCallback } from 'react';
|
||||
import { openFolderSettings } from '../commands/openFolderSettings';
|
||||
import { openWorkspaceSettings } from '../commands/openWorkspaceSettings';
|
||||
import { useHttpAuthenticationConfig } from '../hooks/useHttpAuthenticationConfig';
|
||||
import { useInheritedAuthentication } from '../hooks/useInheritedAuthentication';
|
||||
import { useRenderTemplate } from '../hooks/useRenderTemplate';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { Dropdown, type DropdownItem } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { Input, type InputProps } from './core/Input';
|
||||
import { Link } from './core/Link';
|
||||
import { SegmentedControl } from './core/SegmentedControl';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { DynamicForm } from './DynamicForm';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
|
||||
interface Props {
|
||||
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
|
||||
}
|
||||
|
||||
export function HttpAuthenticationEditor({ model }: Props) {
|
||||
const inheritedAuth = useInheritedAuthentication(model);
|
||||
const authConfig = useHttpAuthenticationConfig(
|
||||
model.authenticationType,
|
||||
model.authentication,
|
||||
model,
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
async (authentication: Record<string, unknown>) => await patchModel(model, { authentication }),
|
||||
[model],
|
||||
);
|
||||
|
||||
if (model.authenticationType === 'none') {
|
||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
||||
}
|
||||
|
||||
if (model.authenticationType != null && authConfig.data == null) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<p>
|
||||
Auth plugin not found for <InlineCode>{model.authenticationType}</InlineCode>
|
||||
</p>
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
if (inheritedAuth == null) {
|
||||
if (model.model === 'workspace' || model.model === 'folder') {
|
||||
return (
|
||||
<EmptyStateText className="flex-col gap-1">
|
||||
<p>
|
||||
Apply auth to all requests in <strong>{resolvedModelName(model)}</strong>
|
||||
</p>
|
||||
<Link href="https://yaak.app/docs/using-yaak/request-inheritance">Documentation</Link>
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
||||
}
|
||||
|
||||
if (inheritedAuth.authenticationType === 'none') {
|
||||
return <EmptyStateText>No authentication</EmptyStateText>;
|
||||
}
|
||||
|
||||
const wasAuthInherited = inheritedAuth?.id !== model.id;
|
||||
if (wasAuthInherited) {
|
||||
const name = resolvedModelName(inheritedAuth);
|
||||
const cta = inheritedAuth.model === 'workspace' ? 'Workspace' : name;
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<p>
|
||||
Inherited from{' '}
|
||||
<button
|
||||
type="submit"
|
||||
className="underline hover:text-text"
|
||||
onClick={() => {
|
||||
if (inheritedAuth.model === 'folder') openFolderSettings(inheritedAuth.id, 'auth');
|
||||
else openWorkspaceSettings('auth');
|
||||
}}
|
||||
>
|
||||
{cta}
|
||||
</button>
|
||||
</p>
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-y-3">
|
||||
<div>
|
||||
<HStack space={2} alignItems="start">
|
||||
<SegmentedControl
|
||||
label="Enabled"
|
||||
hideLabel
|
||||
name="enabled"
|
||||
value={
|
||||
model.authentication.disabled === false || model.authentication.disabled == null
|
||||
? '__TRUE__'
|
||||
: model.authentication.disabled === true
|
||||
? '__FALSE__'
|
||||
: '__DYNAMIC__'
|
||||
}
|
||||
options={[
|
||||
{ label: 'Enabled', value: '__TRUE__' },
|
||||
{ label: 'Disabled', value: '__FALSE__' },
|
||||
{ label: 'Enabled when...', value: '__DYNAMIC__' },
|
||||
]}
|
||||
onChange={async (enabled) => {
|
||||
let disabled: boolean | string;
|
||||
if (enabled === '__TRUE__') {
|
||||
disabled = false;
|
||||
} else if (enabled === '__FALSE__') {
|
||||
disabled = true;
|
||||
} else {
|
||||
disabled = '';
|
||||
}
|
||||
await handleChange({ ...model.authentication, disabled });
|
||||
}}
|
||||
/>
|
||||
{authConfig.data?.actions && authConfig.data.actions.length > 0 && (
|
||||
<Dropdown
|
||||
items={authConfig.data.actions.map(
|
||||
(a): DropdownItem => ({
|
||||
label: a.label,
|
||||
leftSlot: a.icon ? <Icon icon={a.icon} /> : null,
|
||||
onSelect: () => a.call(model),
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
title="Authentication Actions"
|
||||
icon="settings"
|
||||
size="xs"
|
||||
className="!text-secondary"
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</HStack>
|
||||
{typeof model.authentication.disabled === 'string' && (
|
||||
<div className="mt-3">
|
||||
<AuthenticationDisabledInput
|
||||
className="w-full"
|
||||
stateKey={`auth.${model.id}.dynamic`}
|
||||
value={model.authentication.disabled}
|
||||
onChange={(v) => handleChange({ ...model.authentication, disabled: v })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DynamicForm
|
||||
disabled={model.authentication.disabled === true}
|
||||
autocompleteVariables
|
||||
autocompleteFunctions
|
||||
stateKey={`auth.${model.id}.${model.authenticationType}`}
|
||||
inputs={authConfig.data?.args ?? []}
|
||||
data={model.authentication}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AuthenticationDisabledInput({
|
||||
value,
|
||||
onChange,
|
||||
stateKey,
|
||||
className,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: InputProps['onChange'];
|
||||
stateKey: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const rendered = useRenderTemplate({
|
||||
template: value,
|
||||
enabled: true,
|
||||
purpose: 'preview',
|
||||
refreshKey: value,
|
||||
});
|
||||
|
||||
return (
|
||||
<Input
|
||||
size="sm"
|
||||
className={className}
|
||||
label="Dynamic Disabled"
|
||||
hideLabel
|
||||
defaultValue={value}
|
||||
placeholder="Enabled when this renders a non-empty value"
|
||||
rightSlot={
|
||||
<div className="px-1 flex items-center">
|
||||
<div className="rounded-full bg-surface-highlight text-xs px-1.5 py-0.5 text-text-subtle whitespace-nowrap">
|
||||
{rendered.isPending ? 'loading' : rendered.data ? 'enabled' : 'disabled'}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
onChange={onChange}
|
||||
stateKey={stateKey}
|
||||
/>
|
||||
);
|
||||
}
|
||||
66
apps/yaak-client/components/HttpRequestLayout.tsx
Normal file
66
apps/yaak-client/components/HttpRequestLayout.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useCurrentGraphQLSchema } from '../hooks/useIntrospectGraphQL';
|
||||
import { workspaceLayoutAtom } from '../lib/atoms';
|
||||
import type { SlotProps } from './core/SplitLayout';
|
||||
import { SplitLayout } from './core/SplitLayout';
|
||||
import { GraphQLDocsExplorer } from './graphql/GraphQLDocsExplorer';
|
||||
import { showGraphQLDocExplorerAtom } from './graphql/graphqlAtoms';
|
||||
import { HttpRequestPane } from './HttpRequestPane';
|
||||
import { HttpResponsePane } from './HttpResponsePane';
|
||||
|
||||
interface Props {
|
||||
activeRequest: HttpRequest;
|
||||
style: CSSProperties;
|
||||
}
|
||||
|
||||
export function HttpRequestLayout({ activeRequest, style }: Props) {
|
||||
const showGraphQLDocExplorer = useAtomValue(showGraphQLDocExplorerAtom);
|
||||
const graphQLSchema = useCurrentGraphQLSchema(activeRequest);
|
||||
const workspaceLayout = useAtomValue(workspaceLayoutAtom);
|
||||
|
||||
const requestResponseSplit = ({ style }: Pick<SlotProps, 'style'>) => (
|
||||
<SplitLayout
|
||||
name="http_layout"
|
||||
className="p-3 gap-1.5"
|
||||
style={style}
|
||||
layout={workspaceLayout}
|
||||
firstSlot={({ orientation, style }) => (
|
||||
<HttpRequestPane
|
||||
style={style}
|
||||
activeRequest={activeRequest}
|
||||
fullHeight={orientation === 'horizontal'}
|
||||
/>
|
||||
)}
|
||||
secondSlot={({ style }) => (
|
||||
<HttpResponsePane activeRequestId={activeRequest.id} style={style} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
if (
|
||||
activeRequest.bodyType === 'graphql' &&
|
||||
showGraphQLDocExplorer[activeRequest.id] !== undefined &&
|
||||
graphQLSchema != null
|
||||
) {
|
||||
return (
|
||||
<SplitLayout
|
||||
name="graphql_layout"
|
||||
defaultRatio={1 / 3}
|
||||
firstSlot={requestResponseSplit}
|
||||
secondSlot={({ style, orientation }) => (
|
||||
<GraphQLDocsExplorer
|
||||
requestId={activeRequest.id}
|
||||
schema={graphQLSchema}
|
||||
className={classNames(orientation === 'horizontal' && '!ml-0')}
|
||||
style={style}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return requestResponseSplit({ style });
|
||||
}
|
||||
477
apps/yaak-client/components/HttpRequestPane.tsx
Normal file
477
apps/yaak-client/components/HttpRequestPane.tsx
Normal file
@@ -0,0 +1,477 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { lazy, Suspense, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
|
||||
import { allRequestsAtom } from '../hooks/useAllRequests';
|
||||
import { useAuthTab } from '../hooks/useAuthTab';
|
||||
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
|
||||
import { useHeadersTab } from '../hooks/useHeadersTab';
|
||||
import { useImportCurl } from '../hooks/useImportCurl';
|
||||
import { useInheritedHeaders } from '../hooks/useInheritedHeaders';
|
||||
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
|
||||
import { useRequestUpdateKey } from '../hooks/useRequestUpdateKey';
|
||||
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
|
||||
import { deepEqualAtom } from '../lib/atoms';
|
||||
import { languageFromContentType } from '../lib/contentType';
|
||||
import { generateId } from '../lib/generateId';
|
||||
import {
|
||||
BODY_TYPE_BINARY,
|
||||
BODY_TYPE_FORM_MULTIPART,
|
||||
BODY_TYPE_FORM_URLENCODED,
|
||||
BODY_TYPE_GRAPHQL,
|
||||
BODY_TYPE_JSON,
|
||||
BODY_TYPE_NONE,
|
||||
BODY_TYPE_OTHER,
|
||||
BODY_TYPE_XML,
|
||||
getContentTypeFromHeaders,
|
||||
} from '../lib/model_util';
|
||||
import { prepareImportQuerystring } from '../lib/prepareImportQuerystring';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { showToast } from '../lib/toast';
|
||||
import { BinaryFileEditor } from './BinaryFileEditor';
|
||||
import { ConfirmLargeRequestBody } from './ConfirmLargeRequestBody';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import type { GenericCompletionConfig } from './core/Editor/genericCompletion';
|
||||
import { Editor } from './core/Editor/LazyEditor';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import type { Pair } from './core/PairEditor';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import type { TabItem, TabsRef } from './core/Tabs/Tabs';
|
||||
import { setActiveTab, TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
import { FormMultipartEditor } from './FormMultipartEditor';
|
||||
import { FormUrlencodedEditor } from './FormUrlencodedEditor';
|
||||
import { HeadersEditor } from './HeadersEditor';
|
||||
import { HttpAuthenticationEditor } from './HttpAuthenticationEditor';
|
||||
import { MarkdownEditor } from './MarkdownEditor';
|
||||
import { RequestMethodDropdown } from './RequestMethodDropdown';
|
||||
import { UrlBar } from './UrlBar';
|
||||
import { UrlParametersEditor } from './UrlParameterEditor';
|
||||
|
||||
const GraphQLEditor = lazy(() =>
|
||||
import('./graphql/GraphQLEditor').then((m) => ({ default: m.GraphQLEditor })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
style: CSSProperties;
|
||||
fullHeight: boolean;
|
||||
className?: string;
|
||||
activeRequest: HttpRequest;
|
||||
}
|
||||
|
||||
const TAB_BODY = 'body';
|
||||
const TAB_PARAMS = 'params';
|
||||
const TAB_HEADERS = 'headers';
|
||||
const TAB_AUTH = 'auth';
|
||||
const TAB_DESCRIPTION = 'description';
|
||||
const TABS_STORAGE_KEY = 'http_request_tabs';
|
||||
|
||||
const nonActiveRequestUrlsAtom = atom((get) => {
|
||||
const activeRequestId = get(activeRequestIdAtom);
|
||||
const requests = get(allRequestsAtom);
|
||||
return requests
|
||||
.filter((r) => r.id !== activeRequestId)
|
||||
.map((r): GenericCompletionOption => ({ type: 'constant', label: r.url }));
|
||||
});
|
||||
|
||||
const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom);
|
||||
|
||||
export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) {
|
||||
const activeRequestId = activeRequest.id;
|
||||
const tabsRef = useRef<TabsRef>(null);
|
||||
const [forceUpdateHeaderEditorKey, setForceUpdateHeaderEditorKey] = useState<number>(0);
|
||||
const forceUpdateKey = useRequestUpdateKey(activeRequest.id ?? null);
|
||||
const [{ urlKey }, { forceUrlRefresh, forceParamsRefresh }] = useRequestEditor();
|
||||
const contentType = getContentTypeFromHeaders(activeRequest.headers);
|
||||
const authTab = useAuthTab(TAB_AUTH, activeRequest);
|
||||
const headersTab = useHeadersTab(TAB_HEADERS, activeRequest);
|
||||
const inheritedHeaders = useInheritedHeaders(activeRequest);
|
||||
|
||||
// Listen for event to focus the params tab (e.g., when clicking a :param in the URL)
|
||||
useRequestEditorEvent('request_pane.focus_tab', () => {
|
||||
tabsRef.current?.setActiveTab(TAB_PARAMS);
|
||||
}, []);
|
||||
|
||||
const handleContentTypeChange = useCallback(
|
||||
async (contentType: string | null, patch: Partial<Omit<HttpRequest, 'headers'>> = {}) => {
|
||||
if (activeRequest == null) {
|
||||
console.error('Failed to get active request to update', activeRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = activeRequest.headers.filter((h) => h.name.toLowerCase() !== 'content-type');
|
||||
|
||||
if (contentType != null) {
|
||||
headers.push({
|
||||
name: 'Content-Type',
|
||||
value: contentType,
|
||||
enabled: true,
|
||||
id: generateId(),
|
||||
});
|
||||
}
|
||||
await patchModel(activeRequest, { ...patch, headers });
|
||||
|
||||
// Force update header editor so any changed headers are reflected
|
||||
setTimeout(() => setForceUpdateHeaderEditorKey((u) => u + 1), 100);
|
||||
},
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const { urlParameterPairs, urlParametersKey } = useMemo(() => {
|
||||
const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map(
|
||||
(m) => m[1] ?? '',
|
||||
);
|
||||
const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value);
|
||||
const items: Pair[] = [...nonEmptyParameters];
|
||||
for (const name of placeholderNames) {
|
||||
const item = items.find((p) => p.name === name);
|
||||
if (item) {
|
||||
item.readOnlyName = true;
|
||||
} else {
|
||||
items.push({ name, value: '', enabled: true, readOnlyName: true, id: generateId() });
|
||||
}
|
||||
}
|
||||
return { urlParameterPairs: items, urlParametersKey: placeholderNames.join(',') };
|
||||
}, [activeRequest.url, activeRequest.urlParameters]);
|
||||
|
||||
let numParams = 0;
|
||||
if (
|
||||
activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ||
|
||||
activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART
|
||||
) {
|
||||
numParams = Array.isArray(activeRequest.body?.form)
|
||||
? activeRequest.body.form.filter((p) => p.name).length
|
||||
: 0;
|
||||
}
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
value: TAB_BODY,
|
||||
rightSlot: numParams > 0 ? <CountBadge count={numParams} /> : null,
|
||||
options: {
|
||||
value: activeRequest.bodyType,
|
||||
items: [
|
||||
{ type: 'separator', label: 'Form Data' },
|
||||
{ label: 'Url Encoded', value: BODY_TYPE_FORM_URLENCODED },
|
||||
{ label: 'Multi-Part', value: BODY_TYPE_FORM_MULTIPART },
|
||||
{ type: 'separator', label: 'Text Content' },
|
||||
{ label: 'GraphQL', value: BODY_TYPE_GRAPHQL },
|
||||
{ label: 'JSON', value: BODY_TYPE_JSON },
|
||||
{ label: 'XML', value: BODY_TYPE_XML },
|
||||
{
|
||||
label: 'Other',
|
||||
value: BODY_TYPE_OTHER,
|
||||
shortLabel: nameOfContentTypeOr(contentType, 'Other'),
|
||||
},
|
||||
{ type: 'separator', label: 'Other' },
|
||||
{ label: 'Binary File', value: BODY_TYPE_BINARY },
|
||||
{ label: 'No Body', shortLabel: 'Body', value: BODY_TYPE_NONE },
|
||||
],
|
||||
onChange: async (bodyType) => {
|
||||
if (bodyType === activeRequest.bodyType) return;
|
||||
|
||||
const showMethodToast = (newMethod: string) => {
|
||||
if (activeRequest.method.toLowerCase() === newMethod.toLowerCase()) return;
|
||||
showToast({
|
||||
id: 'switched-method',
|
||||
message: (
|
||||
<>
|
||||
Request method switched to <InlineCode>POST</InlineCode>
|
||||
</>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const patch: Partial<HttpRequest> = { bodyType };
|
||||
let newContentType: string | null | undefined;
|
||||
if (bodyType === BODY_TYPE_NONE) {
|
||||
newContentType = null;
|
||||
} else if (
|
||||
bodyType === BODY_TYPE_FORM_URLENCODED ||
|
||||
bodyType === BODY_TYPE_FORM_MULTIPART ||
|
||||
bodyType === BODY_TYPE_JSON ||
|
||||
bodyType === BODY_TYPE_OTHER ||
|
||||
bodyType === BODY_TYPE_XML
|
||||
) {
|
||||
const isDefaultishRequest =
|
||||
activeRequest.bodyType === BODY_TYPE_NONE &&
|
||||
activeRequest.method.toLowerCase() === 'get';
|
||||
const requiresPost = bodyType === BODY_TYPE_FORM_MULTIPART;
|
||||
if (isDefaultishRequest || requiresPost) {
|
||||
patch.method = 'POST';
|
||||
showMethodToast(patch.method);
|
||||
}
|
||||
newContentType = bodyType === BODY_TYPE_OTHER ? 'text/plain' : bodyType;
|
||||
} else if (bodyType === BODY_TYPE_GRAPHQL) {
|
||||
patch.method = 'POST';
|
||||
newContentType = 'application/json';
|
||||
showMethodToast(patch.method);
|
||||
}
|
||||
|
||||
if (newContentType !== undefined) {
|
||||
await handleContentTypeChange(newContentType, patch);
|
||||
} else {
|
||||
await patchModel(activeRequest, patch);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
value: TAB_PARAMS,
|
||||
rightSlot: <CountBadge count={urlParameterPairs.length} />,
|
||||
label: 'Params',
|
||||
},
|
||||
...headersTab,
|
||||
...authTab,
|
||||
{
|
||||
value: TAB_DESCRIPTION,
|
||||
label: 'Info',
|
||||
},
|
||||
],
|
||||
[
|
||||
activeRequest,
|
||||
authTab,
|
||||
contentType,
|
||||
handleContentTypeChange,
|
||||
headersTab,
|
||||
numParams,
|
||||
urlParameterPairs.length,
|
||||
],
|
||||
);
|
||||
|
||||
const { mutate: sendRequest } = useSendAnyHttpRequest();
|
||||
const { activeResponse } = usePinnedHttpResponse(activeRequestId);
|
||||
const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
const updateKey = useRequestUpdateKey(activeRequestId);
|
||||
const { mutate: importCurl } = useImportCurl();
|
||||
|
||||
const handleBodyChange = useCallback(
|
||||
(body: HttpRequest['body']) => patchModel(activeRequest, { body }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const handleBodyTextChange = useCallback(
|
||||
(text: string) => patchModel(activeRequest, { body: { text } }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom);
|
||||
|
||||
const autocomplete: GenericCompletionConfig = useMemo(
|
||||
() => ({
|
||||
minMatch: 3,
|
||||
options:
|
||||
autocompleteUrls.length > 0
|
||||
? autocompleteUrls
|
||||
: [
|
||||
{ label: 'http://', type: 'constant' },
|
||||
{ label: 'https://', type: 'constant' },
|
||||
],
|
||||
}),
|
||||
[autocompleteUrls],
|
||||
);
|
||||
|
||||
const handlePaste = useCallback(
|
||||
async (e: ClipboardEvent, text: string) => {
|
||||
if (text.startsWith('curl ')) {
|
||||
importCurl({ overwriteRequestId: activeRequestId, command: text });
|
||||
} else {
|
||||
const patch = prepareImportQuerystring(text);
|
||||
if (patch != null) {
|
||||
e.preventDefault(); // Prevent input onChange
|
||||
|
||||
await patchModel(activeRequest, patch);
|
||||
await setActiveTab({
|
||||
storageKey: TABS_STORAGE_KEY,
|
||||
activeTabKey: activeRequestId,
|
||||
value: TAB_PARAMS,
|
||||
});
|
||||
|
||||
// Wait for request to update, then refresh the UI
|
||||
// TODO: Somehow make this deterministic
|
||||
setTimeout(() => {
|
||||
forceUrlRefresh();
|
||||
forceParamsRefresh();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeRequest, activeRequestId, forceParamsRefresh, forceUrlRefresh, importCurl],
|
||||
);
|
||||
const handleSend = useCallback(
|
||||
() => sendRequest(activeRequest.id ?? null),
|
||||
[activeRequest.id, sendRequest],
|
||||
);
|
||||
|
||||
const handleUrlChange = useCallback(
|
||||
(url: string) => patchModel(activeRequest, { url }),
|
||||
[activeRequest],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1')}
|
||||
>
|
||||
{activeRequest && (
|
||||
<>
|
||||
<UrlBar
|
||||
stateKey={`url.${activeRequest.id}`}
|
||||
key={forceUpdateKey + urlKey}
|
||||
url={activeRequest.url}
|
||||
placeholder="https://example.com"
|
||||
onPasteOverwrite={handlePaste}
|
||||
autocomplete={autocomplete}
|
||||
onSend={handleSend}
|
||||
onCancel={cancelResponse}
|
||||
onUrlChange={handleUrlChange}
|
||||
leftSlot={
|
||||
<div className="py-0.5">
|
||||
<RequestMethodDropdown request={activeRequest} className="ml-0.5 !h-full" />
|
||||
</div>
|
||||
}
|
||||
forceUpdateKey={updateKey}
|
||||
isLoading={activeResponse != null && activeResponse.state !== 'closed'}
|
||||
/>
|
||||
<Tabs
|
||||
ref={tabsRef}
|
||||
label="Request"
|
||||
tabs={tabs}
|
||||
tabListClassName="mt-1 -mb-1.5"
|
||||
storageKey={TABS_STORAGE_KEY}
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_AUTH}>
|
||||
<HttpAuthenticationEditor model={activeRequest} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<HeadersEditor
|
||||
inheritedHeaders={inheritedHeaders}
|
||||
forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`}
|
||||
headers={activeRequest.headers}
|
||||
stateKey={`headers.${activeRequest.id}`}
|
||||
onChange={(headers) => patchModel(activeRequest, { headers })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PARAMS}>
|
||||
<UrlParametersEditor
|
||||
stateKey={`params.${activeRequest.id}`}
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
pairs={urlParameterPairs}
|
||||
onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })}
|
||||
/>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ConfirmLargeRequestBody request={activeRequest}>
|
||||
{activeRequest.bodyType === BODY_TYPE_JSON ? (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
defaultValue={`${activeRequest.body?.text ?? ''}`}
|
||||
language="json"
|
||||
onChange={handleBodyTextChange}
|
||||
stateKey={`json.${activeRequest.id}`}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_XML ? (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
defaultValue={`${activeRequest.body?.text ?? ''}`}
|
||||
language="xml"
|
||||
onChange={handleBodyTextChange}
|
||||
stateKey={`xml.${activeRequest.id}`}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_GRAPHQL ? (
|
||||
<Suspense>
|
||||
<GraphQLEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
baseRequest={activeRequest}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
</Suspense>
|
||||
) : activeRequest.bodyType === BODY_TYPE_FORM_URLENCODED ? (
|
||||
<FormUrlencodedEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_FORM_MULTIPART ? (
|
||||
<FormMultipartEditor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
request={activeRequest}
|
||||
onChange={handleBodyChange}
|
||||
/>
|
||||
) : activeRequest.bodyType === BODY_TYPE_BINARY ? (
|
||||
<BinaryFileEditor
|
||||
requestId={activeRequest.id}
|
||||
contentType={contentType}
|
||||
body={activeRequest.body}
|
||||
onChange={(body) => patchModel(activeRequest, { body })}
|
||||
onChangeContentType={handleContentTypeChange}
|
||||
/>
|
||||
) : typeof activeRequest.bodyType === 'string' ? (
|
||||
<Editor
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
language={languageFromContentType(contentType)}
|
||||
placeholder="..."
|
||||
heightMode={fullHeight ? 'full' : 'auto'}
|
||||
defaultValue={`${activeRequest.body?.text ?? ''}`}
|
||||
onChange={handleBodyTextChange}
|
||||
stateKey={`other.${activeRequest.id}`}
|
||||
/>
|
||||
) : (
|
||||
<EmptyStateText>No Body</EmptyStateText>
|
||||
)}
|
||||
</ConfirmLargeRequestBody>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_DESCRIPTION}>
|
||||
<div className="grid grid-rows-[auto_minmax(0,1fr)] h-full">
|
||||
<PlainInput
|
||||
label="Request Name"
|
||||
hideLabel
|
||||
forceUpdateKey={updateKey}
|
||||
defaultValue={activeRequest.name}
|
||||
className="font-sans !text-xl !px-0"
|
||||
containerClassName="border-0"
|
||||
placeholder={resolvedModelName(activeRequest)}
|
||||
onChange={(name) => patchModel(activeRequest, { name })}
|
||||
/>
|
||||
<MarkdownEditor
|
||||
name="request-description"
|
||||
placeholder="Request description"
|
||||
defaultValue={activeRequest.description}
|
||||
stateKey={`description.${activeRequest.id}`}
|
||||
forceUpdateKey={updateKey}
|
||||
onChange={(description) => patchModel(activeRequest, { description })}
|
||||
/>
|
||||
</div>
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function nameOfContentTypeOr(contentType: string | null, fallback: string) {
|
||||
const language = languageFromContentType(contentType);
|
||||
if (language === 'markdown') {
|
||||
return 'Markdown';
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
430
apps/yaak-client/components/HttpResponsePane.tsx
Normal file
430
apps/yaak-client/components/HttpResponsePane.tsx
Normal file
@@ -0,0 +1,430 @@
|
||||
import type { HttpResponse, HttpResponseEvent } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import type { ComponentType, CSSProperties } from 'react';
|
||||
import { lazy, Suspense, useMemo } from 'react';
|
||||
import { useCancelHttpResponse } from '../hooks/useCancelHttpResponse';
|
||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||
import { usePinnedHttpResponse } from '../hooks/usePinnedHttpResponse';
|
||||
import { useResponseBodyBytes, useResponseBodyText } from '../hooks/useResponseBodyText';
|
||||
import { useResponseViewMode } from '../hooks/useResponseViewMode';
|
||||
import { useTimelineViewMode } from '../hooks/useTimelineViewMode';
|
||||
import { getMimeTypeFromContentType } from '../lib/contentType';
|
||||
import { getContentTypeFromHeaders, getCookieCounts } from '../lib/model_util';
|
||||
import { ConfirmLargeResponse } from './ConfirmLargeResponse';
|
||||
import { ConfirmLargeResponseRequest } from './ConfirmLargeResponseRequest';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { HotkeyList } from './core/HotkeyList';
|
||||
import { HttpResponseDurationTag } from './core/HttpResponseDurationTag';
|
||||
import { HttpStatusTag } from './core/HttpStatusTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { LoadingIcon } from './core/LoadingIcon';
|
||||
import { PillButton } from './core/PillButton';
|
||||
import { SizeTag } from './core/SizeTag';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
import type { TabItem } from './core/Tabs/Tabs';
|
||||
import { TabContent, Tabs } from './core/Tabs/Tabs';
|
||||
import { Tooltip } from './core/Tooltip';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { HttpResponseTimeline } from './HttpResponseTimeline';
|
||||
import { RecentHttpResponsesDropdown } from './RecentHttpResponsesDropdown';
|
||||
import { RequestBodyViewer } from './RequestBodyViewer';
|
||||
import { ResponseCookies } from './ResponseCookies';
|
||||
import { ResponseHeaders } from './ResponseHeaders';
|
||||
import { AudioViewer } from './responseViewers/AudioViewer';
|
||||
import { CsvViewer } from './responseViewers/CsvViewer';
|
||||
import { EventStreamViewer } from './responseViewers/EventStreamViewer';
|
||||
import { HTMLOrTextViewer } from './responseViewers/HTMLOrTextViewer';
|
||||
import { ImageViewer } from './responseViewers/ImageViewer';
|
||||
import { MultipartViewer } from './responseViewers/MultipartViewer';
|
||||
import { SvgViewer } from './responseViewers/SvgViewer';
|
||||
import { VideoViewer } from './responseViewers/VideoViewer';
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import('./responseViewers/PdfViewer').then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
activeRequestId: string;
|
||||
}
|
||||
|
||||
const TAB_BODY = 'body';
|
||||
const TAB_REQUEST = 'request';
|
||||
const TAB_HEADERS = 'headers';
|
||||
const TAB_COOKIES = 'cookies';
|
||||
const TAB_TIMELINE = 'timeline';
|
||||
|
||||
export type TimelineViewMode = 'timeline' | 'text';
|
||||
|
||||
interface RedirectDropWarning {
|
||||
droppedBodyCount: number;
|
||||
droppedHeaders: string[];
|
||||
}
|
||||
|
||||
export function HttpResponsePane({ style, className, activeRequestId }: Props) {
|
||||
const { activeResponse, setPinnedResponseId, responses } = usePinnedHttpResponse(activeRequestId);
|
||||
const [viewMode, setViewMode] = useResponseViewMode(activeResponse?.requestId);
|
||||
const [timelineViewMode, setTimelineViewMode] = useTimelineViewMode();
|
||||
const contentType = getContentTypeFromHeaders(activeResponse?.headers ?? null);
|
||||
const mimeType = contentType == null ? null : getMimeTypeFromContentType(contentType).essence;
|
||||
|
||||
const responseEvents = useHttpResponseEvents(activeResponse);
|
||||
const redirectDropWarning = useMemo(
|
||||
() => getRedirectDropWarning(responseEvents.data),
|
||||
[responseEvents.data],
|
||||
);
|
||||
const shouldShowRedirectDropWarning =
|
||||
activeResponse?.state === 'closed' && redirectDropWarning != null;
|
||||
|
||||
const cookieCounts = useMemo(() => getCookieCounts(responseEvents.data), [responseEvents.data]);
|
||||
|
||||
const tabs = useMemo<TabItem[]>(
|
||||
() => [
|
||||
{
|
||||
value: TAB_BODY,
|
||||
label: 'Response',
|
||||
options: {
|
||||
value: viewMode,
|
||||
onChange: setViewMode,
|
||||
items: [
|
||||
{ label: 'Response', value: 'pretty' },
|
||||
...(mimeType?.startsWith('image')
|
||||
? []
|
||||
: [{ label: 'Response (Raw)', shortLabel: 'Raw', value: 'raw' }]),
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
value: TAB_REQUEST,
|
||||
label: 'Request',
|
||||
rightSlot:
|
||||
(activeResponse?.requestContentLength ?? 0) > 0 ? <CountBadge count={true} /> : null,
|
||||
},
|
||||
{
|
||||
value: TAB_HEADERS,
|
||||
label: 'Headers',
|
||||
rightSlot: (
|
||||
<CountBadge
|
||||
count={activeResponse?.requestHeaders.length ?? 0}
|
||||
count2={activeResponse?.headers.length ?? 0}
|
||||
showZero
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: TAB_COOKIES,
|
||||
label: 'Cookies',
|
||||
rightSlot:
|
||||
cookieCounts.sent > 0 || cookieCounts.received > 0 ? (
|
||||
<CountBadge count={cookieCounts.sent} count2={cookieCounts.received} showZero />
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
value: TAB_TIMELINE,
|
||||
rightSlot: <CountBadge count={responseEvents.data?.length ?? 0} />,
|
||||
options: {
|
||||
value: timelineViewMode,
|
||||
onChange: (v) => setTimelineViewMode((v as TimelineViewMode) ?? 'timeline'),
|
||||
items: [
|
||||
{ label: 'Timeline', value: 'timeline' },
|
||||
{ label: 'Timeline (Text)', shortLabel: 'Timeline', value: 'text' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
activeResponse?.headers,
|
||||
activeResponse?.requestContentLength,
|
||||
activeResponse?.requestHeaders.length,
|
||||
cookieCounts.sent,
|
||||
cookieCounts.received,
|
||||
mimeType,
|
||||
responseEvents.data?.length,
|
||||
setViewMode,
|
||||
viewMode,
|
||||
timelineViewMode,
|
||||
setTimelineViewMode,
|
||||
],
|
||||
);
|
||||
|
||||
const cancel = useCancelHttpResponse(activeResponse?.id ?? null);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
className={classNames(
|
||||
className,
|
||||
'x-theme-responsePane',
|
||||
'max-h-full h-full',
|
||||
'bg-surface rounded-md border border-border-subtle overflow-hidden',
|
||||
'relative',
|
||||
)}
|
||||
>
|
||||
{activeResponse == null ? (
|
||||
<HotkeyList hotkeys={['request.send', 'model.create', 'sidebar.focus', 'url_bar.focus']} />
|
||||
) : (
|
||||
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">
|
||||
<HStack
|
||||
className={classNames(
|
||||
'text-text-subtle w-full flex-shrink-0',
|
||||
// Remove a bit of space because the tabs have lots too
|
||||
'-mb-1.5',
|
||||
)}
|
||||
>
|
||||
{activeResponse && (
|
||||
<div
|
||||
className={classNames(
|
||||
'grid grid-cols-[auto_minmax(4rem,1fr)_auto]',
|
||||
'cursor-default select-none',
|
||||
'whitespace-nowrap w-full pl-3 overflow-x-auto font-mono text-sm hide-scrollbars',
|
||||
)}
|
||||
>
|
||||
<HStack space={2} className="w-full flex-shrink-0">
|
||||
{activeResponse.state !== 'closed' && <LoadingIcon size="sm" />}
|
||||
<HttpStatusTag showReason response={activeResponse} />
|
||||
<span>•</span>
|
||||
<HttpResponseDurationTag response={activeResponse} />
|
||||
<span>•</span>
|
||||
<SizeTag
|
||||
contentLength={activeResponse.contentLength ?? 0}
|
||||
contentLengthCompressed={activeResponse.contentLengthCompressed}
|
||||
/>
|
||||
</HStack>
|
||||
{shouldShowRedirectDropWarning ? (
|
||||
<Tooltip
|
||||
tabIndex={0}
|
||||
className="my-auto pl-3 flex-shrink-0 max-w-full justify-self-end overflow-hidden"
|
||||
content={
|
||||
<VStack alignItems="start" space={1} className="text-xs">
|
||||
<span className="font-medium text-warning">
|
||||
Redirect changed this request
|
||||
</span>
|
||||
{redirectDropWarning.droppedBodyCount > 0 && (
|
||||
<span>
|
||||
Body dropped on {redirectDropWarning.droppedBodyCount}{' '}
|
||||
{redirectDropWarning.droppedBodyCount === 1
|
||||
? 'redirect hop'
|
||||
: 'redirect hops'}
|
||||
</span>
|
||||
)}
|
||||
{redirectDropWarning.droppedHeaders.length > 0 && (
|
||||
<span>
|
||||
Headers dropped:{' '}
|
||||
<span className="font-mono">
|
||||
{redirectDropWarning.droppedHeaders.join(', ')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-text-subtle">See Timeline for details.</span>
|
||||
</VStack>
|
||||
}
|
||||
>
|
||||
<span className="inline-flex min-w-0">
|
||||
<PillButton
|
||||
color="warning"
|
||||
className="font-sans text-sm !flex-shrink max-w-full"
|
||||
innerClassName="flex items-center"
|
||||
leftSlot={<Icon icon="alert_triangle" size="xs" color="warning" />}
|
||||
>
|
||||
<span className="truncate">
|
||||
{getRedirectWarningLabel(redirectDropWarning)}
|
||||
</span>
|
||||
</PillButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="justify-self-end flex-shrink-0">
|
||||
<RecentHttpResponsesDropdown
|
||||
responses={responses}
|
||||
activeResponse={activeResponse}
|
||||
onPinnedResponseId={setPinnedResponseId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<div className="overflow-hidden flex flex-col min-h-0">
|
||||
{activeResponse?.error && (
|
||||
<Banner color="danger" className="mx-3 mt-1 flex-shrink-0">
|
||||
{activeResponse.error}
|
||||
</Banner>
|
||||
)}
|
||||
{/* Show tabs if we have any data (headers, body, etc.) even if there's an error */}
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
label="Response"
|
||||
className="ml-3 mr-3 mb-3 min-h-0 flex-1"
|
||||
tabListClassName="mt-0.5 -mb-1.5"
|
||||
storageKey="http_response_tabs"
|
||||
activeTabKey={activeRequestId}
|
||||
>
|
||||
<TabContent value={TAB_BODY}>
|
||||
<ErrorBoundary name="Http Response Viewer">
|
||||
<Suspense>
|
||||
<ConfirmLargeResponse response={activeResponse}>
|
||||
{activeResponse.state === 'initialized' ? (
|
||||
<EmptyStateText>
|
||||
<VStack space={3}>
|
||||
<HStack space={3}>
|
||||
<LoadingIcon className="text-text-subtlest" />
|
||||
Sending Request
|
||||
</HStack>
|
||||
<Button size="sm" variant="border" onClick={() => cancel.mutate()}>
|
||||
Cancel
|
||||
</Button>
|
||||
</VStack>
|
||||
</EmptyStateText>
|
||||
) : activeResponse.state === 'closed' &&
|
||||
(activeResponse.contentLength ?? 0) === 0 ? (
|
||||
<EmptyStateText>Empty</EmptyStateText>
|
||||
) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? (
|
||||
<EventStreamViewer response={activeResponse} />
|
||||
) : mimeType?.match(/^image\/svg/) ? (
|
||||
<HttpSvgViewer response={activeResponse} />
|
||||
) : mimeType?.match(/^image/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={ImageViewer} />
|
||||
) : mimeType?.match(/^audio/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={AudioViewer} />
|
||||
) : mimeType?.match(/^video/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={VideoViewer} />
|
||||
) : mimeType?.match(/^multipart/i) && viewMode === 'pretty' ? (
|
||||
<HttpMultipartViewer response={activeResponse} />
|
||||
) : mimeType?.match(/pdf/i) ? (
|
||||
<EnsureCompleteResponse response={activeResponse} Component={PdfViewer} />
|
||||
) : mimeType?.match(/csv|tab-separated/i) && viewMode === 'pretty' ? (
|
||||
<HttpCsvViewer className="pb-2" response={activeResponse} />
|
||||
) : (
|
||||
<HTMLOrTextViewer
|
||||
textViewerClassName="-mr-2 bg-surface" // Pull to the right
|
||||
response={activeResponse}
|
||||
pretty={viewMode === 'pretty'}
|
||||
/>
|
||||
)}
|
||||
</ConfirmLargeResponse>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_REQUEST}>
|
||||
<ConfirmLargeResponseRequest response={activeResponse}>
|
||||
<RequestBodyViewer response={activeResponse} />
|
||||
</ConfirmLargeResponseRequest>
|
||||
</TabContent>
|
||||
<TabContent value={TAB_HEADERS}>
|
||||
<ResponseHeaders response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_COOKIES}>
|
||||
<ResponseCookies response={activeResponse} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_TIMELINE}>
|
||||
<HttpResponseTimeline response={activeResponse} viewMode={timelineViewMode} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getRedirectDropWarning(
|
||||
events: HttpResponseEvent[] | undefined,
|
||||
): RedirectDropWarning | null {
|
||||
if (events == null || events.length === 0) return null;
|
||||
|
||||
let droppedBodyCount = 0;
|
||||
const droppedHeaders = new Set<string>();
|
||||
for (const e of events) {
|
||||
const event = e.event;
|
||||
if (event.type !== 'redirect') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.dropped_body) {
|
||||
droppedBodyCount += 1;
|
||||
}
|
||||
for (const headerName of event.dropped_headers ?? []) {
|
||||
pushHeaderName(droppedHeaders, headerName);
|
||||
}
|
||||
}
|
||||
|
||||
if (droppedBodyCount === 0 && droppedHeaders.size === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
droppedBodyCount,
|
||||
droppedHeaders: Array.from(droppedHeaders).sort(),
|
||||
};
|
||||
}
|
||||
|
||||
function pushHeaderName(headers: Set<string>, headerName: string): void {
|
||||
const existing = Array.from(headers).find((h) => h.toLowerCase() === headerName.toLowerCase());
|
||||
if (existing == null) {
|
||||
headers.add(headerName);
|
||||
}
|
||||
}
|
||||
|
||||
function getRedirectWarningLabel(warning: RedirectDropWarning): string {
|
||||
if (warning.droppedBodyCount > 0 && warning.droppedHeaders.length > 0) {
|
||||
return 'Dropped body and headers';
|
||||
}
|
||||
if (warning.droppedBodyCount > 0) {
|
||||
return 'Dropped body';
|
||||
}
|
||||
return 'Dropped headers';
|
||||
}
|
||||
|
||||
function EnsureCompleteResponse({
|
||||
response,
|
||||
Component,
|
||||
}: {
|
||||
response: HttpResponse;
|
||||
Component: ComponentType<{ bodyPath: string }>;
|
||||
}) {
|
||||
if (response.bodyPath === null) {
|
||||
return <div>Empty response body</div>;
|
||||
}
|
||||
|
||||
// Wait until the response has been fully-downloaded
|
||||
if (response.state !== 'closed') {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
return <Component bodyPath={response.bodyPath} />;
|
||||
}
|
||||
|
||||
function HttpSvgViewer({ response }: { response: HttpResponse }) {
|
||||
const body = useResponseBodyText({ response, filter: null });
|
||||
|
||||
if (!body.data) return null;
|
||||
|
||||
return <SvgViewer text={body.data} />;
|
||||
}
|
||||
|
||||
function HttpCsvViewer({ response, className }: { response: HttpResponse; className?: string }) {
|
||||
const body = useResponseBodyText({ response, filter: null });
|
||||
|
||||
return <CsvViewer text={body.data ?? null} className={className} />;
|
||||
}
|
||||
|
||||
function HttpMultipartViewer({ response }: { response: HttpResponse }) {
|
||||
const body = useResponseBodyBytes({ response });
|
||||
|
||||
if (body.data == null) return null;
|
||||
|
||||
const contentTypeHeader = getContentTypeFromHeaders(response.headers);
|
||||
const boundary = contentTypeHeader?.split('boundary=')[1] ?? 'unknown';
|
||||
|
||||
return <MultipartViewer data={body.data} boundary={boundary} idPrefix={response.id} />;
|
||||
}
|
||||
418
apps/yaak-client/components/HttpResponseTimeline.tsx
Normal file
418
apps/yaak-client/components/HttpResponseTimeline.tsx
Normal file
@@ -0,0 +1,418 @@
|
||||
import type {
|
||||
HttpResponse,
|
||||
HttpResponseEvent,
|
||||
HttpResponseEventData,
|
||||
} from '@yaakapp-internal/models';
|
||||
import { type ReactNode, useMemo, useState } from 'react';
|
||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||
import { Editor } from './core/Editor/LazyEditor';
|
||||
import { type EventDetailAction, EventDetailHeader, EventViewer } from './core/EventViewer';
|
||||
import { EventViewerRow } from './core/EventViewerRow';
|
||||
import { HttpStatusTagRaw } from './core/HttpStatusTag';
|
||||
import { Icon, type IconProps } from './core/Icon';
|
||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||
import type { TimelineViewMode } from './HttpResponsePane';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
viewMode: TimelineViewMode;
|
||||
}
|
||||
|
||||
export function HttpResponseTimeline({ response, viewMode }: Props) {
|
||||
return <Inner key={response.id} response={response} viewMode={viewMode} />;
|
||||
}
|
||||
|
||||
function Inner({ response, viewMode }: Props) {
|
||||
const [showRaw, setShowRaw] = useState(false);
|
||||
const { data: events, error, isLoading } = useHttpResponseEvents(response);
|
||||
|
||||
// Generate plain text representation of all events (with prefixes for timeline view)
|
||||
const plainText = useMemo(() => {
|
||||
if (!events || events.length === 0) return '';
|
||||
return events.map((event) => formatEventText(event.event, true)).join('\n');
|
||||
}, [events]);
|
||||
|
||||
// Plain text view - show all events as text in an editor
|
||||
if (viewMode === 'text') {
|
||||
if (isLoading) {
|
||||
return <div className="p-4 text-text-subtlest">Loading events...</div>;
|
||||
} else if (error) {
|
||||
return <div className="p-4 text-danger">{String(error)}</div>;
|
||||
} else if (!events || events.length === 0) {
|
||||
return <div className="p-4 text-text-subtlest">No events recorded</div>;
|
||||
} else {
|
||||
return (
|
||||
<Editor language="timeline" defaultValue={plainText} readOnly stateKey={null} hideGutter />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<EventViewer
|
||||
events={events ?? []}
|
||||
getEventKey={(event) => event.id}
|
||||
error={error ? String(error) : null}
|
||||
isLoading={isLoading}
|
||||
loadingMessage="Loading events..."
|
||||
emptyMessage="No events recorded"
|
||||
splitLayoutName="http_response_events"
|
||||
defaultRatio={0.25}
|
||||
renderRow={({ event, isActive, onClick }) => {
|
||||
const display = getEventDisplay(event.event);
|
||||
return (
|
||||
<EventViewerRow
|
||||
isActive={isActive}
|
||||
onClick={onClick}
|
||||
icon={<Icon color={display.color} icon={display.icon} size="sm" />}
|
||||
content={display.summary}
|
||||
timestamp={event.createdAt}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
renderDetail={({ event, onClose }) => (
|
||||
<EventDetails event={event} showRaw={showRaw} setShowRaw={setShowRaw} onClose={onClose} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function EventDetails({
|
||||
event,
|
||||
showRaw,
|
||||
setShowRaw,
|
||||
onClose,
|
||||
}: {
|
||||
event: HttpResponseEvent;
|
||||
showRaw: boolean;
|
||||
setShowRaw: (v: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { label } = getEventDisplay(event.event);
|
||||
const e = event.event;
|
||||
|
||||
const actions: EventDetailAction[] = [
|
||||
{
|
||||
key: 'toggle-raw',
|
||||
label: showRaw ? 'Formatted' : 'Text',
|
||||
onClick: () => setShowRaw(!showRaw),
|
||||
},
|
||||
];
|
||||
|
||||
// Determine the title based on event type
|
||||
const title = (() => {
|
||||
switch (e.type) {
|
||||
case 'header_up':
|
||||
return 'Header Sent';
|
||||
case 'header_down':
|
||||
return 'Header Received';
|
||||
case 'send_url':
|
||||
return 'Request';
|
||||
case 'receive_url':
|
||||
return 'Response';
|
||||
case 'redirect':
|
||||
return 'Redirect';
|
||||
case 'setting':
|
||||
return 'Apply Setting';
|
||||
case 'chunk_sent':
|
||||
return 'Data Sent';
|
||||
case 'chunk_received':
|
||||
return 'Data Received';
|
||||
case 'dns_resolved':
|
||||
return e.overridden ? 'DNS Override' : 'DNS Resolution';
|
||||
default:
|
||||
return label;
|
||||
}
|
||||
})();
|
||||
|
||||
// Render content based on view mode and event type
|
||||
const renderContent = () => {
|
||||
// Raw view - show plaintext representation (without prefix)
|
||||
if (showRaw) {
|
||||
const rawText = formatEventText(event.event, false);
|
||||
return <Editor language="text" defaultValue={rawText} readOnly stateKey={null} hideGutter />;
|
||||
}
|
||||
|
||||
// Headers - show name and value
|
||||
if (e.type === 'header_up' || e.type === 'header_down') {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Header">{e.name}</KeyValueRow>
|
||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Request URL - show all URL parts separately
|
||||
if (e.type === 'send_url') {
|
||||
const auth = e.username || e.password ? `${e.username}:${e.password}@` : '';
|
||||
const isDefaultPort =
|
||||
(e.scheme === 'http' && e.port === 80) || (e.scheme === 'https' && e.port === 443);
|
||||
const portStr = isDefaultPort ? '' : `:${e.port}`;
|
||||
const query = e.query ? `?${e.query}` : '';
|
||||
const fragment = e.fragment ? `#${e.fragment}` : '';
|
||||
const fullUrl = `${e.scheme}://${auth}${e.host}${portStr}${e.path}${query}${fragment}`;
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="URL">{fullUrl}</KeyValueRow>
|
||||
<KeyValueRow label="Method">{e.method}</KeyValueRow>
|
||||
<KeyValueRow label="Scheme">{e.scheme}</KeyValueRow>
|
||||
{e.username ? <KeyValueRow label="Username">{e.username}</KeyValueRow> : null}
|
||||
{e.password ? <KeyValueRow label="Password">{e.password}</KeyValueRow> : null}
|
||||
<KeyValueRow label="Host">{e.host}</KeyValueRow>
|
||||
{!isDefaultPort ? <KeyValueRow label="Port">{e.port}</KeyValueRow> : null}
|
||||
<KeyValueRow label="Path">{e.path}</KeyValueRow>
|
||||
{e.query ? <KeyValueRow label="Query">{e.query}</KeyValueRow> : null}
|
||||
{e.fragment ? <KeyValueRow label="Fragment">{e.fragment}</KeyValueRow> : null}
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Response status - show version and status separately
|
||||
if (e.type === 'receive_url') {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="HTTP Version">{e.version}</KeyValueRow>
|
||||
<KeyValueRow label="Status">
|
||||
<HttpStatusTagRaw status={e.status} />
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect - show status, URL, and behavior
|
||||
if (e.type === 'redirect') {
|
||||
const droppedHeaders = e.dropped_headers ?? [];
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Status">
|
||||
<HttpStatusTagRaw status={e.status} />
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="Location">{e.url}</KeyValueRow>
|
||||
<KeyValueRow label="Behavior">
|
||||
{e.behavior === 'drop_body' ? 'Drop body, change to GET' : 'Preserve method and body'}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow label="Body Dropped">{e.dropped_body ? 'Yes' : 'No'}</KeyValueRow>
|
||||
<KeyValueRow label="Headers Dropped">
|
||||
{droppedHeaders.length > 0 ? droppedHeaders.join(', ') : '--'}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Settings - show as key/value
|
||||
if (e.type === 'setting') {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Setting">{e.name}</KeyValueRow>
|
||||
<KeyValueRow label="Value">{e.value}</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Chunks - show formatted bytes
|
||||
if (e.type === 'chunk_sent' || e.type === 'chunk_received') {
|
||||
return <div className="font-mono text-editor">{formatBytes(e.bytes)}</div>;
|
||||
}
|
||||
|
||||
// DNS Resolution - show hostname, addresses, and timing
|
||||
if (e.type === 'dns_resolved') {
|
||||
return (
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Hostname">{e.hostname}</KeyValueRow>
|
||||
<KeyValueRow label="Addresses">{e.addresses.join(', ')}</KeyValueRow>
|
||||
<KeyValueRow label="Duration">
|
||||
{e.overridden ? (
|
||||
<span className="text-text-subtlest">--</span>
|
||||
) : (
|
||||
`${String(e.duration)}ms`
|
||||
)}
|
||||
</KeyValueRow>
|
||||
{e.overridden ? <KeyValueRow label="Source">Workspace Override</KeyValueRow> : null}
|
||||
</KeyValueRows>
|
||||
);
|
||||
}
|
||||
|
||||
// Default - use summary
|
||||
const { summary } = getEventDisplay(event.event);
|
||||
return <div className="font-mono text-editor">{summary}</div>;
|
||||
};
|
||||
return (
|
||||
<div className="flex flex-col gap-2 h-full">
|
||||
<EventDetailHeader
|
||||
title={title}
|
||||
timestamp={event.createdAt}
|
||||
actions={actions}
|
||||
onClose={onClose}
|
||||
/>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type EventTextParts = { prefix: '>' | '<' | '*'; text: string };
|
||||
|
||||
/** Get the prefix and text for an event */
|
||||
function getEventTextParts(event: HttpResponseEventData): EventTextParts {
|
||||
switch (event.type) {
|
||||
case 'send_url':
|
||||
return {
|
||||
prefix: '>',
|
||||
text: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`,
|
||||
};
|
||||
case 'receive_url':
|
||||
return { prefix: '<', text: `${event.version} ${event.status}` };
|
||||
case 'header_up':
|
||||
return { prefix: '>', text: `${event.name}: ${event.value}` };
|
||||
case 'header_down':
|
||||
return { prefix: '<', text: `${event.name}: ${event.value}` };
|
||||
case 'redirect': {
|
||||
const behavior = event.behavior === 'drop_body' ? 'drop body' : 'preserve';
|
||||
const droppedHeaders = event.dropped_headers ?? [];
|
||||
const dropped = [
|
||||
event.dropped_body ? 'body dropped' : null,
|
||||
droppedHeaders.length > 0 ? `headers dropped: ${droppedHeaders.join(', ')}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return {
|
||||
prefix: '*',
|
||||
text: `Redirect ${event.status} -> ${event.url} (${behavior}${dropped ? `, ${dropped}` : ''})`,
|
||||
};
|
||||
}
|
||||
case 'setting':
|
||||
return { prefix: '*', text: `Setting ${event.name}=${event.value}` };
|
||||
case 'info':
|
||||
return { prefix: '*', text: event.message };
|
||||
case 'chunk_sent':
|
||||
return { prefix: '*', text: `[${formatBytes(event.bytes)} sent]` };
|
||||
case 'chunk_received':
|
||||
return { prefix: '*', text: `[${formatBytes(event.bytes)} received]` };
|
||||
case 'dns_resolved':
|
||||
if (event.overridden) {
|
||||
return {
|
||||
prefix: '*',
|
||||
text: `DNS override ${event.hostname} -> ${event.addresses.join(', ')}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
prefix: '*',
|
||||
text: `DNS resolved ${event.hostname} to ${event.addresses.join(', ')} (${event.duration}ms)`,
|
||||
};
|
||||
default:
|
||||
return { prefix: '*', text: '[unknown event]' };
|
||||
}
|
||||
}
|
||||
|
||||
/** Format event as plaintext, optionally with curl-style prefix (> outgoing, < incoming, * info) */
|
||||
function formatEventText(event: HttpResponseEventData, includePrefix: boolean): string {
|
||||
const { prefix, text } = getEventTextParts(event);
|
||||
return includePrefix ? `${prefix} ${text}` : text;
|
||||
}
|
||||
|
||||
type EventDisplay = {
|
||||
icon: IconProps['icon'];
|
||||
color: IconProps['color'];
|
||||
label: string;
|
||||
summary: ReactNode;
|
||||
};
|
||||
|
||||
function getEventDisplay(event: HttpResponseEventData): EventDisplay {
|
||||
switch (event.type) {
|
||||
case 'setting':
|
||||
return {
|
||||
icon: 'settings',
|
||||
color: 'secondary',
|
||||
label: 'Setting',
|
||||
summary: `${event.name} = ${event.value}`,
|
||||
};
|
||||
case 'info':
|
||||
return {
|
||||
icon: 'info',
|
||||
color: 'secondary',
|
||||
label: 'Info',
|
||||
summary: event.message,
|
||||
};
|
||||
case 'redirect': {
|
||||
const droppedHeaders = event.dropped_headers ?? [];
|
||||
const dropped = [
|
||||
event.dropped_body ? 'drop body' : null,
|
||||
droppedHeaders.length > 0
|
||||
? `drop ${droppedHeaders.length} ${droppedHeaders.length === 1 ? 'header' : 'headers'}`
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
return {
|
||||
icon: 'arrow_big_right_dash',
|
||||
color: 'success',
|
||||
label: 'Redirect',
|
||||
summary: `Redirecting ${event.status} ${event.url}${dropped ? ` (${dropped})` : ''}`,
|
||||
};
|
||||
}
|
||||
case 'send_url':
|
||||
return {
|
||||
icon: 'arrow_big_up_dash',
|
||||
color: 'primary',
|
||||
label: 'Request',
|
||||
summary: `${event.method} ${event.path}${event.query ? `?${event.query}` : ''}${event.fragment ? `#${event.fragment}` : ''}`,
|
||||
};
|
||||
case 'receive_url':
|
||||
return {
|
||||
icon: 'arrow_big_down_dash',
|
||||
color: 'info',
|
||||
label: 'Response',
|
||||
summary: `${event.version} ${event.status}`,
|
||||
};
|
||||
case 'header_up':
|
||||
return {
|
||||
icon: 'arrow_big_up_dash',
|
||||
color: 'primary',
|
||||
label: 'Header',
|
||||
summary: `${event.name}: ${event.value}`,
|
||||
};
|
||||
case 'header_down':
|
||||
return {
|
||||
icon: 'arrow_big_down_dash',
|
||||
color: 'info',
|
||||
label: 'Header',
|
||||
summary: `${event.name}: ${event.value}`,
|
||||
};
|
||||
|
||||
case 'chunk_sent':
|
||||
return {
|
||||
icon: 'info',
|
||||
color: 'secondary',
|
||||
label: 'Chunk',
|
||||
summary: `${formatBytes(event.bytes)} chunk sent`,
|
||||
};
|
||||
case 'chunk_received':
|
||||
return {
|
||||
icon: 'info',
|
||||
color: 'secondary',
|
||||
label: 'Chunk',
|
||||
summary: `${formatBytes(event.bytes)} chunk received`,
|
||||
};
|
||||
case 'dns_resolved':
|
||||
return {
|
||||
icon: 'globe',
|
||||
color: event.overridden ? 'success' : 'secondary',
|
||||
label: event.overridden ? 'DNS Override' : 'DNS',
|
||||
summary: event.overridden
|
||||
? `${event.hostname} → ${event.addresses.join(', ')} (overridden)`
|
||||
: `${event.hostname} → ${event.addresses.join(', ')} (${event.duration}ms)`,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: 'info',
|
||||
color: 'secondary',
|
||||
label: 'Unknown',
|
||||
summary: 'Unknown event',
|
||||
};
|
||||
}
|
||||
}
|
||||
56
apps/yaak-client/components/ImportCurlButton.tsx
Normal file
56
apps/yaak-client/components/ImportCurlButton.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { clear, readText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import * as m from 'motion/react-m';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useImportCurl } from '../hooks/useImportCurl';
|
||||
import { useWindowFocus } from '../hooks/useWindowFocus';
|
||||
import { Button } from './core/Button';
|
||||
import { Icon } from './core/Icon';
|
||||
|
||||
export function ImportCurlButton() {
|
||||
const focused = useWindowFocus();
|
||||
const [clipboardText, setClipboardText] = useState('');
|
||||
|
||||
const importCurl = useImportCurl();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: none
|
||||
useEffect(() => {
|
||||
readText().then(setClipboardText);
|
||||
}, [focused]);
|
||||
|
||||
if (!clipboardText?.trim().startsWith('curl ')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, scale: 0 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<Button
|
||||
size="2xs"
|
||||
variant="border"
|
||||
color="success"
|
||||
className="rounded-full"
|
||||
rightSlot={<Icon icon="import" size="sm" />}
|
||||
isLoading={isLoading}
|
||||
title="Import Curl command from clipboard"
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await importCurl.mutateAsync({ command: clipboardText });
|
||||
await clear(); // Clear the clipboard so the button goes away
|
||||
setClipboardText('');
|
||||
} catch (e) {
|
||||
console.log('Failed to import curl', e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Import Curl
|
||||
</Button>
|
||||
</m.div>
|
||||
);
|
||||
}
|
||||
54
apps/yaak-client/components/ImportDataDialog.tsx
Normal file
54
apps/yaak-client/components/ImportDataDialog.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocalStorage } from 'react-use';
|
||||
import { Button } from './core/Button';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { SelectFile } from './SelectFile';
|
||||
|
||||
interface Props {
|
||||
importData: (filePath: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ImportDataDialog({ importData }: Props) {
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [filePath, setFilePath] = useLocalStorage<string | null>('importFilePath', null);
|
||||
|
||||
return (
|
||||
<VStack space={5} className="pb-4">
|
||||
<VStack space={1}>
|
||||
<ul className="list-disc pl-5">
|
||||
<li>OpenAPI 3.0, 3.1</li>
|
||||
<li>Postman Collection v2, v2.1</li>
|
||||
<li>Insomnia v4+</li>
|
||||
<li>Swagger 2.0</li>
|
||||
<li>
|
||||
Curl commands <em className="text-text-subtle">(or paste into URL)</em>
|
||||
</li>
|
||||
</ul>
|
||||
</VStack>
|
||||
<VStack space={2}>
|
||||
<SelectFile
|
||||
filePath={filePath ?? null}
|
||||
onChange={({ filePath }) => setFilePath(filePath)}
|
||||
/>
|
||||
{filePath && (
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={!filePath || isLoading}
|
||||
isLoading={isLoading}
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await importData(filePath);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isLoading ? 'Importing' : 'Import'}
|
||||
</Button>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
14
apps/yaak-client/components/IsDev.tsx
Normal file
14
apps/yaak-client/components/IsDev.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function IsDev({ children }: Props) {
|
||||
if (!appInfo.isDev) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
10
apps/yaak-client/components/KeyboardShortcutsDialog.tsx
Normal file
10
apps/yaak-client/components/KeyboardShortcutsDialog.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { hotkeyActions } from '../hooks/useHotKey';
|
||||
import { HotkeyList } from './core/HotkeyList';
|
||||
|
||||
export function KeyboardShortcutsDialog() {
|
||||
return (
|
||||
<div className="grid h-full">
|
||||
<HotkeyList hotkeys={hotkeyActions} className="pb-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
apps/yaak-client/components/LicenseBadge.tsx
Normal file
139
apps/yaak-client/components/LicenseBadge.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import type { LicenseCheckStatus } from '@yaakapp-internal/license';
|
||||
import { useLicense } from '@yaakapp-internal/license';
|
||||
import { settingsAtom } from '@yaakapp-internal/models';
|
||||
import { differenceInCalendarDays } from 'date-fns';
|
||||
import { formatDate } from 'date-fns/format';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { ReactNode } from 'react';
|
||||
import { openSettings } from '../commands/openSettings';
|
||||
import { atomWithKVStorage } from '../lib/atoms/atomWithKVStorage';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { CargoFeature } from './CargoFeature';
|
||||
import type { ButtonProps } from './core/Button';
|
||||
import { Dropdown, type DropdownItem } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { PillButton } from './core/PillButton';
|
||||
|
||||
const dismissedAtom = atomWithKVStorage<string | null>('dismissed_license_expired', null);
|
||||
|
||||
function getDetail(
|
||||
data: LicenseCheckStatus,
|
||||
dismissedExpired: string | null,
|
||||
): { label: ReactNode; color: ButtonProps['color']; options?: DropdownItem[] } | null | undefined {
|
||||
const dismissedAt = dismissedExpired ? new Date(dismissedExpired).getTime() : null;
|
||||
|
||||
switch (data.status) {
|
||||
case 'active':
|
||||
return null;
|
||||
case 'personal_use':
|
||||
return { label: 'Personal Use', color: 'notice' };
|
||||
case 'trialing':
|
||||
return { label: 'Commercial Trial', color: 'secondary' };
|
||||
case 'error':
|
||||
return { label: 'Error', color: 'danger' };
|
||||
case 'inactive':
|
||||
return { label: 'Personal Use', color: 'notice' };
|
||||
case 'past_due':
|
||||
return { label: 'Past Due', color: 'danger' };
|
||||
case 'expired':
|
||||
// Don't show the expired message if it's been less than 14 days since the last dismissal
|
||||
if (dismissedAt && differenceInCalendarDays(new Date(), dismissedAt) < 14) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
color: 'notice',
|
||||
label: data.data.changes > 0 ? 'Updates Paused' : 'License Expired',
|
||||
options: [
|
||||
{
|
||||
label: `${data.data.changes} New Updates`,
|
||||
color: 'success',
|
||||
leftSlot: <Icon icon="gift" />,
|
||||
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
||||
hidden: data.data.changes === 0 || data.data.changesUrl == null,
|
||||
onSelect: () => openUrl(data.data.changesUrl ?? ''),
|
||||
},
|
||||
{
|
||||
type: 'separator',
|
||||
label: `License expired ${formatDate(data.data.periodEnd, 'MMM dd, yyyy')}`,
|
||||
},
|
||||
{
|
||||
label: <div className="min-w-[12rem]">Renew License</div>,
|
||||
leftSlot: <Icon icon="refresh" />,
|
||||
rightSlot: <Icon icon="external_link" size="sm" className="opacity-disabled" />,
|
||||
hidden: data.data.changesUrl == null,
|
||||
onSelect: () => openUrl(data.data.billingUrl),
|
||||
},
|
||||
{
|
||||
label: 'Enter License Key',
|
||||
leftSlot: <Icon icon="key_round" />,
|
||||
hidden: data.data.changesUrl == null,
|
||||
onSelect: openLicenseDialog,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
{
|
||||
label: <span className="text-text-subtle">Remind me Later</span>,
|
||||
leftSlot: <Icon icon="alarm_clock" className="text-text-subtle" />,
|
||||
onSelect: () => jotaiStore.set(dismissedAtom, new Date().toISOString()),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function LicenseBadge() {
|
||||
return (
|
||||
<CargoFeature feature="license">
|
||||
<LicenseBadgeCmp />
|
||||
</CargoFeature>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseBadgeCmp() {
|
||||
const { check } = useLicense();
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const dismissed = useAtomValue(dismissedAtom);
|
||||
|
||||
// Dismissed license badge
|
||||
if (settings.hideLicenseBadge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (check.error) {
|
||||
// Failed to check for license. Probably a network or server error, so just don't show anything.
|
||||
return null;
|
||||
}
|
||||
|
||||
// Hasn't loaded yet
|
||||
if (check.data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const detail = getDetail(check.data, dismissed);
|
||||
if (detail == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (detail.options && detail.options.length > 0) {
|
||||
return (
|
||||
<Dropdown items={detail.options}>
|
||||
<PillButton color={detail.color}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{detail.label} <Icon icon="chevron_down" className="opacity-60" />
|
||||
</div>
|
||||
</PillButton>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PillButton color={detail.color} onClick={openLicenseDialog}>
|
||||
{detail.label}
|
||||
</PillButton>
|
||||
);
|
||||
}
|
||||
|
||||
function openLicenseDialog() {
|
||||
openSettings.mutate('license');
|
||||
}
|
||||
31
apps/yaak-client/components/LocalImage.tsx
Normal file
31
apps/yaak-client/components/LocalImage.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { resolveResource } from '@tauri-apps/api/path';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LocalImage({ src: srcPath, className }: Props) {
|
||||
const src = useQuery({
|
||||
queryKey: ['local-image', srcPath],
|
||||
queryFn: async () => {
|
||||
const p = await resolveResource(srcPath);
|
||||
return convertFileSrc(p);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<img
|
||||
src={src.data}
|
||||
alt="Response preview"
|
||||
className={classNames(
|
||||
className,
|
||||
'transition-opacity',
|
||||
src.data == null ? 'opacity-0' : 'opacity-100',
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
113
apps/yaak-client/components/Markdown.tsx
Normal file
113
apps/yaak-client/components/Markdown.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import ReactMarkdown, { type Components } from 'react-markdown';
|
||||
import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { Prose } from './Prose';
|
||||
|
||||
interface Props {
|
||||
children: string | null;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Markdown({ children, className }: Props) {
|
||||
if (children == null) return null;
|
||||
|
||||
return (
|
||||
<Prose className={className}>
|
||||
<ErrorBoundary name="Markdown">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</ErrorBoundary>
|
||||
</Prose>
|
||||
);
|
||||
}
|
||||
|
||||
const prismTheme = {
|
||||
'pre[class*="language-"]': {
|
||||
// Needs to be here, so the lib doesn't add its own
|
||||
},
|
||||
|
||||
// Syntax tokens
|
||||
comment: { color: 'var(--textSubtle)' },
|
||||
prolog: { color: 'var(--textSubtle)' },
|
||||
doctype: { color: 'var(--textSubtle)' },
|
||||
cdata: { color: 'var(--textSubtle)' },
|
||||
|
||||
punctuation: { color: 'var(--textSubtle)' },
|
||||
|
||||
property: { color: 'var(--primary)' },
|
||||
'attr-name': { color: 'var(--primary)' },
|
||||
|
||||
string: { color: 'var(--notice)' },
|
||||
char: { color: 'var(--notice)' },
|
||||
|
||||
number: { color: 'var(--info)' },
|
||||
constant: { color: 'var(--info)' },
|
||||
symbol: { color: 'var(--info)' },
|
||||
|
||||
boolean: { color: 'var(--warning)' },
|
||||
'attr-value': { color: 'var(--warning)' },
|
||||
|
||||
variable: { color: 'var(--success)' },
|
||||
|
||||
tag: { color: 'var(--info)' },
|
||||
operator: { color: 'var(--danger)' },
|
||||
keyword: { color: 'var(--danger)' },
|
||||
function: { color: 'var(--success)' },
|
||||
'class-name': { color: 'var(--primary)' },
|
||||
builtin: { color: 'var(--danger)' },
|
||||
selector: { color: 'var(--danger)' },
|
||||
inserted: { color: 'var(--success)' },
|
||||
deleted: { color: 'var(--danger)' },
|
||||
regex: { color: 'var(--warning)' },
|
||||
|
||||
important: { color: 'var(--danger)', fontWeight: 'bold' },
|
||||
italic: { fontStyle: 'italic' },
|
||||
bold: { fontWeight: 'bold' },
|
||||
entity: { cursor: 'help' },
|
||||
};
|
||||
|
||||
const lineStyle: CSSProperties = {
|
||||
paddingRight: '1.5em',
|
||||
paddingLeft: '0',
|
||||
opacity: 0.5,
|
||||
};
|
||||
|
||||
const markdownComponents: Partial<Components> = {
|
||||
// Ensure links open in external browser by adding target="_blank"
|
||||
a: ({ href, children, ...rest }) => {
|
||||
if (href && !href.match(/https?:\/\//)) {
|
||||
href = `http://${href}`;
|
||||
}
|
||||
return (
|
||||
<a target="_blank" rel="noreferrer noopener" href={href} {...rest}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
code(props) {
|
||||
const { children, className, ref, ...extraProps } = props;
|
||||
extraProps.node = undefined;
|
||||
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...extraProps}
|
||||
CodeTag="code"
|
||||
showLineNumbers
|
||||
PreTag="div"
|
||||
lineNumberStyle={lineStyle}
|
||||
language={match[1]}
|
||||
style={prismTheme}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code {...extraProps} ref={ref} className={className}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
};
|
||||
83
apps/yaak-client/components/MarkdownEditor.tsx
Normal file
83
apps/yaak-client/components/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import classNames from 'classnames';
|
||||
import { useRef, useState } from 'react';
|
||||
import type { EditorProps } from './core/Editor/Editor';
|
||||
import { Editor } from './core/Editor/LazyEditor';
|
||||
import { SegmentedControl } from './core/SegmentedControl';
|
||||
import { Markdown } from './Markdown';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function MarkdownEditor({
|
||||
className,
|
||||
editorClassName,
|
||||
defaultValue,
|
||||
onChange,
|
||||
name,
|
||||
forceUpdateKey,
|
||||
...editorProps
|
||||
}: Props) {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>(defaultValue ? 'preview' : 'edit');
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const editor = (
|
||||
<Editor
|
||||
hideGutter
|
||||
wrapLines
|
||||
className={classNames(editorClassName, '[&_.cm-line]:!max-w-lg max-h-full')}
|
||||
language="markdown"
|
||||
defaultValue={defaultValue}
|
||||
onChange={onChange}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
{...editorProps}
|
||||
/>
|
||||
);
|
||||
|
||||
const preview =
|
||||
defaultValue.length === 0 ? (
|
||||
<p className="text-text-subtlest">No description</p>
|
||||
) : (
|
||||
<div className="pr-1.5 overflow-y-auto max-h-full [&_*]:cursor-auto [&_*]:select-auto">
|
||||
<Markdown className="max-w-lg select-auto cursor-auto">{defaultValue}</Markdown>
|
||||
</div>
|
||||
);
|
||||
|
||||
const contents = viewMode === 'preview' ? preview : editor;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classNames(
|
||||
'group/markdown',
|
||||
'relative w-full h-full pt-1.5 rounded-md gap-x-1.5',
|
||||
'min-w-0', // Not sure why this is needed
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="h-full w-full">{contents}</div>
|
||||
<div className="absolute top-0 right-0 pt-1.5 pr-1.5">
|
||||
<SegmentedControl
|
||||
name={name}
|
||||
label="View mode"
|
||||
hideLabel
|
||||
onChange={setViewMode}
|
||||
value={viewMode}
|
||||
className="opacity-0 group-focus-within/markdown:opacity-100 group-hover/markdown:opacity-100"
|
||||
options={[
|
||||
{ icon: 'eye', label: 'Preview mode', value: 'preview' },
|
||||
{ icon: 'pencil', label: 'Edit mode', value: 'edit' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/yaak-client/components/MoveToWorkspaceDialog.tsx
Normal file
89
apps/yaak-client/components/MoveToWorkspaceDialog.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { GrpcRequest, HttpRequest, WebsocketRequest } from '@yaakapp-internal/models';
|
||||
import { patchModel, workspacesAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
import { pluralizeCount } from '../lib/pluralize';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { router } from '../lib/router';
|
||||
import { showToast } from '../lib/toast';
|
||||
import { Button } from './core/Button';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { Select } from './core/Select';
|
||||
import { VStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
activeWorkspaceId: string;
|
||||
requests: (HttpRequest | GrpcRequest | WebsocketRequest)[];
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
export function MoveToWorkspaceDialog({ onDone, requests, activeWorkspaceId }: Props) {
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(activeWorkspaceId);
|
||||
|
||||
const targetWorkspace = workspaces.find((w) => w.id === selectedWorkspaceId);
|
||||
const isSameWorkspace = selectedWorkspaceId === activeWorkspaceId;
|
||||
|
||||
return (
|
||||
<VStack space={4} className="mb-4">
|
||||
<Select
|
||||
label="Target Workspace"
|
||||
name="workspace"
|
||||
value={selectedWorkspaceId}
|
||||
onChange={setSelectedWorkspaceId}
|
||||
options={workspaces.map((w) => ({
|
||||
label: w.id === activeWorkspaceId ? `${w.name} (current)` : w.name,
|
||||
value: w.id,
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={isSameWorkspace}
|
||||
onClick={async () => {
|
||||
const patch = {
|
||||
workspaceId: selectedWorkspaceId,
|
||||
folderId: null,
|
||||
};
|
||||
|
||||
await Promise.all(requests.map((r) => patchModel(r, patch)));
|
||||
|
||||
// Hide after a moment, to give time for requests to disappear
|
||||
setTimeout(onDone, 100);
|
||||
showToast({
|
||||
id: 'workspace-moved',
|
||||
message:
|
||||
requests.length === 1 && requests[0] != null ? (
|
||||
<>
|
||||
<InlineCode>{resolvedModelName(requests[0])}</InlineCode> moved to{' '}
|
||||
<InlineCode>{targetWorkspace?.name ?? 'unknown'}</InlineCode>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{pluralizeCount('request', requests.length)} moved to{' '}
|
||||
<InlineCode>{targetWorkspace?.name ?? 'unknown'}</InlineCode>
|
||||
</>
|
||||
),
|
||||
action: ({ hide }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
color="secondary"
|
||||
className="mr-auto min-w-[5rem]"
|
||||
onClick={async () => {
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId: selectedWorkspaceId },
|
||||
});
|
||||
hide();
|
||||
}}
|
||||
>
|
||||
Switch to Workspace
|
||||
</Button>
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
{requests.length === 1 ? 'Move' : `Move ${pluralizeCount('Request', requests.length)}`}
|
||||
</Button>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
90
apps/yaak-client/components/Overlay.tsx
Normal file
90
apps/yaak-client/components/Overlay.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import classNames from 'classnames';
|
||||
import { FocusTrap } from 'focus-trap-react';
|
||||
import * as m from 'motion/react-m';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { Portal } from './Portal';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
portalName: string;
|
||||
open: boolean;
|
||||
onClose?: () => void;
|
||||
zIndex?: keyof typeof zIndexes;
|
||||
variant?: 'default' | 'transparent';
|
||||
noBackdrop?: boolean;
|
||||
}
|
||||
|
||||
const zIndexes: Record<number, string> = {
|
||||
10: 'z-10',
|
||||
20: 'z-20',
|
||||
30: 'z-30',
|
||||
40: 'z-40',
|
||||
50: 'z-50',
|
||||
};
|
||||
|
||||
export function Overlay({
|
||||
variant = 'default',
|
||||
zIndex = 30,
|
||||
open,
|
||||
onClose,
|
||||
portalName,
|
||||
noBackdrop,
|
||||
children,
|
||||
}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
if (noBackdrop) {
|
||||
return (
|
||||
<Portal name={portalName}>
|
||||
{open && (
|
||||
<FocusTrap focusTrapOptions={{ clickOutsideDeactivates: true }}>
|
||||
{/* NOTE: <div> wrapper is required for some reason, or FocusTrap complains */}
|
||||
<div>{children}</div>
|
||||
</FocusTrap>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal name={portalName}>
|
||||
{open && (
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
// Allow outside click so we can click things like toasts
|
||||
allowOutsideClick: true,
|
||||
delayInitialFocus: true,
|
||||
checkCanFocusTrap: async () => {
|
||||
// Not sure why delayInitialFocus: true doesn't help, but having this no-op promise
|
||||
// seems to be required to make things work.
|
||||
},
|
||||
}}
|
||||
>
|
||||
<m.div
|
||||
ref={containerRef}
|
||||
className={classNames('fixed inset-0', zIndexes[zIndex])}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
onClick={onClose}
|
||||
className={classNames(
|
||||
'absolute inset-0',
|
||||
variant === 'default' && 'bg-backdrop backdrop-blur-sm',
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Show the draggable region at the top */}
|
||||
{/* TODO: Figure out tauri drag region and also make clickable still */}
|
||||
{variant === 'default' && (
|
||||
<div data-tauri-drag-region className="absolute top-0 left-0 h-md right-0" />
|
||||
)}
|
||||
{children}
|
||||
</m.div>
|
||||
</FocusTrap>
|
||||
)}
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
13
apps/yaak-client/components/Portal.tsx
Normal file
13
apps/yaak-client/components/Portal.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { usePortal } from '../hooks/usePortal';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function Portal({ children, name }: Props) {
|
||||
const portal = usePortal(name);
|
||||
return createPortal(children, portal);
|
||||
}
|
||||
210
apps/yaak-client/components/Prose.css
Normal file
210
apps/yaak-client/components/Prose.css
Normal file
@@ -0,0 +1,210 @@
|
||||
.prose {
|
||||
@apply text-text;
|
||||
|
||||
& > :first-child {
|
||||
@apply mt-0;
|
||||
}
|
||||
|
||||
& > :last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
img,
|
||||
video,
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
table,
|
||||
blockquote,
|
||||
hr,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply my-5;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
@apply mt-10 leading-tight text-balance;
|
||||
}
|
||||
|
||||
p {
|
||||
@apply text-pretty;
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply text-4xl font-bold;
|
||||
}
|
||||
|
||||
h2 {
|
||||
@apply text-2xl font-bold;
|
||||
}
|
||||
|
||||
h3 {
|
||||
@apply text-xl font-bold;
|
||||
}
|
||||
|
||||
em {
|
||||
@apply italic;
|
||||
}
|
||||
|
||||
strong {
|
||||
@apply font-bold;
|
||||
}
|
||||
|
||||
ul {
|
||||
@apply list-disc;
|
||||
|
||||
ul,
|
||||
ol {
|
||||
@apply my-0;
|
||||
}
|
||||
}
|
||||
|
||||
ol {
|
||||
@apply list-decimal;
|
||||
|
||||
ol,
|
||||
ul {
|
||||
@apply my-0;
|
||||
}
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
@apply pl-6;
|
||||
|
||||
li p {
|
||||
@apply inline-block my-0;
|
||||
}
|
||||
|
||||
li {
|
||||
@apply pl-2;
|
||||
}
|
||||
|
||||
li::marker {
|
||||
@apply text-success;
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-notice hover:underline;
|
||||
|
||||
* {
|
||||
@apply text-notice !important;
|
||||
}
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
@apply max-h-[65vh];
|
||||
@apply w-auto mx-auto rounded-md;
|
||||
}
|
||||
|
||||
table code,
|
||||
p code,
|
||||
ol code,
|
||||
ul code {
|
||||
@apply text-xs bg-surface-active text-info font-normal whitespace-nowrap;
|
||||
@apply px-1.5 py-0.5 rounded not-italic;
|
||||
@apply select-text;
|
||||
}
|
||||
|
||||
pre {
|
||||
@apply bg-surface-highlight text-text !important;
|
||||
@apply px-4 py-3 rounded-md;
|
||||
@apply overflow-auto whitespace-pre;
|
||||
@apply text-editor font-mono;
|
||||
|
||||
code {
|
||||
@apply font-normal;
|
||||
}
|
||||
}
|
||||
|
||||
.banner {
|
||||
@apply border border-dashed;
|
||||
@apply border-border bg-surface-highlight text-text px-4 py-3 rounded text-base;
|
||||
|
||||
&::before {
|
||||
@apply block font-bold mb-1;
|
||||
@apply text-text-subtlest;
|
||||
|
||||
content: "Note";
|
||||
}
|
||||
|
||||
&.x-theme-banner--secondary::before {
|
||||
content: "Info";
|
||||
}
|
||||
|
||||
&.x-theme-banner--success::before {
|
||||
content: "Tip";
|
||||
}
|
||||
|
||||
&.x-theme-banner--notice::before {
|
||||
content: "Important";
|
||||
}
|
||||
|
||||
&.x-theme-banner--warning::before {
|
||||
content: "Warning";
|
||||
}
|
||||
|
||||
&.x-theme-banner--danger::before {
|
||||
content: "Caution";
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
@apply italic py-3 pl-5 pr-3 border-l-8 border-surface-active text-lg text-text bg-surface-highlight rounded shadow-lg;
|
||||
|
||||
p {
|
||||
@apply m-0;
|
||||
}
|
||||
}
|
||||
|
||||
h2[id] > a .icon.icon-link {
|
||||
@apply hidden w-4 h-4 bg-success ml-2;
|
||||
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24' stroke='currentColor' stroke-width='2' fill='none' stroke-linecap='round' stroke-linejoin='round' class='css-i6dzq1'%3E%3Cpath d='M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'%3E%3C/path%3E%3Cpath d='M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'%3E%3C/path%3E%3C/svg%3E");
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
|
||||
&:hover {
|
||||
@apply bg-notice;
|
||||
}
|
||||
}
|
||||
|
||||
h2[id]:hover {
|
||||
.icon.icon-link {
|
||||
@apply inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
@apply border-secondary border-dashed md:mx-[25%] my-10;
|
||||
}
|
||||
|
||||
figure {
|
||||
img {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
@apply relative pl-9 text-success text-sm pt-1;
|
||||
|
||||
p {
|
||||
@apply m-0;
|
||||
}
|
||||
}
|
||||
|
||||
figcaption::before {
|
||||
@apply border-info absolute left-2 top-0 h-3.5 w-6 rounded-bl border-l border-b border-dotted;
|
||||
content: "";
|
||||
}
|
||||
}
|
||||
}
|
||||
12
apps/yaak-client/components/Prose.tsx
Normal file
12
apps/yaak-client/components/Prose.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import classNames from 'classnames';
|
||||
import type { ReactNode } from 'react';
|
||||
import './Prose.css';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Prose({ className, ...props }: Props) {
|
||||
return <div className={classNames('prose', className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { GrpcConnection } from '@yaakapp-internal/models';
|
||||
import { deleteModel } from '@yaakapp-internal/models';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { useDeleteGrpcConnections } from '../hooks/useDeleteGrpcConnections';
|
||||
import { pluralizeCount } from '../lib/pluralize';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
connections: GrpcConnection[];
|
||||
activeConnection: GrpcConnection;
|
||||
onPinnedConnectionId: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RecentGrpcConnectionsDropdown({
|
||||
activeConnection,
|
||||
connections,
|
||||
onPinnedConnectionId,
|
||||
}: Props) {
|
||||
const deleteAllConnections = useDeleteGrpcConnections(activeConnection?.requestId);
|
||||
const latestConnectionId = connections[0]?.id ?? 'n/a';
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: 'Clear Connection',
|
||||
onSelect: () => deleteModel(activeConnection),
|
||||
disabled: connections.length === 0,
|
||||
},
|
||||
{
|
||||
label: `Clear ${pluralizeCount('Connection', connections.length)}`,
|
||||
onSelect: deleteAllConnections.mutate,
|
||||
hidden: connections.length <= 1,
|
||||
disabled: connections.length === 0,
|
||||
},
|
||||
{ type: 'separator', label: 'History' },
|
||||
...connections.map((c) => ({
|
||||
label: (
|
||||
<HStack space={2}>
|
||||
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago •{' '}
|
||||
<span className="font-mono text-sm">{c.elapsed}ms</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedConnectionId(c.id),
|
||||
})),
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
title="Show connection history"
|
||||
icon={activeConnection?.id === latestConnectionId ? 'history' : 'pin'}
|
||||
className="m-0.5 text-text-subtle"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
89
apps/yaak-client/components/RecentHttpResponsesDropdown.tsx
Normal file
89
apps/yaak-client/components/RecentHttpResponsesDropdown.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { deleteModel } from '@yaakapp-internal/models';
|
||||
import { useCopyHttpResponse } from '../hooks/useCopyHttpResponse';
|
||||
import { useDeleteHttpResponses } from '../hooks/useDeleteHttpResponses';
|
||||
import { useSaveResponse } from '../hooks/useSaveResponse';
|
||||
import { pluralize } from '../lib/pluralize';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { HttpStatusTag } from './core/HttpStatusTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
responses: HttpResponse[];
|
||||
activeResponse: HttpResponse;
|
||||
onPinnedResponseId: (id: string) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const RecentHttpResponsesDropdown = function ResponsePane({
|
||||
activeResponse,
|
||||
responses,
|
||||
onPinnedResponseId,
|
||||
}: Props) {
|
||||
const deleteAllResponses = useDeleteHttpResponses(activeResponse?.requestId);
|
||||
const latestResponseId = responses[0]?.id ?? 'n/a';
|
||||
const saveResponse = useSaveResponse(activeResponse);
|
||||
const copyResponse = useCopyHttpResponse(activeResponse);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: 'Save to File',
|
||||
onSelect: saveResponse.mutate,
|
||||
leftSlot: <Icon icon="save" />,
|
||||
hidden: responses.length === 0 || !!activeResponse.error,
|
||||
disabled: activeResponse.state !== 'closed' && activeResponse.status >= 100,
|
||||
},
|
||||
{
|
||||
label: 'Copy Body',
|
||||
onSelect: copyResponse.mutate,
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
hidden: responses.length === 0 || !!activeResponse.error,
|
||||
disabled: activeResponse.state !== 'closed' && activeResponse.status >= 100,
|
||||
},
|
||||
{
|
||||
label: 'Delete',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => deleteModel(activeResponse),
|
||||
},
|
||||
{
|
||||
label: 'Unpin Response',
|
||||
onSelect: () => onPinnedResponseId(activeResponse.id),
|
||||
leftSlot: <Icon icon="unpin" />,
|
||||
hidden: latestResponseId === activeResponse.id,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: 'separator', label: 'History' },
|
||||
{
|
||||
label: `Delete ${responses.length} ${pluralize('Response', responses.length)}`,
|
||||
onSelect: deleteAllResponses.mutate,
|
||||
hidden: responses.length === 0,
|
||||
disabled: responses.length === 0,
|
||||
},
|
||||
{ type: 'separator' },
|
||||
...responses.map((r: HttpResponse) => ({
|
||||
label: (
|
||||
<HStack space={2}>
|
||||
<HttpStatusTag short className="text-xs" response={r} />
|
||||
<span className="text-text-subtle">→</span>{' '}
|
||||
<span className="font-mono text-sm">{r.elapsed >= 0 ? `${r.elapsed}ms` : 'n/a'}</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeResponse?.id === r.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedResponseId(r.id),
|
||||
})),
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
title="Show response history"
|
||||
icon={activeResponse?.id === latestResponseId ? 'history' : 'pin'}
|
||||
className="m-0.5 text-text-subtle"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
101
apps/yaak-client/components/RecentRequestsDropdown.tsx
Normal file
101
apps/yaak-client/components/RecentRequestsDropdown.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import classNames from 'classnames';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useActiveRequest } from '../hooks/useActiveRequest';
|
||||
import { activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { allRequestsAtom } from '../hooks/useAllRequests';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import { useKeyboardEvent } from '../hooks/useKeyboardEvent';
|
||||
import { useRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { router } from '../lib/router';
|
||||
import { Button } from './core/Button';
|
||||
import type { DropdownItem, DropdownRef } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { HttpMethodTag } from './core/HttpMethodTag';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RecentRequestsDropdown({ className }: Props) {
|
||||
const activeRequest = useActiveRequest();
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
const [recentRequestIds] = useRecentRequests();
|
||||
|
||||
// Handle key-up
|
||||
// TODO: Somehow make useHotKey have this functionality. Note: e.key does not work
|
||||
// on Linux, for example, when Control is mapped to CAPS. This will never fire.
|
||||
useKeyboardEvent('keyup', 'Control', () => {
|
||||
if (dropdownRef.current?.isOpen) {
|
||||
dropdownRef.current?.select?.();
|
||||
}
|
||||
});
|
||||
|
||||
useHotKey('switcher.prev', () => {
|
||||
if (!dropdownRef.current?.isOpen) {
|
||||
// Select the second because the first is the current request
|
||||
dropdownRef.current?.open(1);
|
||||
} else {
|
||||
dropdownRef.current?.next?.();
|
||||
}
|
||||
});
|
||||
|
||||
useHotKey('switcher.next', () => {
|
||||
if (!dropdownRef.current?.isOpen) dropdownRef.current?.open();
|
||||
dropdownRef.current?.prev?.();
|
||||
});
|
||||
|
||||
const items = useMemo(() => {
|
||||
const activeWorkspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
if (activeWorkspaceId === null) return [];
|
||||
|
||||
const requests = jotaiStore.get(allRequestsAtom);
|
||||
const recentRequestItems: DropdownItem[] = [];
|
||||
for (const id of recentRequestIds) {
|
||||
const request = requests.find((r) => r.id === id);
|
||||
if (request === undefined) continue;
|
||||
|
||||
recentRequestItems.push({
|
||||
label: resolvedModelName(request),
|
||||
leftSlot: <HttpMethodTag short className="text-xs" request={request} />,
|
||||
onSelect: async () => {
|
||||
await router.navigate({
|
||||
to: '/workspaces/$workspaceId',
|
||||
params: { workspaceId: activeWorkspaceId },
|
||||
search: (prev) => ({ ...prev, request_id: request.id }),
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// No recent requests to show
|
||||
if (recentRequestItems.length === 0) {
|
||||
return [
|
||||
{
|
||||
key: 'no-recent-requests',
|
||||
label: 'No recent requests',
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return recentRequestItems.slice(0, 20);
|
||||
}, [recentRequestIds]);
|
||||
|
||||
return (
|
||||
<Dropdown ref={dropdownRef} items={items}>
|
||||
<Button
|
||||
size="sm"
|
||||
hotkeyAction="switcher.toggle"
|
||||
className={classNames(
|
||||
className,
|
||||
'truncate pointer-events-auto',
|
||||
activeRequest == null && 'text-text-subtlest italic',
|
||||
)}
|
||||
>
|
||||
{resolvedModelName(activeRequest)}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { WebsocketConnection } from '@yaakapp-internal/models';
|
||||
import { deleteModel, getModel } from '@yaakapp-internal/models';
|
||||
import { formatDistanceToNowStrict } from 'date-fns';
|
||||
import { deleteWebsocketConnections } from '../commands/deleteWebsocketConnections';
|
||||
import { pluralizeCount } from '../lib/pluralize';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
connections: WebsocketConnection[];
|
||||
activeConnection: WebsocketConnection;
|
||||
onPinnedConnectionId: (id: string) => void;
|
||||
}
|
||||
|
||||
export function RecentWebsocketConnectionsDropdown({
|
||||
activeConnection,
|
||||
connections,
|
||||
onPinnedConnectionId,
|
||||
}: Props) {
|
||||
const latestConnectionId = connections[0]?.id ?? 'n/a';
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: 'Clear Connection',
|
||||
onSelect: () => deleteModel(activeConnection),
|
||||
disabled: connections.length === 0,
|
||||
},
|
||||
{
|
||||
label: `Clear ${pluralizeCount('Connection', connections.length)}`,
|
||||
onSelect: () => {
|
||||
const request = getModel('websocket_request', activeConnection.requestId);
|
||||
if (request != null) {
|
||||
deleteWebsocketConnections.mutate(request);
|
||||
}
|
||||
},
|
||||
hidden: connections.length <= 1,
|
||||
disabled: connections.length === 0,
|
||||
},
|
||||
{ type: 'separator', label: 'History' },
|
||||
...connections.map((c) => ({
|
||||
label: (
|
||||
<HStack space={2}>
|
||||
{formatDistanceToNowStrict(`${c.createdAt}Z`)} ago •{' '}
|
||||
<span className="font-mono text-sm">{c.elapsed}ms</span>
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: activeConnection?.id === c.id ? <Icon icon="check" /> : <Icon icon="empty" />,
|
||||
onSelect: () => onPinnedConnectionId(c.id),
|
||||
})),
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
title="Show connection history"
|
||||
icon={activeConnection?.id === latestConnectionId ? 'history' : 'pin'}
|
||||
className="m-0.5 text-text-subtle"
|
||||
size="sm"
|
||||
iconSize="md"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
41
apps/yaak-client/components/RedirectToLatestWorkspace.tsx
Normal file
41
apps/yaak-client/components/RedirectToLatestWorkspace.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { workspacesAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import { getRecentCookieJars } from '../hooks/useRecentCookieJars';
|
||||
import { getRecentEnvironments } from '../hooks/useRecentEnvironments';
|
||||
import { getRecentRequests } from '../hooks/useRecentRequests';
|
||||
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
|
||||
import { router } from '../lib/router';
|
||||
|
||||
export function RedirectToLatestWorkspace() {
|
||||
const workspaces = useAtomValue(workspacesAtom);
|
||||
const recentWorkspaces = useRecentWorkspaces();
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaces.length === 0 || recentWorkspaces == null) {
|
||||
console.log('No workspaces found to redirect to. Skipping.', {
|
||||
workspaces,
|
||||
recentWorkspaces,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const workspaceId = recentWorkspaces[0] ?? workspaces[0]?.id ?? 'n/a';
|
||||
const environmentId = (await getRecentEnvironments(workspaceId))[0] ?? null;
|
||||
const cookieJarId = (await getRecentCookieJars(workspaceId))[0] ?? null;
|
||||
const requestId = (await getRecentRequests(workspaceId))[0] ?? null;
|
||||
const params = { workspaceId };
|
||||
const search = {
|
||||
cookie_jar_id: cookieJarId,
|
||||
environment_id: environmentId,
|
||||
request_id: requestId,
|
||||
};
|
||||
|
||||
console.log('Redirecting to workspace', params, search);
|
||||
await router.navigate({ to: '/workspaces/$workspaceId', params, search });
|
||||
})();
|
||||
}, [recentWorkspaces, workspaces, workspaces.length]);
|
||||
|
||||
return null;
|
||||
}
|
||||
102
apps/yaak-client/components/RequestBodyViewer.tsx
Normal file
102
apps/yaak-client/components/RequestBodyViewer.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { useHttpRequestBody } from '../hooks/useHttpRequestBody';
|
||||
import { getMimeTypeFromContentType, languageFromContentType } from '../lib/contentType';
|
||||
import { LoadingIcon } from './core/LoadingIcon';
|
||||
import { EmptyStateText } from './EmptyStateText';
|
||||
import { AudioViewer } from './responseViewers/AudioViewer';
|
||||
import { CsvViewer } from './responseViewers/CsvViewer';
|
||||
import { ImageViewer } from './responseViewers/ImageViewer';
|
||||
import { MultipartViewer } from './responseViewers/MultipartViewer';
|
||||
import { SvgViewer } from './responseViewers/SvgViewer';
|
||||
import { TextViewer } from './responseViewers/TextViewer';
|
||||
import { VideoViewer } from './responseViewers/VideoViewer';
|
||||
import { WebPageViewer } from './responseViewers/WebPageViewer';
|
||||
|
||||
const PdfViewer = lazy(() =>
|
||||
import('./responseViewers/PdfViewer').then((m) => ({ default: m.PdfViewer })),
|
||||
);
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function RequestBodyViewer({ response }: Props) {
|
||||
return <RequestBodyViewerInner key={response.id} response={response} />;
|
||||
}
|
||||
|
||||
function RequestBodyViewerInner({ response }: Props) {
|
||||
const { data, isLoading, error } = useHttpRequestBody(response);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon />
|
||||
</EmptyStateText>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <EmptyStateText>Error loading request body: {error.message}</EmptyStateText>;
|
||||
}
|
||||
|
||||
if (data?.bodyText == null || data.bodyText.length === 0) {
|
||||
return <EmptyStateText>No request body</EmptyStateText>;
|
||||
}
|
||||
|
||||
const { bodyText, body } = data;
|
||||
|
||||
// Try to detect language from content-type header that was sent
|
||||
const contentTypeHeader = response.requestHeaders.find(
|
||||
(h) => h.name.toLowerCase() === 'content-type',
|
||||
);
|
||||
const contentType = contentTypeHeader?.value ?? null;
|
||||
const mimeType = contentType ? getMimeTypeFromContentType(contentType).essence : null;
|
||||
const language = languageFromContentType(contentType, bodyText);
|
||||
|
||||
// Route to appropriate viewer based on content type
|
||||
if (mimeType?.match(/^multipart/i)) {
|
||||
const boundary = contentType?.split('boundary=')[1] ?? 'unknown';
|
||||
// Create a copy because parseMultipart may detach the buffer
|
||||
const bodyCopy = new Uint8Array(body);
|
||||
return (
|
||||
<MultipartViewer data={bodyCopy} boundary={boundary} idPrefix={`request.${response.id}`} />
|
||||
);
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^image\/svg/i)) {
|
||||
return <SvgViewer text={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^image/i)) {
|
||||
return <ImageViewer data={body.buffer} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^audio/i)) {
|
||||
return <AudioViewer data={body} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^video/i)) {
|
||||
return <VideoViewer data={body} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/csv|tab-separated/i)) {
|
||||
return <CsvViewer text={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/^text\/html/i)) {
|
||||
return <WebPageViewer html={bodyText} />;
|
||||
}
|
||||
|
||||
if (mimeType?.match(/pdf/i)) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingIcon />}>
|
||||
<PdfViewer data={body} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TextViewer text={bodyText} language={language} stateKey={`request.body.${response.id}`} />
|
||||
);
|
||||
}
|
||||
78
apps/yaak-client/components/RequestMethodDropdown.tsx
Normal file
78
apps/yaak-client/components/RequestMethodDropdown.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import { patchModel } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { showPrompt } from '../lib/prompt';
|
||||
import { Button } from './core/Button';
|
||||
import type { DropdownItem } from './core/Dropdown';
|
||||
import { HttpMethodTag, HttpMethodTagRaw } from './core/HttpMethodTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import type { RadioDropdownItem } from './core/RadioDropdown';
|
||||
import { RadioDropdown } from './core/RadioDropdown';
|
||||
|
||||
type Props = {
|
||||
request: HttpRequest;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const radioItems: RadioDropdownItem<string>[] = [
|
||||
'GET',
|
||||
'PUT',
|
||||
'POST',
|
||||
'PATCH',
|
||||
'DELETE',
|
||||
'OPTIONS',
|
||||
'QUERY',
|
||||
'HEAD',
|
||||
].map((m) => ({
|
||||
value: m,
|
||||
label: <HttpMethodTagRaw method={m} />,
|
||||
}));
|
||||
|
||||
export const RequestMethodDropdown = memo(function RequestMethodDropdown({
|
||||
request,
|
||||
className,
|
||||
}: Props) {
|
||||
const handleChange = useCallback(
|
||||
async (method: string) => {
|
||||
await patchModel(request, { method });
|
||||
},
|
||||
[request],
|
||||
);
|
||||
|
||||
const itemsAfter = useMemo<DropdownItem[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'custom',
|
||||
label: 'CUSTOM',
|
||||
leftSlot: <Icon icon="sparkles" />,
|
||||
onSelect: async () => {
|
||||
const newMethod = await showPrompt({
|
||||
id: 'custom-method',
|
||||
label: 'Http Method',
|
||||
title: 'Custom Method',
|
||||
confirmText: 'Save',
|
||||
description: 'Enter a custom method name',
|
||||
placeholder: 'CUSTOM',
|
||||
});
|
||||
if (newMethod == null) return;
|
||||
await handleChange(newMethod);
|
||||
},
|
||||
},
|
||||
],
|
||||
[handleChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<RadioDropdown
|
||||
value={request.method}
|
||||
items={radioItems}
|
||||
itemsAfter={itemsAfter}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<Button size="xs" className={classNames(className, 'text-text-subtle hover:text')}>
|
||||
<HttpMethodTag request={request} noAlias />
|
||||
</Button>
|
||||
</RadioDropdown>
|
||||
);
|
||||
});
|
||||
115
apps/yaak-client/components/ResizeHandle.tsx
Normal file
115
apps/yaak-client/components/ResizeHandle.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import classNames from 'classnames';
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
const START_DISTANCE = 7;
|
||||
|
||||
export interface ResizeHandleEvent {
|
||||
x: number;
|
||||
y: number;
|
||||
xStart: number;
|
||||
yStart: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
style?: CSSProperties;
|
||||
className?: string;
|
||||
onResizeStart?: () => void;
|
||||
onResizeEnd?: () => void;
|
||||
onResizeMove?: (e: ResizeHandleEvent) => void;
|
||||
onReset?: () => void;
|
||||
side: 'left' | 'right' | 'top';
|
||||
justify: 'center' | 'end' | 'start';
|
||||
}
|
||||
|
||||
export function ResizeHandle({
|
||||
style,
|
||||
justify,
|
||||
className,
|
||||
onResizeStart,
|
||||
onResizeEnd,
|
||||
onResizeMove,
|
||||
onReset,
|
||||
side,
|
||||
}: Props) {
|
||||
const vertical = side === 'top';
|
||||
const [isResizing, setIsResizing] = useState<boolean>(false);
|
||||
const moveState = useRef<{
|
||||
move: (e: MouseEvent) => void;
|
||||
up: (e: MouseEvent) => void;
|
||||
calledStart: boolean;
|
||||
xStart: number;
|
||||
yStart: number;
|
||||
} | null>(null);
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: ReactMouseEvent<HTMLDivElement>) => {
|
||||
function move(e: MouseEvent) {
|
||||
if (moveState.current == null) return;
|
||||
|
||||
const xDistance = moveState.current.xStart - e.clientX;
|
||||
const yDistance = moveState.current.yStart - e.clientY;
|
||||
const distance = Math.abs(vertical ? yDistance : xDistance);
|
||||
if (moveState.current.calledStart) {
|
||||
onResizeMove?.({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
xStart: moveState.current.xStart,
|
||||
yStart: moveState.current.yStart,
|
||||
});
|
||||
} else if (distance > START_DISTANCE) {
|
||||
onResizeStart?.();
|
||||
moveState.current.calledStart = true;
|
||||
setIsResizing(true);
|
||||
}
|
||||
}
|
||||
|
||||
function up() {
|
||||
setIsResizing(false);
|
||||
moveState.current = null;
|
||||
document.documentElement.removeEventListener('mousemove', move);
|
||||
document.documentElement.removeEventListener('mouseup', up);
|
||||
onResizeEnd?.();
|
||||
}
|
||||
|
||||
moveState.current = { calledStart: false, xStart: e.clientX, yStart: e.clientY, move, up };
|
||||
|
||||
document.documentElement.addEventListener('mousemove', move);
|
||||
document.documentElement.addEventListener('mouseup', up);
|
||||
},
|
||||
[onResizeEnd, onResizeMove, onResizeStart, vertical],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
style={style}
|
||||
onDoubleClick={onReset}
|
||||
onPointerDown={handlePointerDown}
|
||||
className={classNames(
|
||||
className,
|
||||
'group z-10 flex select-none transition-colors hover:bg-surface-active rounded-full',
|
||||
// 'bg-info', // For debugging
|
||||
vertical ? 'w-full h-1.5 cursor-row-resize' : 'h-full w-1.5 cursor-col-resize',
|
||||
justify === 'center' && 'justify-center',
|
||||
justify === 'end' && 'justify-end',
|
||||
justify === 'start' && 'justify-start',
|
||||
side === 'right' && 'right-0',
|
||||
side === 'left' && 'left-0',
|
||||
side === 'top' && 'top-0',
|
||||
)}
|
||||
>
|
||||
{/* Show global overlay with cursor style to ensure cursor remains the same when moving quickly */}
|
||||
{isResizing && (
|
||||
<div
|
||||
className={classNames(
|
||||
// 'bg-[rgba(255,0,0,0.1)]', // For debugging
|
||||
'fixed -left-[100vw] -right-[100vw] -top-[100vh] -bottom-[100vh]',
|
||||
vertical && 'cursor-row-resize',
|
||||
!vertical && 'cursor-col-resize',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
225
apps/yaak-client/components/ResponseCookies.tsx
Normal file
225
apps/yaak-client/components/ResponseCookies.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useMemo } from 'react';
|
||||
import type { JSX } from 'react/jsx-runtime';
|
||||
import { useHttpResponseEvents } from '../hooks/useHttpResponseEvents';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { DetailsBanner } from './core/DetailsBanner';
|
||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
interface ParsedCookie {
|
||||
name: string;
|
||||
value: string;
|
||||
domain?: string;
|
||||
path?: string;
|
||||
expires?: string;
|
||||
maxAge?: string;
|
||||
secure?: boolean;
|
||||
httpOnly?: boolean;
|
||||
sameSite?: string;
|
||||
isDeleted?: boolean;
|
||||
}
|
||||
|
||||
function parseCookieHeader(cookieHeader: string): Array<{ name: string; value: string }> {
|
||||
// Parse "Cookie: name=value; name2=value2" format
|
||||
return cookieHeader.split(';').map((pair) => {
|
||||
const [name = '', ...valueParts] = pair.split('=');
|
||||
return {
|
||||
name: name.trim(),
|
||||
value: valueParts.join('=').trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseSetCookieHeader(setCookieHeader: string): ParsedCookie {
|
||||
// Parse "Set-Cookie: name=value; Domain=...; Path=..." format
|
||||
const parts = setCookieHeader.split(';').map((p) => p.trim());
|
||||
const [nameValue = '', ...attributes] = parts;
|
||||
const [name = '', ...valueParts] = nameValue.split('=');
|
||||
|
||||
const cookie: ParsedCookie = {
|
||||
name: name.trim(),
|
||||
value: valueParts.join('=').trim(),
|
||||
};
|
||||
|
||||
for (const attr of attributes) {
|
||||
const [key = '', val] = attr.split('=').map((s) => s.trim());
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
if (lowerKey === 'domain') cookie.domain = val;
|
||||
else if (lowerKey === 'path') cookie.path = val;
|
||||
else if (lowerKey === 'expires') cookie.expires = val;
|
||||
else if (lowerKey === 'max-age') cookie.maxAge = val;
|
||||
else if (lowerKey === 'secure') cookie.secure = true;
|
||||
else if (lowerKey === 'httponly') cookie.httpOnly = true;
|
||||
else if (lowerKey === 'samesite') cookie.sameSite = val;
|
||||
}
|
||||
|
||||
// Detect if cookie is being deleted
|
||||
if (cookie.maxAge !== undefined) {
|
||||
const maxAgeNum = Number.parseInt(cookie.maxAge, 10);
|
||||
if (!Number.isNaN(maxAgeNum) && maxAgeNum <= 0) {
|
||||
cookie.isDeleted = true;
|
||||
}
|
||||
} else if (cookie.expires !== undefined) {
|
||||
// Check if expires date is in the past
|
||||
try {
|
||||
const expiresDate = new Date(cookie.expires);
|
||||
if (expiresDate.getTime() < Date.now()) {
|
||||
cookie.isDeleted = true;
|
||||
}
|
||||
} catch {
|
||||
// Invalid date, ignore
|
||||
}
|
||||
}
|
||||
|
||||
return cookie;
|
||||
}
|
||||
|
||||
export function ResponseCookies({ response }: Props) {
|
||||
const { data: events } = useHttpResponseEvents(response);
|
||||
|
||||
const { sentCookies, receivedCookies } = useMemo(() => {
|
||||
if (!events) return { sentCookies: [], receivedCookies: [] };
|
||||
|
||||
// Use Maps to deduplicate by cookie name (latest value wins)
|
||||
const sentMap = new Map<string, { name: string; value: string }>();
|
||||
const receivedMap = new Map<string, ParsedCookie>();
|
||||
|
||||
for (const event of events) {
|
||||
const e = event.event;
|
||||
|
||||
// Cookie headers sent (header_up with name=cookie)
|
||||
if (e.type === 'header_up' && e.name.toLowerCase() === 'cookie') {
|
||||
const cookies = parseCookieHeader(e.value);
|
||||
for (const cookie of cookies) {
|
||||
sentMap.set(cookie.name, cookie);
|
||||
}
|
||||
}
|
||||
|
||||
// Set-Cookie headers received (header_down with name=set-cookie)
|
||||
if (e.type === 'header_down' && e.name.toLowerCase() === 'set-cookie') {
|
||||
const cookie = parseSetCookieHeader(e.value);
|
||||
receivedMap.set(cookie.name, cookie);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sentCookies: Array.from(sentMap.values()),
|
||||
receivedCookies: Array.from(receivedMap.values()),
|
||||
};
|
||||
}, [events]);
|
||||
|
||||
return (
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<DetailsBanner
|
||||
defaultOpen
|
||||
storageKey={`${response.requestId}.sent_cookies`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Sent Cookies <CountBadge showZero count={sentCookies.length} />
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
{sentCookies.length === 0 ? (
|
||||
<NoCookies />
|
||||
) : (
|
||||
<KeyValueRows>
|
||||
{sentCookies.map((cookie, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
<KeyValueRow labelColor="primary" key={i} label={cookie.name}>
|
||||
{cookie.value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
)}
|
||||
</DetailsBanner>
|
||||
|
||||
<DetailsBanner
|
||||
defaultOpen
|
||||
storageKey={`${response.requestId}.received_cookies`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Received Cookies <CountBadge showZero count={receivedCookies.length} />
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
{receivedCookies.length === 0 ? (
|
||||
<NoCookies />
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{receivedCookies.map((cookie, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
<div key={i} className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2 my-1">
|
||||
<span
|
||||
className={classNames(
|
||||
'font-mono text-editor select-auto cursor-auto',
|
||||
cookie.isDeleted ? 'line-through opacity-60 text-text-subtle' : 'text-text',
|
||||
)}
|
||||
>
|
||||
{cookie.name}
|
||||
<span className="text-text-subtlest select-auto cursor-auto mx-0.5">=</span>
|
||||
{cookie.value}
|
||||
</span>
|
||||
{cookie.isDeleted && (
|
||||
<span className="text-xs font-sans text-danger bg-danger/10 px-1.5 py-0.5 rounded">
|
||||
Deleted
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<KeyValueRows>
|
||||
{[
|
||||
cookie.domain && (
|
||||
<KeyValueRow labelColor="info" label="Domain" key="domain">
|
||||
{cookie.domain}
|
||||
</KeyValueRow>
|
||||
),
|
||||
cookie.path && (
|
||||
<KeyValueRow labelColor="info" label="Path" key="path">
|
||||
{cookie.path}
|
||||
</KeyValueRow>
|
||||
),
|
||||
cookie.expires && (
|
||||
<KeyValueRow labelColor="info" label="Expires" key="expires">
|
||||
{cookie.expires}
|
||||
</KeyValueRow>
|
||||
),
|
||||
cookie.maxAge && (
|
||||
<KeyValueRow labelColor="info" label="Max-Age" key="maxAge">
|
||||
{cookie.maxAge}
|
||||
</KeyValueRow>
|
||||
),
|
||||
cookie.secure && (
|
||||
<KeyValueRow labelColor="info" label="Secure" key="secure">
|
||||
true
|
||||
</KeyValueRow>
|
||||
),
|
||||
cookie.httpOnly && (
|
||||
<KeyValueRow labelColor="info" label="HttpOnly" key="httpOnly">
|
||||
true
|
||||
</KeyValueRow>
|
||||
),
|
||||
cookie.sameSite && (
|
||||
<KeyValueRow labelColor="info" label="SameSite" key="sameSite">
|
||||
{cookie.sameSite}
|
||||
</KeyValueRow>
|
||||
),
|
||||
].filter((item): item is JSX.Element => Boolean(item))}
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</DetailsBanner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoCookies() {
|
||||
return <span className="text-text-subtlest text-sm italic">No Cookies</span>;
|
||||
}
|
||||
101
apps/yaak-client/components/ResponseHeaders.tsx
Normal file
101
apps/yaak-client/components/ResponseHeaders.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { useMemo } from 'react';
|
||||
import { CountBadge } from './core/CountBadge';
|
||||
import { DetailsBanner } from './core/DetailsBanner';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function ResponseHeaders({ response }: Props) {
|
||||
const responseHeaders = useMemo(
|
||||
() =>
|
||||
[...response.headers].sort((a, b) =>
|
||||
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
||||
),
|
||||
[response.headers],
|
||||
);
|
||||
const requestHeaders = useMemo(
|
||||
() =>
|
||||
[...response.requestHeaders].sort((a, b) =>
|
||||
a.name.toLocaleLowerCase().localeCompare(b.name.toLocaleLowerCase()),
|
||||
),
|
||||
[response.requestHeaders],
|
||||
);
|
||||
return (
|
||||
<div className="overflow-auto h-full pb-4 gap-y-3 flex flex-col pr-0.5">
|
||||
<DetailsBanner storageKey={`${response.requestId}.general`} summary={<h2>Info</h2>}>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow labelColor="secondary" label="Request URL">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="select-text cursor-text">{response.url}</span>
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
className="inline-block w-auto !h-auto opacity-50 hover:opacity-100"
|
||||
icon="external_link"
|
||||
onClick={() => openUrl(response.url)}
|
||||
title="Open in browser"
|
||||
/>
|
||||
</div>
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="secondary" label="Remote Address">
|
||||
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="secondary" label="Version">
|
||||
{response.version ?? <span className="text-text-subtlest">--</span>}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</DetailsBanner>
|
||||
<DetailsBanner
|
||||
storageKey={`${response.requestId}.request_headers`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Request Headers <CountBadge showZero count={requestHeaders.length} />
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
{requestHeaders.length === 0 ? (
|
||||
<NoHeaders />
|
||||
) : (
|
||||
<KeyValueRows>
|
||||
{requestHeaders.map((h, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
<KeyValueRow labelColor="primary" key={i} label={h.name}>
|
||||
{h.value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
)}
|
||||
</DetailsBanner>
|
||||
<DetailsBanner
|
||||
defaultOpen
|
||||
storageKey={`${response.requestId}.response_headers`}
|
||||
summary={
|
||||
<h2 className="flex items-center">
|
||||
Response Headers <CountBadge showZero count={responseHeaders.length} />
|
||||
</h2>
|
||||
}
|
||||
>
|
||||
{responseHeaders.length === 0 ? (
|
||||
<NoHeaders />
|
||||
) : (
|
||||
<KeyValueRows>
|
||||
{responseHeaders.map((h, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: none
|
||||
<KeyValueRow labelColor="info" key={i} label={h.name}>
|
||||
{h.value}
|
||||
</KeyValueRow>
|
||||
))}
|
||||
</KeyValueRows>
|
||||
)}
|
||||
</DetailsBanner>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NoHeaders() {
|
||||
return <span className="text-text-subtlest text-sm italic">No Headers</span>;
|
||||
}
|
||||
44
apps/yaak-client/components/ResponseInfo.tsx
Normal file
44
apps/yaak-client/components/ResponseInfo.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import type { HttpResponse } from '@yaakapp-internal/models';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { KeyValueRow, KeyValueRows } from './core/KeyValueRow';
|
||||
|
||||
interface Props {
|
||||
response: HttpResponse;
|
||||
}
|
||||
|
||||
export function ResponseInfo({ response }: Props) {
|
||||
return (
|
||||
<div className="overflow-auto h-full pb-4">
|
||||
<KeyValueRows>
|
||||
<KeyValueRow labelColor="info" label="Version">
|
||||
{response.version ?? <span className="text-text-subtlest">--</span>}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow labelColor="info" label="Remote Address">
|
||||
{response.remoteAddr ?? <span className="text-text-subtlest">--</span>}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow
|
||||
labelColor="info"
|
||||
label={
|
||||
<div className="flex items-center">
|
||||
URL
|
||||
<IconButton
|
||||
iconSize="sm"
|
||||
className="inline-block w-auto ml-1 !h-auto opacity-50 hover:opacity-100"
|
||||
icon="external_link"
|
||||
onClick={() => openUrl(response.url)}
|
||||
title="Open in browser"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{
|
||||
<div className="flex">
|
||||
<span className="select-text cursor-text">{response.url}</span>
|
||||
</div>
|
||||
}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
apps/yaak-client/components/RouteError.tsx
Normal file
46
apps/yaak-client/components/RouteError.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Button } from './core/Button';
|
||||
import { DetailsBanner } from './core/DetailsBanner';
|
||||
import { FormattedError } from './core/FormattedError';
|
||||
import { Heading } from './core/Heading';
|
||||
import { VStack } from './core/Stacks';
|
||||
|
||||
export default function RouteError({ error }: { error: unknown }) {
|
||||
console.log('Error', error);
|
||||
const stringified = JSON.stringify(error);
|
||||
// biome-ignore lint/suspicious/noExplicitAny: none
|
||||
const message = (error as any).message ?? stringified;
|
||||
const stack =
|
||||
typeof error === 'object' && error != null && 'stack' in error ? String(error.stack) : null;
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<VStack space={5} className="w-[50rem] !h-auto">
|
||||
<Heading>Route Error 🔥</Heading>
|
||||
<FormattedError>
|
||||
{message}
|
||||
{stack && (
|
||||
<DetailsBanner
|
||||
color="secondary"
|
||||
className="mt-3 select-auto text-xs max-h-[40vh]"
|
||||
summary="Stack Trace"
|
||||
>
|
||||
<div className="mt-2 text-xs">{stack}</div>
|
||||
</DetailsBanner>
|
||||
)}
|
||||
</FormattedError>
|
||||
<VStack space={2}>
|
||||
<Button
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
window.location.assign('/');
|
||||
}}
|
||||
>
|
||||
Go Home
|
||||
</Button>
|
||||
<Button color="info" onClick={() => window.location.reload()}>
|
||||
Refresh
|
||||
</Button>
|
||||
</VStack>
|
||||
</VStack>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
148
apps/yaak-client/components/SelectFile.tsx
Normal file
148
apps/yaak-client/components/SelectFile.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import classNames from 'classnames';
|
||||
import mime from 'mime';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useRef, useState } 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'> & {
|
||||
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
|
||||
filePath: string | null;
|
||||
nameOverride?: string | null;
|
||||
directory?: boolean;
|
||||
inline?: boolean;
|
||||
noun?: string;
|
||||
help?: ReactNode;
|
||||
label?: ReactNode;
|
||||
};
|
||||
|
||||
// Special character to insert ltr text in rtl element
|
||||
const rtlEscapeChar = <>‎</>;
|
||||
|
||||
export function SelectFile({
|
||||
onChange,
|
||||
filePath,
|
||||
inline,
|
||||
className,
|
||||
directory,
|
||||
noun,
|
||||
nameOverride,
|
||||
size = 'sm',
|
||||
label,
|
||||
help,
|
||||
...props
|
||||
}: Props) {
|
||||
const handleClick = async () => {
|
||||
const filePath = await open({
|
||||
title: directory ? 'Select Folder' : 'Select File',
|
||||
multiple: false,
|
||||
directory,
|
||||
});
|
||||
if (filePath == null) return;
|
||||
const contentType = filePath ? mime.getType(filePath) : null;
|
||||
onChange({ filePath, contentType });
|
||||
};
|
||||
|
||||
const handleClear = async () => {
|
||||
onChange({ filePath: null, contentType: null });
|
||||
};
|
||||
|
||||
const itemLabel = noun ?? (directory ? 'Folder' : 'File');
|
||||
const selectOrChange = (filePath ? 'Change ' : 'Select ') + itemLabel;
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Listen for dropped files on the element
|
||||
// NOTE: This doesn't work for Windows since native drag-n-drop can't work at the same tmie
|
||||
// as browser drag-n-drop.
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
const setup = async () => {
|
||||
const webview = getCurrentWebviewWindow();
|
||||
unlisten = await webview.onDragDropEvent((event) => {
|
||||
if (event.payload.type === 'over') {
|
||||
const p = event.payload.position;
|
||||
const r = ref.current?.getBoundingClientRect();
|
||||
if (r == null) return;
|
||||
const isOver = p.x >= r.left && p.x <= r.right && p.y >= r.top && p.y <= r.bottom;
|
||||
console.log('IS OVER', isOver);
|
||||
setIsHovering(isOver);
|
||||
} else if (event.payload.type === 'drop' && isHovering) {
|
||||
console.log('User dropped', event.payload.paths);
|
||||
const p = event.payload.paths[0];
|
||||
if (p) onChange({ filePath: p, contentType: null });
|
||||
setIsHovering(false);
|
||||
} else {
|
||||
console.log('File drop cancelled');
|
||||
setIsHovering(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
setup().catch(console.error);
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [isHovering, onChange]);
|
||||
|
||||
const filePathWithNameOverride = nameOverride ? `${filePath} (${nameOverride})` : filePath;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="w-full">
|
||||
{label && (
|
||||
<Label htmlFor={null} help={help}>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
<HStack className="relative justify-stretch overflow-hidden">
|
||||
<Button
|
||||
className={classNames(
|
||||
className,
|
||||
'rtl mr-1.5',
|
||||
inline && 'w-full',
|
||||
filePath && inline && 'font-mono text-xs',
|
||||
isHovering && '!border-notice',
|
||||
)}
|
||||
color={isHovering ? 'primary' : 'secondary'}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
{...props}
|
||||
>
|
||||
{rtlEscapeChar}
|
||||
{inline ? filePathWithNameOverride || selectOrChange : selectOrChange}
|
||||
</Button>
|
||||
|
||||
{!inline && (
|
||||
<>
|
||||
{filePath && (
|
||||
<IconButton
|
||||
size={size === 'auto' ? 'md' : 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>
|
||||
);
|
||||
}
|
||||
159
apps/yaak-client/components/Settings/Settings.tsx
Normal file
159
apps/yaak-client/components/Settings/Settings.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useSearch } from '@tanstack/react-router';
|
||||
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import { useLicense } from '@yaakapp-internal/license';
|
||||
import { pluginsAtom, settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useKeyPressEvent } from 'react-use';
|
||||
import { appInfo } from '../../lib/appInfo';
|
||||
import { capitalize } from '../../lib/capitalize';
|
||||
import { CountBadge } from '../core/CountBadge';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { HStack } from '../core/Stacks';
|
||||
import { TabContent, type TabItem, Tabs } from '../core/Tabs/Tabs';
|
||||
import { HeaderSize } from '../HeaderSize';
|
||||
import { SettingsCertificates } from './SettingsCertificates';
|
||||
import { SettingsGeneral } from './SettingsGeneral';
|
||||
import { SettingsHotkeys } from './SettingsHotkeys';
|
||||
import { SettingsInterface } from './SettingsInterface';
|
||||
import { SettingsLicense } from './SettingsLicense';
|
||||
import { SettingsPlugins } from './SettingsPlugins';
|
||||
import { SettingsProxy } from './SettingsProxy';
|
||||
import { SettingsTheme } from './SettingsTheme';
|
||||
|
||||
interface Props {
|
||||
hide?: () => void;
|
||||
}
|
||||
|
||||
const TAB_GENERAL = 'general';
|
||||
const TAB_INTERFACE = 'interface';
|
||||
const TAB_THEME = 'theme';
|
||||
const TAB_SHORTCUTS = 'shortcuts';
|
||||
const TAB_PROXY = 'proxy';
|
||||
const TAB_CERTIFICATES = 'certificates';
|
||||
const TAB_PLUGINS = 'plugins';
|
||||
const TAB_LICENSE = 'license';
|
||||
const tabs = [
|
||||
TAB_GENERAL,
|
||||
TAB_THEME,
|
||||
TAB_INTERFACE,
|
||||
TAB_SHORTCUTS,
|
||||
TAB_PLUGINS,
|
||||
TAB_CERTIFICATES,
|
||||
TAB_PROXY,
|
||||
TAB_LICENSE,
|
||||
] as const;
|
||||
export type SettingsTab = (typeof tabs)[number];
|
||||
|
||||
export default function Settings({ hide }: Props) {
|
||||
const { tab: tabFromQuery } = useSearch({ from: '/workspaces/$workspaceId/settings' });
|
||||
// Parse tab and subtab (e.g., "plugins:installed")
|
||||
const [mainTab, subtab] = tabFromQuery?.split(':') ?? [];
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const licenseCheck = useLicense();
|
||||
|
||||
// Close settings window on escape
|
||||
// TODO: Could this be put in a better place? Eg. in Rust key listener when creating the window
|
||||
useKeyPressEvent('Escape', async () => {
|
||||
if (hide != null) {
|
||||
// It's being shown in a dialog, so close the dialog
|
||||
hide();
|
||||
} else {
|
||||
// It's being shown in a window, so close the window
|
||||
await getCurrentWebviewWindow().close();
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classNames('grid grid-rows-[auto_minmax(0,1fr)] h-full')}>
|
||||
{hide ? (
|
||||
<span />
|
||||
) : (
|
||||
<HeaderSize
|
||||
data-tauri-drag-region
|
||||
ignoreControlsSpacing
|
||||
onlyXWindowControl
|
||||
size="md"
|
||||
className="x-theme-appHeader bg-surface text-text-subtle flex items-center justify-center border-b border-border-subtle text-sm font-semibold"
|
||||
>
|
||||
<HStack
|
||||
space={2}
|
||||
justifyContent="center"
|
||||
className="w-full h-full grid grid-cols-[1fr_auto] pointer-events-none"
|
||||
>
|
||||
<div className={classNames(type() === 'macos' ? 'text-center' : 'pl-2')}>Settings</div>
|
||||
</HStack>
|
||||
</HeaderSize>
|
||||
)}
|
||||
<Tabs
|
||||
layout="horizontal"
|
||||
defaultValue={mainTab || tabFromQuery}
|
||||
addBorders
|
||||
tabListClassName="min-w-[10rem] bg-surface x-theme-sidebar border-r border-border pl-3"
|
||||
label="Settings"
|
||||
tabs={tabs.map(
|
||||
(value): TabItem => ({
|
||||
value,
|
||||
label: capitalize(value),
|
||||
hidden: !appInfo.featureLicense && value === TAB_LICENSE,
|
||||
leftSlot:
|
||||
value === TAB_GENERAL ? (
|
||||
<Icon icon="settings" className="text-secondary" />
|
||||
) : value === TAB_THEME ? (
|
||||
<Icon icon="palette" className="text-secondary" />
|
||||
) : value === TAB_INTERFACE ? (
|
||||
<Icon icon="columns_2" className="text-secondary" />
|
||||
) : value === TAB_SHORTCUTS ? (
|
||||
<Icon icon="keyboard" className="text-secondary" />
|
||||
) : value === TAB_CERTIFICATES ? (
|
||||
<Icon icon="shield_check" className="text-secondary" />
|
||||
) : value === TAB_PROXY ? (
|
||||
<Icon icon="wifi" className="text-secondary" />
|
||||
) : value === TAB_PLUGINS ? (
|
||||
<Icon icon="puzzle" className="text-secondary" />
|
||||
) : value === TAB_LICENSE ? (
|
||||
<Icon icon="key_round" className="text-secondary" />
|
||||
) : null,
|
||||
rightSlot:
|
||||
value === TAB_CERTIFICATES ? (
|
||||
<CountBadge count={settings.clientCertificates.length} />
|
||||
) : value === TAB_PLUGINS ? (
|
||||
<CountBadge count={plugins.filter((p) => p.source !== 'bundled').length} />
|
||||
) : value === TAB_PROXY && settings.proxy?.type === 'enabled' ? (
|
||||
<CountBadge count />
|
||||
) : value === TAB_LICENSE && licenseCheck.check.data?.status === 'personal_use' ? (
|
||||
<CountBadge count color="notice" />
|
||||
) : null,
|
||||
}),
|
||||
)}
|
||||
>
|
||||
<TabContent value={TAB_GENERAL} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<SettingsGeneral />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_INTERFACE} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<SettingsInterface />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_THEME} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<SettingsTheme />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_SHORTCUTS} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<SettingsHotkeys />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PLUGINS} className="h-full grid grid-rows-1">
|
||||
<SettingsPlugins defaultSubtab={mainTab === TAB_PLUGINS ? subtab : undefined} />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_PROXY} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<SettingsProxy />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_CERTIFICATES} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<SettingsCertificates />
|
||||
</TabContent>
|
||||
<TabContent value={TAB_LICENSE} className="overflow-y-auto h-full px-6 !py-4">
|
||||
<SettingsLicense />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
253
apps/yaak-client/components/Settings/SettingsCertificates.tsx
Normal file
253
apps/yaak-client/components/Settings/SettingsCertificates.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
import type { ClientCertificate } from '@yaakapp-internal/models';
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useRef } from 'react';
|
||||
import { showConfirmDelete } from '../../lib/confirm';
|
||||
import { Button } from '../core/Button';
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { DetailsBanner } from '../core/DetailsBanner';
|
||||
import { Heading } from '../core/Heading';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
import { SelectFile } from '../SelectFile';
|
||||
|
||||
function createEmptyCertificate(): ClientCertificate {
|
||||
return {
|
||||
host: '',
|
||||
port: null,
|
||||
crtFile: null,
|
||||
keyFile: null,
|
||||
pfxFile: null,
|
||||
passphrase: null,
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
interface CertificateEditorProps {
|
||||
certificate: ClientCertificate;
|
||||
index: number;
|
||||
onUpdate: (index: number, cert: ClientCertificate) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}
|
||||
|
||||
function CertificateEditor({ certificate, index, onUpdate, onRemove }: CertificateEditorProps) {
|
||||
const updateField = <K extends keyof ClientCertificate>(
|
||||
field: K,
|
||||
value: ClientCertificate[K],
|
||||
) => {
|
||||
onUpdate(index, { ...certificate, [field]: value });
|
||||
};
|
||||
|
||||
const hasPfx = Boolean(certificate.pfxFile && certificate.pfxFile.length > 0);
|
||||
const hasCrtKey = Boolean(
|
||||
(certificate.crtFile && certificate.crtFile.length > 0) ||
|
||||
(certificate.keyFile && certificate.keyFile.length > 0),
|
||||
);
|
||||
|
||||
// Determine certificate type for display
|
||||
const certType = hasPfx ? 'PFX' : hasCrtKey ? 'CERT' : null;
|
||||
const defaultOpen = useRef<boolean>(!certificate.host);
|
||||
|
||||
return (
|
||||
<DetailsBanner
|
||||
defaultOpen={defaultOpen.current}
|
||||
summary={
|
||||
<HStack alignItems="center" justifyContent="between" space={2} className="w-full">
|
||||
<HStack space={1.5}>
|
||||
<Checkbox
|
||||
className="ml-1"
|
||||
checked={certificate.enabled ?? true}
|
||||
title={certificate.enabled ? 'Disable certificate' : 'Enable certificate'}
|
||||
hideLabel
|
||||
onChange={(enabled) => updateField('enabled', enabled)}
|
||||
/>
|
||||
|
||||
{certificate.host ? (
|
||||
<InlineCode>
|
||||
{certificate.host || <> </>}
|
||||
{certificate.port != null && `:${certificate.port}`}
|
||||
</InlineCode>
|
||||
) : (
|
||||
<span className="italic text-sm text-text-subtlest">Configure Certificate</span>
|
||||
)}
|
||||
{certType && <InlineCode>{certType}</InlineCode>}
|
||||
</HStack>
|
||||
<IconButton
|
||||
icon="trash"
|
||||
size="sm"
|
||||
title="Remove certificate"
|
||||
className="text-text-subtlest -mr-2"
|
||||
onClick={() => onRemove(index)}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<VStack space={3} className="mt-2">
|
||||
<HStack space={2} alignItems="end">
|
||||
<PlainInput
|
||||
leftSlot={
|
||||
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
|
||||
https://
|
||||
</div>
|
||||
}
|
||||
validate={(value) => {
|
||||
if (!value) return false;
|
||||
if (!/^[a-zA-Z0-9_.-]+$/.test(value)) return false;
|
||||
return true;
|
||||
}}
|
||||
label="Host"
|
||||
placeholder="example.com"
|
||||
size="sm"
|
||||
required
|
||||
defaultValue={certificate.host}
|
||||
onChange={(host) => updateField('host', host)}
|
||||
/>
|
||||
<PlainInput
|
||||
label="Port"
|
||||
hideLabel
|
||||
validate={(value) => {
|
||||
if (!value) return true;
|
||||
if (Number.isNaN(parseInt(value, 10))) return false;
|
||||
return true;
|
||||
}}
|
||||
placeholder="443"
|
||||
leftSlot={
|
||||
<div className="bg-surface-highlight flex items-center text-editor font-mono px-2 text-text-subtle mr-1">
|
||||
:
|
||||
</div>
|
||||
}
|
||||
size="sm"
|
||||
className="w-24"
|
||||
defaultValue={certificate.port?.toString() ?? ''}
|
||||
onChange={(port) => updateField('port', port ? parseInt(port, 10) : null)}
|
||||
/>
|
||||
</HStack>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<VStack space={2}>
|
||||
<SelectFile
|
||||
label="CRT File"
|
||||
noun="Cert"
|
||||
filePath={certificate.crtFile ?? null}
|
||||
size="sm"
|
||||
disabled={hasPfx}
|
||||
onChange={({ filePath }) => updateField('crtFile', filePath)}
|
||||
/>
|
||||
<SelectFile
|
||||
label="KEY File"
|
||||
noun="Key"
|
||||
filePath={certificate.keyFile ?? null}
|
||||
size="sm"
|
||||
disabled={hasPfx}
|
||||
onChange={({ filePath }) => updateField('keyFile', filePath)}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<SelectFile
|
||||
label="PFX File"
|
||||
noun="Key"
|
||||
filePath={certificate.pfxFile ?? null}
|
||||
size="sm"
|
||||
disabled={hasCrtKey}
|
||||
onChange={({ filePath }) => updateField('pfxFile', filePath)}
|
||||
/>
|
||||
|
||||
<PlainInput
|
||||
label="Passphrase"
|
||||
size="sm"
|
||||
type="password"
|
||||
defaultValue={certificate.passphrase ?? ''}
|
||||
onChange={(passphrase) => updateField('passphrase', passphrase || null)}
|
||||
/>
|
||||
</VStack>
|
||||
</DetailsBanner>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsCertificates() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const certificates = settings.clientCertificates ?? [];
|
||||
|
||||
const updateCertificates = async (newCertificates: ClientCertificate[]) => {
|
||||
await patchModel(settings, { clientCertificates: newCertificates });
|
||||
};
|
||||
|
||||
const handleAdd = async () => {
|
||||
const newCert = createEmptyCertificate();
|
||||
await updateCertificates([...certificates, newCert]);
|
||||
};
|
||||
|
||||
const handleUpdate = async (index: number, cert: ClientCertificate) => {
|
||||
const newCertificates = [...certificates];
|
||||
newCertificates[index] = cert;
|
||||
await updateCertificates(newCertificates);
|
||||
};
|
||||
|
||||
const handleRemove = async (index: number) => {
|
||||
const cert = certificates[index];
|
||||
if (cert == null) return;
|
||||
|
||||
const host = cert.host || 'this certificate';
|
||||
const port = cert.port != null ? `:${cert.port}` : '';
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: 'confirm-remove-certificate',
|
||||
title: 'Delete Certificate',
|
||||
description: (
|
||||
<>
|
||||
Permanently delete certificate for{' '}
|
||||
<InlineCode>
|
||||
{host}
|
||||
{port}
|
||||
</InlineCode>
|
||||
?
|
||||
</>
|
||||
),
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
const newCertificates = certificates.filter((_, i) => i !== index);
|
||||
|
||||
await updateCertificates(newCertificates);
|
||||
};
|
||||
|
||||
return (
|
||||
<VStack space={3}>
|
||||
<div className="mb-3">
|
||||
<HStack justifyContent="between" alignItems="start">
|
||||
<div>
|
||||
<Heading>Client Certificates</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Add and manage TLS certificates on a per domain basis
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="border" size="sm" color="secondary" onClick={handleAdd}>
|
||||
Add Certificate
|
||||
</Button>
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
{certificates.length > 0 && (
|
||||
<VStack space={3}>
|
||||
{certificates.map((cert, index) => (
|
||||
<CertificateEditor
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: Index is fine here
|
||||
key={index}
|
||||
certificate={cert}
|
||||
index={index}
|
||||
onUpdate={handleUpdate}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
176
apps/yaak-client/components/Settings/SettingsGeneral.tsx
Normal file
176
apps/yaak-client/components/Settings/SettingsGeneral.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
||||
import { useCheckForUpdates } from '../../hooks/useCheckForUpdates';
|
||||
import { appInfo } from '../../lib/appInfo';
|
||||
import { revealInFinderText } from '../../lib/reveal';
|
||||
import { CargoFeature } from '../CargoFeature';
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { Heading } from '../core/Heading';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { KeyValueRow, KeyValueRows } from '../core/KeyValueRow';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { Select } from '../core/Select';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { VStack } from '../core/Stacks';
|
||||
|
||||
export function SettingsGeneral() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const checkForUpdates = useCheckForUpdates();
|
||||
|
||||
if (settings == null || workspace == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-4">
|
||||
<Heading>General</Heading>
|
||||
<p className="text-text-subtle">Configure general settings for update behavior and more.</p>
|
||||
</div>
|
||||
<CargoFeature feature="updater">
|
||||
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-1">
|
||||
<Select
|
||||
name="updateChannel"
|
||||
label="Update Channel"
|
||||
labelPosition="left"
|
||||
labelClassName="w-[14rem]"
|
||||
size="sm"
|
||||
value={settings.updateChannel}
|
||||
onChange={(updateChannel) => patchModel(settings, { updateChannel })}
|
||||
options={[
|
||||
{ label: 'Stable', value: 'stable' },
|
||||
{ label: 'Beta (more frequent)', value: 'beta' },
|
||||
]}
|
||||
/>
|
||||
<IconButton
|
||||
variant="border"
|
||||
size="sm"
|
||||
title="Check for updates"
|
||||
icon="refresh"
|
||||
spin={checkForUpdates.isPending}
|
||||
onClick={() => checkForUpdates.mutateAsync()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
name="autoupdate"
|
||||
value={settings.autoupdate ? 'auto' : 'manual'}
|
||||
label="Update Behavior"
|
||||
labelPosition="left"
|
||||
size="sm"
|
||||
labelClassName="w-[14rem]"
|
||||
onChange={(v) => patchModel(settings, { autoupdate: v === 'auto' })}
|
||||
options={[
|
||||
{ label: 'Automatic', value: 'auto' },
|
||||
{ label: 'Manual', value: 'manual' },
|
||||
]}
|
||||
/>
|
||||
<Checkbox
|
||||
className="pl-2 mt-1 ml-[14rem]"
|
||||
checked={settings.autoDownloadUpdates}
|
||||
disabled={!settings.autoupdate}
|
||||
help="Automatically download Yaak updates (!50MB) in the background, so they will be immediately ready to install."
|
||||
title="Automatically download updates"
|
||||
onChange={(autoDownloadUpdates) => patchModel(settings, { autoDownloadUpdates })}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
className="pl-2 mt-1 ml-[14rem]"
|
||||
checked={settings.checkNotifications}
|
||||
title="Check for notifications"
|
||||
help="Periodically ping Yaak servers to check for relevant notifications."
|
||||
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
|
||||
/>
|
||||
<Checkbox
|
||||
disabled
|
||||
className="pl-2 mt-1 ml-[14rem]"
|
||||
checked={false}
|
||||
title="Send anonymous usage statistics"
|
||||
help="Yaak is local-first and does not collect analytics or usage data 🔐"
|
||||
onChange={(checkNotifications) => patchModel(settings, { checkNotifications })}
|
||||
/>
|
||||
</CargoFeature>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<Heading level={2}>
|
||||
Workspace{' '}
|
||||
<div className="inline-block ml-1 bg-surface-highlight px-2 py-0.5 rounded text text-shrink">
|
||||
{workspace.name}
|
||||
</div>
|
||||
</Heading>
|
||||
<VStack className="mt-1 w-full" space={3}>
|
||||
<PlainInput
|
||||
required
|
||||
size="sm"
|
||||
name="requestTimeout"
|
||||
label="Request Timeout (ms)"
|
||||
labelClassName="w-[14rem]"
|
||||
placeholder="0"
|
||||
labelPosition="left"
|
||||
defaultValue={`${workspace.settingRequestTimeout}`}
|
||||
validate={(value) => Number.parseInt(value, 10) >= 0}
|
||||
onChange={(v) =>
|
||||
patchModel(workspace, { settingRequestTimeout: Number.parseInt(v, 10) || 0 })
|
||||
}
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
checked={workspace.settingValidateCertificates}
|
||||
help="When disabled, skip validation of server certificates, useful when interacting with self-signed certs."
|
||||
title="Validate TLS certificates"
|
||||
onChange={(settingValidateCertificates) =>
|
||||
patchModel(workspace, { settingValidateCertificates })
|
||||
}
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
checked={workspace.settingFollowRedirects}
|
||||
title="Follow redirects"
|
||||
onChange={(settingFollowRedirects) =>
|
||||
patchModel(workspace, {
|
||||
settingFollowRedirects,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</VStack>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<Heading level={2}>App Info</Heading>
|
||||
<KeyValueRows>
|
||||
<KeyValueRow label="Version">{appInfo.version}</KeyValueRow>
|
||||
<KeyValueRow
|
||||
label="Data Directory"
|
||||
rightSlot={
|
||||
<IconButton
|
||||
title={revealInFinderText}
|
||||
icon="folder_open"
|
||||
size="2xs"
|
||||
onClick={() => revealItemInDir(appInfo.appDataDir)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{appInfo.appDataDir}
|
||||
</KeyValueRow>
|
||||
<KeyValueRow
|
||||
label="Logs Directory"
|
||||
rightSlot={
|
||||
<IconButton
|
||||
title={revealInFinderText}
|
||||
icon="folder_open"
|
||||
size="2xs"
|
||||
onClick={() => revealItemInDir(appInfo.appLogDir)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{appInfo.appLogDir}
|
||||
</KeyValueRow>
|
||||
</KeyValueRows>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
357
apps/yaak-client/components/Settings/SettingsHotkeys.tsx
Normal file
357
apps/yaak-client/components/Settings/SettingsHotkeys.tsx
Normal file
@@ -0,0 +1,357 @@
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { fuzzyMatch } from 'fuzzbunny';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
defaultHotkeys,
|
||||
formatHotkeyString,
|
||||
getHotkeyScope,
|
||||
type HotkeyAction,
|
||||
hotkeyActions,
|
||||
hotkeysAtom,
|
||||
useHotkeyLabel,
|
||||
} from '../../hooks/useHotKey';
|
||||
import { capitalize } from '../../lib/capitalize';
|
||||
import { showDialog } from '../../lib/dialog';
|
||||
import { Button } from '../core/Button';
|
||||
import { Dropdown, type DropdownItem } from '../core/Dropdown';
|
||||
import { Heading } from '../core/Heading';
|
||||
import { HotkeyRaw } from '../core/Hotkey';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
|
||||
|
||||
const HOLD_KEYS = ['Shift', 'Control', 'Alt', 'Meta'];
|
||||
const LAYOUT_INSENSITIVE_KEYS = [
|
||||
'Equal',
|
||||
'Minus',
|
||||
'BracketLeft',
|
||||
'BracketRight',
|
||||
'Backquote',
|
||||
'Space',
|
||||
];
|
||||
|
||||
/** Convert a KeyboardEvent to a hotkey string like "Meta+Shift+k" or "Control+Shift+k" */
|
||||
function eventToHotkeyString(e: KeyboardEvent): string | null {
|
||||
// Don't capture modifier-only key presses
|
||||
if (HOLD_KEYS.includes(e.key)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
|
||||
// Add modifiers in consistent order (Meta, Control, Alt, Shift)
|
||||
if (e.metaKey) {
|
||||
parts.push('Meta');
|
||||
}
|
||||
if (e.ctrlKey) {
|
||||
parts.push('Control');
|
||||
}
|
||||
if (e.altKey) {
|
||||
parts.push('Alt');
|
||||
}
|
||||
if (e.shiftKey) {
|
||||
parts.push('Shift');
|
||||
}
|
||||
|
||||
// Get the main key - use the same logic as useHotKey.ts
|
||||
const key = LAYOUT_INSENSITIVE_KEYS.includes(e.code) ? e.code : e.key;
|
||||
parts.push(key);
|
||||
|
||||
return parts.join('+');
|
||||
}
|
||||
|
||||
export function SettingsHotkeys() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const hotkeys = useAtomValue(hotkeysAtom);
|
||||
const [filter, setFilter] = useState('');
|
||||
|
||||
const filteredActions = useMemo(() => {
|
||||
if (!filter.trim()) {
|
||||
return hotkeyActions;
|
||||
}
|
||||
return hotkeyActions.filter((action) => {
|
||||
const scope = getHotkeyScope(action).replace(/_/g, ' ');
|
||||
const label = action.replace(/[_.]/g, ' ');
|
||||
const searchText = `${scope} ${label}`;
|
||||
return fuzzyMatch(searchText, filter) != null;
|
||||
});
|
||||
}, [filter]);
|
||||
|
||||
if (settings == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Keyboard Shortcuts</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Click the menu button to add, remove, or reset keyboard shortcuts.
|
||||
</p>
|
||||
</div>
|
||||
<PlainInput
|
||||
label="Filter"
|
||||
placeholder="Filter shortcuts..."
|
||||
defaultValue={filter}
|
||||
onChange={setFilter}
|
||||
hideLabel
|
||||
containerClassName="max-w-xs"
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Scope</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
<TableHeaderCell>Shortcut</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
{/* key={filter} forces re-render on filter change to fix Safari table rendering bug */}
|
||||
<TableBody key={filter}>
|
||||
{filteredActions.map((action) => (
|
||||
<HotkeyRow
|
||||
key={action}
|
||||
action={action}
|
||||
currentKeys={hotkeys[action]}
|
||||
defaultKeys={defaultHotkeys[action]}
|
||||
onSave={async (keys) => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
if (arraysEqual(keys, defaultHotkeys[action])) {
|
||||
// Remove from settings if it matches default (use default)
|
||||
delete newHotkeys[action];
|
||||
} else {
|
||||
// Store the keys (including empty array to disable)
|
||||
newHotkeys[action] = keys;
|
||||
}
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
onReset={async () => {
|
||||
const newHotkeys = { ...settings.hotkeys };
|
||||
delete newHotkeys[action];
|
||||
await patchModel(settings, { hotkeys: newHotkeys });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
interface HotkeyRowProps {
|
||||
action: HotkeyAction;
|
||||
currentKeys: string[];
|
||||
defaultKeys: string[];
|
||||
onSave: (keys: string[]) => Promise<void>;
|
||||
onReset: () => Promise<void>;
|
||||
}
|
||||
|
||||
function HotkeyRow({ action, currentKeys, defaultKeys, onSave, onReset }: HotkeyRowProps) {
|
||||
const label = useHotkeyLabel(action);
|
||||
const scope = capitalize(getHotkeyScope(action).replace(/_/g, ' '));
|
||||
const isCustomized = !arraysEqual(currentKeys, defaultKeys);
|
||||
const isDisabled = currentKeys.length === 0;
|
||||
|
||||
const handleStartRecording = useCallback(() => {
|
||||
showDialog({
|
||||
id: `record-hotkey-${action}`,
|
||||
title: label,
|
||||
size: 'sm',
|
||||
render: ({ hide }) => (
|
||||
<RecordHotkeyDialog
|
||||
label={label}
|
||||
onSave={async (key) => {
|
||||
await onSave([...currentKeys, key]);
|
||||
hide();
|
||||
}}
|
||||
onCancel={hide}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [action, label, currentKeys, onSave]);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
async (keyToRemove: string) => {
|
||||
const newKeys = currentKeys.filter((k) => k !== keyToRemove);
|
||||
await onSave(newKeys);
|
||||
},
|
||||
[currentKeys, onSave],
|
||||
);
|
||||
|
||||
const handleClearAll = useCallback(async () => {
|
||||
await onSave([]);
|
||||
}, [onSave]);
|
||||
|
||||
// Build dropdown items dynamically
|
||||
const dropdownItems: DropdownItem[] = [
|
||||
{
|
||||
label: 'Add Keyboard Shortcut',
|
||||
leftSlot: <Icon icon="plus" />,
|
||||
onSelect: handleStartRecording,
|
||||
},
|
||||
];
|
||||
|
||||
// Add remove options for each existing shortcut
|
||||
if (!isDisabled) {
|
||||
currentKeys.forEach((key) => {
|
||||
dropdownItems.push({
|
||||
label: (
|
||||
<HStack space={1.5}>
|
||||
<span>Remove</span>
|
||||
<HotkeyRaw labelParts={formatHotkeyString(key)} variant="with-bg" className="text-xs" />
|
||||
</HStack>
|
||||
),
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => handleRemove(key),
|
||||
});
|
||||
});
|
||||
|
||||
if (currentKeys.length > 1) {
|
||||
dropdownItems.push(
|
||||
{
|
||||
type: 'separator',
|
||||
},
|
||||
{
|
||||
label: 'Remove All Shortcuts',
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: handleClearAll,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isCustomized) {
|
||||
dropdownItems.push({
|
||||
type: 'separator',
|
||||
});
|
||||
dropdownItems.push({
|
||||
label: 'Reset to Default',
|
||||
leftSlot: <Icon icon="refresh" />,
|
||||
onSelect: onReset,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<span className="text-sm text-text-subtlest">{scope}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm">{label}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HStack space={1.5} className="py-1">
|
||||
{isDisabled ? (
|
||||
<span className="text-text-subtlest">Disabled</span>
|
||||
) : (
|
||||
currentKeys.map((k) => (
|
||||
<HotkeyRaw key={k} labelParts={formatHotkeyString(k)} variant="with-bg" />
|
||||
))
|
||||
)}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Dropdown items={dropdownItems}>
|
||||
<IconButton
|
||||
icon="ellipsis_vertical"
|
||||
size="sm"
|
||||
title="Hotkey actions"
|
||||
className="ml-auto text-text-subtlest"
|
||||
/>
|
||||
</Dropdown>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
const sortedA = [...a].sort();
|
||||
const sortedB = [...b].sort();
|
||||
return sortedA.every((v, i) => v === sortedB[i]);
|
||||
}
|
||||
|
||||
interface RecordHotkeyDialogProps {
|
||||
label: string;
|
||||
onSave: (key: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function RecordHotkeyDialog({ label, onSave, onCancel }: RecordHotkeyDialogProps) {
|
||||
const [recordedKey, setRecordedKey] = useState<string | null>(null);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFocused) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const hotkeyString = eventToHotkeyString(e);
|
||||
if (hotkeyString) {
|
||||
setRecordedKey(hotkeyString);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
};
|
||||
}, [isFocused, onCancel]);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (recordedKey) {
|
||||
onSave(recordedKey);
|
||||
}
|
||||
}, [recordedKey, onSave]);
|
||||
|
||||
return (
|
||||
<VStack space={4}>
|
||||
<div>
|
||||
<p className="text-text-subtle mb-2">
|
||||
Record a key combination for <span className="font-semibold">{label}</span>
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
data-disable-hotkey
|
||||
aria-label="Keyboard shortcut input"
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.currentTarget.focus();
|
||||
}}
|
||||
className={classNames(
|
||||
'flex items-center justify-center',
|
||||
'px-4 py-2 rounded-lg bg-surface-highlight border outline-none cursor-default w-full',
|
||||
'border-border-subtle focus:border-border-focus',
|
||||
)}
|
||||
>
|
||||
{recordedKey ? (
|
||||
<HotkeyRaw labelParts={formatHotkeyString(recordedKey)} />
|
||||
) : (
|
||||
<span className="text-text-subtlest">Press keys...</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<HStack space={2} justifyContent="end">
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" onClick={handleSave} disabled={!recordedKey}>
|
||||
Save
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
245
apps/yaak-client/components/Settings/SettingsInterface.tsx
Normal file
245
apps/yaak-client/components/Settings/SettingsInterface.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { type } from '@tauri-apps/plugin-os';
|
||||
import { useFonts } from '@yaakapp-internal/fonts';
|
||||
import { useLicense } from '@yaakapp-internal/license';
|
||||
import type { EditorKeymap, Settings } from '@yaakapp-internal/models';
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
||||
import { clamp } from '../../lib/clamp';
|
||||
import { showConfirm } from '../../lib/confirm';
|
||||
import { invokeCmd } from '../../lib/tauri';
|
||||
import { CargoFeature } from '../CargoFeature';
|
||||
import { Button } from '../core/Button';
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { Heading } from '../core/Heading';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { Link } from '../core/Link';
|
||||
import { Select } from '../core/Select';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
|
||||
const NULL_FONT_VALUE = '__NULL_FONT__';
|
||||
|
||||
const fontSizeOptions = [
|
||||
8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
|
||||
].map((n) => ({ label: `${n}`, value: `${n}` }));
|
||||
|
||||
const keymaps: { value: EditorKeymap; label: string }[] = [
|
||||
{ value: 'default', label: 'Default' },
|
||||
{ value: 'vim', label: 'Vim' },
|
||||
{ value: 'vscode', label: 'VSCode' },
|
||||
{ value: 'emacs', label: 'Emacs' },
|
||||
];
|
||||
|
||||
export function SettingsInterface() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const fonts = useFonts();
|
||||
|
||||
if (settings == null || workspace == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Interface</Heading>
|
||||
<p className="text-text-subtle">Tweak settings related to the user interface.</p>
|
||||
</div>
|
||||
<Select
|
||||
name="switchWorkspaceBehavior"
|
||||
label="Open workspace behavior"
|
||||
size="sm"
|
||||
help="When opening a workspace, should it open in the current window or a new window?"
|
||||
value={
|
||||
settings.openWorkspaceNewWindow === true
|
||||
? 'new'
|
||||
: settings.openWorkspaceNewWindow === false
|
||||
? 'current'
|
||||
: 'ask'
|
||||
}
|
||||
onChange={async (v) => {
|
||||
if (v === 'current') await patchModel(settings, { openWorkspaceNewWindow: false });
|
||||
else if (v === 'new') await patchModel(settings, { openWorkspaceNewWindow: true });
|
||||
else await patchModel(settings, { openWorkspaceNewWindow: null });
|
||||
}}
|
||||
options={[
|
||||
{ label: 'Always ask', value: 'ask' },
|
||||
{ label: 'Open in current window', value: 'current' },
|
||||
{ label: 'Open in new window', value: 'new' },
|
||||
]}
|
||||
/>
|
||||
<HStack space={2} alignItems="end">
|
||||
{fonts.data && (
|
||||
<Select
|
||||
size="sm"
|
||||
name="uiFont"
|
||||
label="Interface font"
|
||||
value={settings.interfaceFont ?? NULL_FONT_VALUE}
|
||||
options={[
|
||||
{ label: 'System default', value: NULL_FONT_VALUE },
|
||||
...(fonts.data.uiFonts.map((f) => ({
|
||||
label: f,
|
||||
value: f,
|
||||
})) ?? []),
|
||||
// Some people like monospace fonts for the UI
|
||||
...(fonts.data.editorFonts.map((f) => ({
|
||||
label: f,
|
||||
value: f,
|
||||
})) ?? []),
|
||||
]}
|
||||
onChange={async (v) => {
|
||||
const interfaceFont = v === NULL_FONT_VALUE ? null : v;
|
||||
await patchModel(settings, { interfaceFont });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
hideLabel
|
||||
size="sm"
|
||||
name="interfaceFontSize"
|
||||
label="Interface Font Size"
|
||||
defaultValue="14"
|
||||
value={`${settings.interfaceFontSize}`}
|
||||
options={fontSizeOptions}
|
||||
onChange={(v) => patchModel(settings, { interfaceFontSize: Number.parseInt(v, 10) })}
|
||||
/>
|
||||
</HStack>
|
||||
<HStack space={2} alignItems="end">
|
||||
{fonts.data && (
|
||||
<Select
|
||||
size="sm"
|
||||
name="editorFont"
|
||||
label="Editor font"
|
||||
value={settings.editorFont ?? NULL_FONT_VALUE}
|
||||
options={[
|
||||
{ label: 'System default', value: NULL_FONT_VALUE },
|
||||
...(fonts.data.editorFonts.map((f) => ({
|
||||
label: f,
|
||||
value: f,
|
||||
})) ?? []),
|
||||
]}
|
||||
onChange={async (v) => {
|
||||
const editorFont = v === NULL_FONT_VALUE ? null : v;
|
||||
await patchModel(settings, { editorFont });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
hideLabel
|
||||
size="sm"
|
||||
name="editorFontSize"
|
||||
label="Editor Font Size"
|
||||
defaultValue="12"
|
||||
value={`${settings.editorFontSize}`}
|
||||
options={fontSizeOptions}
|
||||
onChange={(v) =>
|
||||
patchModel(settings, { editorFontSize: clamp(Number.parseInt(v, 10) || 14, 8, 30) })
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
<Select
|
||||
leftSlot={<Icon icon="keyboard" color="secondary" />}
|
||||
size="sm"
|
||||
name="editorKeymap"
|
||||
label="Editor keymap"
|
||||
value={`${settings.editorKeymap}`}
|
||||
options={keymaps}
|
||||
onChange={(v) => patchModel(settings, { editorKeymap: v })}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={settings.editorSoftWrap}
|
||||
title="Wrap editor lines"
|
||||
onChange={(editorSoftWrap) => patchModel(settings, { editorSoftWrap })}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={settings.coloredMethods}
|
||||
title="Colorize request methods"
|
||||
onChange={(coloredMethods) => patchModel(settings, { coloredMethods })}
|
||||
/>
|
||||
<CargoFeature feature="license">
|
||||
<LicenseSettings settings={settings} />
|
||||
</CargoFeature>
|
||||
|
||||
<NativeTitlebarSetting settings={settings} />
|
||||
|
||||
{type() !== 'macos' && (
|
||||
<Checkbox
|
||||
checked={settings.hideWindowControls}
|
||||
title="Hide window controls"
|
||||
help="Hide the close/maximize/minimize controls on Windows or Linux"
|
||||
onChange={(hideWindowControls) => patchModel(settings, { hideWindowControls })}
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
|
||||
function NativeTitlebarSetting({ settings }: { settings: Settings }) {
|
||||
const [nativeTitlebar, setNativeTitlebar] = useState(settings.useNativeTitlebar);
|
||||
return (
|
||||
<div className="flex gap-1 overflow-hidden h-2xs">
|
||||
<Checkbox
|
||||
checked={nativeTitlebar}
|
||||
title="Native title bar"
|
||||
help="Use the operating system's standard title bar and window controls"
|
||||
onChange={setNativeTitlebar}
|
||||
/>
|
||||
{settings.useNativeTitlebar !== nativeTitlebar && (
|
||||
<Button
|
||||
color="primary"
|
||||
size="2xs"
|
||||
onClick={async () => {
|
||||
await patchModel(settings, { useNativeTitlebar: nativeTitlebar });
|
||||
await invokeCmd('cmd_restart');
|
||||
}}
|
||||
>
|
||||
Apply and Restart
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseSettings({ settings }: { settings: Settings }) {
|
||||
const license = useLicense();
|
||||
if (license.check.data?.status !== 'personal_use') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
checked={settings.hideLicenseBadge}
|
||||
title="Hide personal use badge"
|
||||
onChange={async (hideLicenseBadge) => {
|
||||
if (hideLicenseBadge) {
|
||||
const confirmed = await showConfirm({
|
||||
id: 'hide-license-badge',
|
||||
title: 'Confirm Personal Use',
|
||||
confirmText: 'Confirm',
|
||||
description: (
|
||||
<VStack space={3}>
|
||||
<p>Hey there 👋🏼</p>
|
||||
<p>
|
||||
Yaak is free for personal projects and learning.{' '}
|
||||
<strong>If you’re using Yaak at work, a license is required.</strong>
|
||||
</p>
|
||||
<p>
|
||||
Licenses help keep Yaak independent and sustainable.{' '}
|
||||
<Link href="https://yaak.app/pricing?s=badge">Purchase a License →</Link>
|
||||
</p>
|
||||
</VStack>
|
||||
),
|
||||
requireTyping: 'Personal Use',
|
||||
color: 'info',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return; // Cancel
|
||||
}
|
||||
}
|
||||
await patchModel(settings, { hideLicenseBadge });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
191
apps/yaak-client/components/Settings/SettingsLicense.tsx
Normal file
191
apps/yaak-client/components/Settings/SettingsLicense.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { useLicense } from '@yaakapp-internal/license';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { formatDate } from 'date-fns/format';
|
||||
import { useState } from 'react';
|
||||
import { useToggle } from '../../hooks/useToggle';
|
||||
import { pluralizeCount } from '../../lib/pluralize';
|
||||
import { CargoFeature } from '../CargoFeature';
|
||||
import { Banner } from '../core/Banner';
|
||||
import { Button } from '../core/Button';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { Link } from '../core/Link';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
import { LocalImage } from '../LocalImage';
|
||||
|
||||
export function SettingsLicense() {
|
||||
return (
|
||||
<CargoFeature feature="license">
|
||||
<SettingsLicenseCmp />
|
||||
</CargoFeature>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsLicenseCmp() {
|
||||
const { check, activate, deactivate } = useLicense();
|
||||
const [key, setKey] = useState<string>('');
|
||||
const [activateFormVisible, toggleActivateFormVisible] = useToggle(false);
|
||||
|
||||
if (check.isPending) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const renderBanner = () => {
|
||||
if (!check.data) return null;
|
||||
|
||||
switch (check.data.status) {
|
||||
case 'active':
|
||||
return <Banner color="success">Your license is active 🥳</Banner>;
|
||||
|
||||
case 'trialing':
|
||||
return (
|
||||
<Banner color="info" className="max-w-lg">
|
||||
<p className="w-full">
|
||||
<strong>
|
||||
{pluralizeCount('day', differenceInDays(check.data.data.end, new Date()))}
|
||||
</strong>{' '}
|
||||
left to evaluate Yaak for commercial use.
|
||||
<br />
|
||||
<span className="opacity-50">Personal use is always free, forever.</span>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
||||
Learn More
|
||||
</Link>
|
||||
</div>
|
||||
</p>
|
||||
</Banner>
|
||||
);
|
||||
|
||||
case 'personal_use':
|
||||
return (
|
||||
<Banner color="notice" className="max-w-lg">
|
||||
<p className="w-full">
|
||||
Your commercial-use trial has ended.
|
||||
<br />
|
||||
<span className="opacity-50">
|
||||
You may continue using Yaak for personal use only.
|
||||
<br />A license is required for commercial use.
|
||||
</span>
|
||||
<Separator className="my-2" />
|
||||
<div className="flex flex-wrap items-center gap-x-2 text-sm text-notice">
|
||||
<Link noUnderline href={`https://yaak.app/pricing?s=learn&t=${check.data.status}`}>
|
||||
Learn More
|
||||
</Link>
|
||||
</div>
|
||||
</p>
|
||||
</Banner>
|
||||
);
|
||||
|
||||
case 'inactive':
|
||||
return (
|
||||
<Banner color="danger">
|
||||
Your license is invalid. Please <Link href="https://yaak.app/dashboard">Sign In</Link>{' '}
|
||||
for more details
|
||||
</Banner>
|
||||
);
|
||||
|
||||
case 'expired':
|
||||
return (
|
||||
<Banner color="notice">
|
||||
Your license expired{' '}
|
||||
<strong>{formatDate(check.data.data.periodEnd, 'MMMM dd, yyyy')}</strong>. Please{' '}
|
||||
<Link href="https://yaak.app/dashboard">Resubscribe</Link> to continue receiving
|
||||
updates.
|
||||
{check.data.data.changesUrl && (
|
||||
<>
|
||||
<br />
|
||||
<Link href={check.data.data.changesUrl}>What's new in latest builds</Link>
|
||||
</>
|
||||
)}
|
||||
</Banner>
|
||||
);
|
||||
|
||||
case 'past_due':
|
||||
return (
|
||||
<Banner color="danger">
|
||||
<strong>Your payment method needs attention.</strong>
|
||||
<br />
|
||||
To re-activate your license, please{' '}
|
||||
<Link href={check.data.data.billingUrl}>update your billing info</Link>.
|
||||
</Banner>
|
||||
);
|
||||
|
||||
case 'error':
|
||||
return (
|
||||
<Banner color="danger">
|
||||
License check failed: {check.data.data.message} (Code: {check.data.data.code})
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-xl">
|
||||
{renderBanner()}
|
||||
|
||||
{check.error && <Banner color="danger">{check.error}</Banner>}
|
||||
{activate.error && <Banner color="danger">{activate.error}</Banner>}
|
||||
|
||||
{check.data?.status === 'active' ? (
|
||||
<HStack space={2}>
|
||||
<Button variant="border" color="secondary" size="sm" onClick={() => deactivate.mutate()}>
|
||||
Deactivate License
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
size="sm"
|
||||
onClick={() => openUrl('https://yaak.app/dashboard?s=support&ref=app.yaak.desktop')}
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
>
|
||||
Direct Support
|
||||
</Button>
|
||||
</HStack>
|
||||
) : (
|
||||
<HStack space={2}>
|
||||
<Button variant="border" color="secondary" size="sm" onClick={toggleActivateFormVisible}>
|
||||
Activate License
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={() =>
|
||||
openUrl(
|
||||
`https://yaak.app/pricing?s=purchase&ref=app.yaak.desktop&t=${check.data?.status ?? ''}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Purchase License
|
||||
</Button>
|
||||
</HStack>
|
||||
)}
|
||||
|
||||
{activateFormVisible && (
|
||||
<VStack
|
||||
as="form"
|
||||
space={3}
|
||||
className="max-w-sm"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
await activate.mutateAsync({ licenseKey: key });
|
||||
toggleActivateFormVisible();
|
||||
}}
|
||||
>
|
||||
<PlainInput
|
||||
autoFocus
|
||||
label="License Key"
|
||||
name="key"
|
||||
onChange={setKey}
|
||||
placeholder="YK1-XXXXX-XXXXX-XXXXX-XXXXX"
|
||||
/>
|
||||
<Button type="submit" color="primary" size="sm" isLoading={activate.isPending}>
|
||||
Submit
|
||||
</Button>
|
||||
</VStack>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
419
apps/yaak-client/components/Settings/SettingsPlugins.tsx
Normal file
419
apps/yaak-client/components/Settings/SettingsPlugins.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import type { Plugin } from '@yaakapp-internal/models';
|
||||
import { patchModel, pluginsAtom } from '@yaakapp-internal/models';
|
||||
import type { PluginVersion } from '@yaakapp-internal/plugins';
|
||||
import {
|
||||
checkPluginUpdates,
|
||||
installPlugin,
|
||||
searchPlugins,
|
||||
uninstallPlugin,
|
||||
} from '@yaakapp-internal/plugins';
|
||||
import classNames from 'classnames';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
import { useDebouncedValue } from '../../hooks/useDebouncedValue';
|
||||
import { useInstallPlugin } from '../../hooks/useInstallPlugin';
|
||||
import { usePluginInfo } from '../../hooks/usePluginInfo';
|
||||
import { usePluginsKey, useRefreshPlugins } from '../../hooks/usePlugins';
|
||||
import { showConfirmDelete } from '../../lib/confirm';
|
||||
import { minPromiseMillis } from '../../lib/minPromiseMillis';
|
||||
import { Button } from '../core/Button';
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { CountBadge } from '../core/CountBadge';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { Link } from '../core/Link';
|
||||
import { LoadingIcon } from '../core/LoadingIcon';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { HStack } from '../core/Stacks';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from '../core/Table';
|
||||
import { TabContent, Tabs } from '../core/Tabs/Tabs';
|
||||
import { EmptyStateText } from '../EmptyStateText';
|
||||
import { SelectFile } from '../SelectFile';
|
||||
|
||||
interface SettingsPluginsProps {
|
||||
defaultSubtab?: string;
|
||||
}
|
||||
|
||||
export function SettingsPlugins({ defaultSubtab }: SettingsPluginsProps) {
|
||||
const [directory, setDirectory] = useState<string | null>(null);
|
||||
const plugins = useAtomValue(pluginsAtom);
|
||||
const bundledPlugins = plugins.filter((p) => p.source === 'bundled');
|
||||
const installedPlugins = plugins.filter((p) => p.source !== 'bundled');
|
||||
const createPlugin = useInstallPlugin();
|
||||
const refreshPlugins = useRefreshPlugins();
|
||||
return (
|
||||
<div className="h-full">
|
||||
<Tabs
|
||||
defaultValue={defaultSubtab}
|
||||
label="Plugins"
|
||||
addBorders
|
||||
tabListClassName="px-6 pt-2"
|
||||
tabs={[
|
||||
{ label: 'Discover', value: 'search' },
|
||||
{
|
||||
label: 'Installed',
|
||||
value: 'installed',
|
||||
rightSlot: <CountBadge count={installedPlugins.length} />,
|
||||
},
|
||||
{
|
||||
label: 'Bundled',
|
||||
value: 'bundled',
|
||||
rightSlot: <CountBadge count={bundledPlugins.length} />,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TabContent value="search" className="px-6">
|
||||
<PluginSearch />
|
||||
</TabContent>
|
||||
<TabContent value="installed" className="pb-0">
|
||||
<div className="h-full grid grid-rows-[minmax(0,1fr)_auto]">
|
||||
<InstalledPlugins plugins={installedPlugins} className="px-6" />
|
||||
<footer className="grid grid-cols-[minmax(0,1fr)_auto] py-2 px-4 border-t bg-surface-highlight border-border-subtle min-w-0">
|
||||
<SelectFile
|
||||
size="xs"
|
||||
noun="Plugin"
|
||||
directory
|
||||
onChange={({ filePath }) => setDirectory(filePath)}
|
||||
filePath={directory}
|
||||
/>
|
||||
<HStack>
|
||||
{directory && (
|
||||
<Button
|
||||
size="xs"
|
||||
color="primary"
|
||||
className="ml-auto"
|
||||
onClick={() => {
|
||||
if (directory == null) return;
|
||||
createPlugin.mutate(directory);
|
||||
setDirectory(null);
|
||||
}}
|
||||
>
|
||||
Add Plugin
|
||||
</Button>
|
||||
)}
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon="refresh"
|
||||
title="Reload plugins"
|
||||
spin={refreshPlugins.isPending}
|
||||
onClick={() => refreshPlugins.mutate()}
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
icon="help"
|
||||
title="View documentation"
|
||||
onClick={() =>
|
||||
openUrl('https://yaak.app/docs/plugin-development/plugins-quick-start')
|
||||
}
|
||||
/>
|
||||
</HStack>
|
||||
</footer>
|
||||
</div>
|
||||
</TabContent>
|
||||
<TabContent value="bundled" className="pb-0 px-6">
|
||||
<BundledPlugins plugins={bundledPlugins} />
|
||||
</TabContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginTableRowForInstalledPlugin({ plugin }: { plugin: Plugin }) {
|
||||
const info = usePluginInfo(plugin.id).data;
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PluginTableRow
|
||||
plugin={plugin}
|
||||
version={info.version}
|
||||
name={info.name}
|
||||
displayName={info.displayName}
|
||||
url={plugin.url}
|
||||
showCheckbox={true}
|
||||
showUninstall={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginTableRowForBundledPlugin({ plugin }: { plugin: Plugin }) {
|
||||
const info = usePluginInfo(plugin.id).data;
|
||||
if (info == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PluginTableRow
|
||||
plugin={plugin}
|
||||
version={info.version}
|
||||
name={info.name}
|
||||
displayName={info.displayName}
|
||||
url={plugin.url}
|
||||
showCheckbox={true}
|
||||
showUninstall={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginTableRowForRemotePluginVersion({ pluginVersion }: { pluginVersion: PluginVersion }) {
|
||||
const plugin = useAtomValue(pluginsAtom).find((p) => p.id === pluginVersion.id);
|
||||
const pluginInfo = usePluginInfo(plugin?.id ?? null).data;
|
||||
|
||||
return (
|
||||
<PluginTableRow
|
||||
plugin={plugin ?? null}
|
||||
version={pluginInfo?.version ?? pluginVersion.version}
|
||||
name={pluginVersion.name}
|
||||
displayName={pluginVersion.displayName}
|
||||
url={pluginVersion.url}
|
||||
showCheckbox={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginTableRow({
|
||||
plugin,
|
||||
name,
|
||||
version,
|
||||
displayName,
|
||||
url,
|
||||
showCheckbox = true,
|
||||
showUninstall = true,
|
||||
}: {
|
||||
plugin: Plugin | null;
|
||||
name: string;
|
||||
version: string;
|
||||
displayName: string;
|
||||
url: string | null;
|
||||
showCheckbox?: boolean;
|
||||
showUninstall?: boolean;
|
||||
}) {
|
||||
const updates = usePluginUpdates();
|
||||
const latestVersion = updates.data?.plugins.find((u) => u.name === name)?.version;
|
||||
const installPluginMutation = useMutation({
|
||||
mutationKey: ['install_plugin', name],
|
||||
mutationFn: (name: string) => installPlugin(name, null),
|
||||
});
|
||||
const uninstall = usePromptUninstall(plugin?.id ?? null, displayName);
|
||||
const refreshPlugins = useRefreshPlugins();
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
{showCheckbox && (
|
||||
<TableCell className="!py-0">
|
||||
<Checkbox
|
||||
hideLabel
|
||||
title={plugin?.enabled ? 'Disable plugin' : 'Enable plugin'}
|
||||
checked={plugin?.enabled ?? false}
|
||||
disabled={plugin == null}
|
||||
onChange={async (enabled) => {
|
||||
if (plugin) {
|
||||
await patchModel(plugin, { enabled });
|
||||
refreshPlugins.mutate();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell className="font-semibold">
|
||||
{url ? (
|
||||
<Link noUnderline href={url}>
|
||||
{displayName}
|
||||
</Link>
|
||||
) : (
|
||||
displayName
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<InlineCode>{name}</InlineCode>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HStack space={1.5}>
|
||||
<InlineCode>{version}</InlineCode>
|
||||
{latestVersion != null && (
|
||||
<InlineCode className="text-success flex items-center gap-1">
|
||||
<Icon icon="arrow_up" size="sm" />
|
||||
{latestVersion}
|
||||
</InlineCode>
|
||||
)}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
<TableCell className="!py-0">
|
||||
<HStack justifyContent="end" space={1.5}>
|
||||
{plugin != null && latestVersion != null ? (
|
||||
<Button
|
||||
variant="border"
|
||||
color="success"
|
||||
title={`Update to ${latestVersion}`}
|
||||
size="xs"
|
||||
isLoading={installPluginMutation.isPending}
|
||||
onClick={() => installPluginMutation.mutate(name)}
|
||||
>
|
||||
Update
|
||||
</Button>
|
||||
) : plugin == null ? (
|
||||
<Button
|
||||
variant="border"
|
||||
color="primary"
|
||||
title={`Install ${version}`}
|
||||
size="xs"
|
||||
isLoading={installPluginMutation.isPending}
|
||||
onClick={() => installPluginMutation.mutate(name)}
|
||||
>
|
||||
Install
|
||||
</Button>
|
||||
) : null}
|
||||
{showUninstall && uninstall != null && (
|
||||
<Button
|
||||
size="xs"
|
||||
title="Uninstall plugin"
|
||||
variant="border"
|
||||
isLoading={uninstall.isPending}
|
||||
onClick={() => uninstall.mutate()}
|
||||
>
|
||||
Uninstall
|
||||
</Button>
|
||||
)}
|
||||
</HStack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginSearch() {
|
||||
const [query, setQuery] = useState<string>('');
|
||||
const debouncedQuery = useDebouncedValue(query);
|
||||
const results = useQuery({
|
||||
queryKey: ['plugins', debouncedQuery],
|
||||
queryFn: () => searchPlugins(query),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full grid grid-rows-[auto_minmax(0,1fr)] gap-3">
|
||||
<HStack space={1.5}>
|
||||
<PlainInput
|
||||
hideLabel
|
||||
label="Search"
|
||||
placeholder="Search plugins..."
|
||||
onChange={setQuery}
|
||||
defaultValue={query}
|
||||
/>
|
||||
</HStack>
|
||||
<div className="w-full h-full">
|
||||
{results.data == null ? (
|
||||
<EmptyStateText>
|
||||
<LoadingIcon size="xl" className="text-text-subtlest" />
|
||||
</EmptyStateText>
|
||||
) : (results.data.plugins ?? []).length === 0 ? (
|
||||
<EmptyStateText>No plugins found</EmptyStateText>
|
||||
) : (
|
||||
<Table scrollable>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{results.data.plugins.map((p) => (
|
||||
<PluginTableRowForRemotePluginVersion key={p.id} pluginVersion={p} />
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InstalledPlugins({ plugins, className }: { plugins: Plugin[]; className?: string }) {
|
||||
return plugins.length === 0 ? (
|
||||
<div className={classNames(className, 'pb-4')}>
|
||||
<EmptyStateText className="text-center">
|
||||
Plugins extend the functionality of Yaak.
|
||||
<br />
|
||||
Add your first plugin to get started.
|
||||
</EmptyStateText>
|
||||
</div>
|
||||
) : (
|
||||
<Table scrollable className={className}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-0" />
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{plugins.map((p) => (
|
||||
<PluginTableRowForInstalledPlugin key={p.id} plugin={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function BundledPlugins({ plugins }: { plugins: Plugin[] }) {
|
||||
return plugins.length === 0 ? (
|
||||
<div className="pb-4">
|
||||
<EmptyStateText className="text-center">No bundled plugins found.</EmptyStateText>
|
||||
</div>
|
||||
) : (
|
||||
<Table scrollable>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableHeaderCell className="w-0" />
|
||||
<TableHeaderCell>Display Name</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<tbody className="divide-y divide-surface-highlight">
|
||||
{plugins.map((p) => (
|
||||
<PluginTableRowForBundledPlugin key={p.id} plugin={p} />
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function usePromptUninstall(pluginId: string | null, name: string) {
|
||||
const mut = useMutation({
|
||||
mutationKey: ['uninstall_plugin', pluginId],
|
||||
mutationFn: async () => {
|
||||
if (pluginId == null) return;
|
||||
|
||||
const confirmed = await showConfirmDelete({
|
||||
id: `uninstall-plugin-${pluginId}`,
|
||||
title: 'Uninstall Plugin',
|
||||
confirmText: 'Uninstall',
|
||||
description: (
|
||||
<>
|
||||
Permanently uninstall <InlineCode>{name}</InlineCode>?
|
||||
</>
|
||||
),
|
||||
});
|
||||
if (confirmed) {
|
||||
await minPromiseMillis(uninstallPlugin(pluginId), 700);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return pluginId == null ? null : mut;
|
||||
}
|
||||
|
||||
function usePluginUpdates() {
|
||||
return useQuery({
|
||||
queryKey: ['plugin_updates', usePluginsKey()],
|
||||
queryFn: () => checkPluginUpdates(),
|
||||
});
|
||||
}
|
||||
208
apps/yaak-client/components/Settings/SettingsProxy.tsx
Normal file
208
apps/yaak-client/components/Settings/SettingsProxy.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
import { Checkbox } from '../core/Checkbox';
|
||||
import { Heading } from '../core/Heading';
|
||||
import { InlineCode } from '../core/InlineCode';
|
||||
import { PlainInput } from '../core/PlainInput';
|
||||
import { Select } from '../core/Select';
|
||||
import { Separator } from '../core/Separator';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
|
||||
export function SettingsProxy() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
|
||||
return (
|
||||
<VStack space={1.5} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Proxy</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Configure a proxy server for HTTP requests. Useful for corporate firewalls, debugging
|
||||
traffic, or routing through specific infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
name="proxy"
|
||||
label="Proxy"
|
||||
hideLabel
|
||||
size="sm"
|
||||
value={settings.proxy?.type ?? 'automatic'}
|
||||
onChange={async (v) => {
|
||||
if (v === 'automatic') {
|
||||
await patchModel(settings, { proxy: undefined });
|
||||
} else if (v === 'enabled') {
|
||||
await patchModel(settings, {
|
||||
proxy: {
|
||||
disabled: false,
|
||||
type: 'enabled',
|
||||
http: '',
|
||||
https: '',
|
||||
auth: { user: '', password: '' },
|
||||
bypass: '',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await patchModel(settings, { proxy: { type: 'disabled' } });
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: 'Automatic proxy detection', value: 'automatic' },
|
||||
{ label: 'Custom proxy configuration', value: 'enabled' },
|
||||
{ label: 'No proxy', value: 'disabled' },
|
||||
]}
|
||||
/>
|
||||
{settings.proxy?.type === 'enabled' && (
|
||||
<VStack space={1.5}>
|
||||
<Checkbox
|
||||
className="my-3"
|
||||
checked={!settings.proxy.disabled}
|
||||
title="Enable proxy"
|
||||
help="Use this to temporarily disable the proxy without losing the configuration"
|
||||
onChange={async (enabled) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === 'enabled' ? proxy.http : '';
|
||||
const https = proxy?.type === 'enabled' ? proxy.https : '';
|
||||
const bypass = proxy?.type === 'enabled' ? proxy.bypass : '';
|
||||
const auth = proxy?.type === 'enabled' ? proxy.auth : null;
|
||||
const disabled = !enabled;
|
||||
await patchModel(settings, {
|
||||
proxy: { type: 'enabled', http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<HStack space={1.5}>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label={
|
||||
<>
|
||||
Proxy for <InlineCode>http://</InlineCode> traffic
|
||||
</>
|
||||
}
|
||||
placeholder="localhost:9090"
|
||||
defaultValue={settings.proxy?.http}
|
||||
onChange={async (http) => {
|
||||
const { proxy } = settings;
|
||||
const https = proxy?.type === 'enabled' ? proxy.https : '';
|
||||
const bypass = proxy?.type === 'enabled' ? proxy.bypass : '';
|
||||
const auth = proxy?.type === 'enabled' ? proxy.auth : null;
|
||||
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
|
||||
await patchModel(settings, {
|
||||
proxy: {
|
||||
type: 'enabled',
|
||||
http,
|
||||
https,
|
||||
auth,
|
||||
disabled,
|
||||
bypass,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label={
|
||||
<>
|
||||
Proxy for <InlineCode>https://</InlineCode> traffic
|
||||
</>
|
||||
}
|
||||
placeholder="localhost:9090"
|
||||
defaultValue={settings.proxy?.https}
|
||||
onChange={async (https) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === 'enabled' ? proxy.http : '';
|
||||
const bypass = proxy?.type === 'enabled' ? proxy.bypass : '';
|
||||
const auth = proxy?.type === 'enabled' ? proxy.auth : null;
|
||||
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
|
||||
await patchModel(settings, {
|
||||
proxy: { type: 'enabled', http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
<Separator className="my-6" />
|
||||
<Checkbox
|
||||
checked={settings.proxy.auth != null}
|
||||
title="Enable authentication"
|
||||
onChange={async (enabled) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === 'enabled' ? proxy.http : '';
|
||||
const https = proxy?.type === 'enabled' ? proxy.https : '';
|
||||
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
|
||||
const bypass = proxy?.type === 'enabled' ? proxy.bypass : '';
|
||||
const auth = enabled ? { user: '', password: '' } : null;
|
||||
await patchModel(settings, {
|
||||
proxy: { type: 'enabled', http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{settings.proxy.auth != null && (
|
||||
<HStack space={1.5}>
|
||||
<PlainInput
|
||||
required
|
||||
size="sm"
|
||||
label="User"
|
||||
placeholder="myUser"
|
||||
defaultValue={settings.proxy.auth.user}
|
||||
onChange={async (user) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === 'enabled' ? proxy.http : '';
|
||||
const https = proxy?.type === 'enabled' ? proxy.https : '';
|
||||
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
|
||||
const bypass = proxy?.type === 'enabled' ? proxy.bypass : '';
|
||||
const password = proxy?.type === 'enabled' ? (proxy.auth?.password ?? '') : '';
|
||||
const auth = { user, password };
|
||||
await patchModel(settings, {
|
||||
proxy: { type: 'enabled', http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<PlainInput
|
||||
size="sm"
|
||||
label="Password"
|
||||
type="password"
|
||||
placeholder="s3cretPassw0rd"
|
||||
defaultValue={settings.proxy.auth.password}
|
||||
onChange={async (password) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === 'enabled' ? proxy.http : '';
|
||||
const https = proxy?.type === 'enabled' ? proxy.https : '';
|
||||
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
|
||||
const bypass = proxy?.type === 'enabled' ? proxy.bypass : '';
|
||||
const user = proxy?.type === 'enabled' ? (proxy.auth?.user ?? '') : '';
|
||||
const auth = { user, password };
|
||||
await patchModel(settings, {
|
||||
proxy: { type: 'enabled', http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</HStack>
|
||||
)}
|
||||
{settings.proxy.type === 'enabled' && (
|
||||
<>
|
||||
<Separator className="my-6" />
|
||||
<PlainInput
|
||||
label="Proxy Bypass"
|
||||
help="Comma-separated list to bypass the proxy."
|
||||
defaultValue={settings.proxy.bypass}
|
||||
placeholder="127.0.0.1, *.example.com, localhost:3000"
|
||||
onChange={async (bypass) => {
|
||||
const { proxy } = settings;
|
||||
const http = proxy?.type === 'enabled' ? proxy.http : '';
|
||||
const https = proxy?.type === 'enabled' ? proxy.https : '';
|
||||
const disabled = proxy?.type === 'enabled' ? proxy.disabled : false;
|
||||
const user = proxy?.type === 'enabled' ? (proxy.auth?.user ?? '') : '';
|
||||
const password = proxy?.type === 'enabled' ? (proxy.auth?.password ?? '') : '';
|
||||
const auth = { user, password };
|
||||
await patchModel(settings, {
|
||||
proxy: { type: 'enabled', http, https, auth, disabled, bypass },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</VStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
175
apps/yaak-client/components/Settings/SettingsTheme.tsx
Normal file
175
apps/yaak-client/components/Settings/SettingsTheme.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { activeWorkspaceAtom } from '../../hooks/useActiveWorkspace';
|
||||
import { useResolvedAppearance } from '../../hooks/useResolvedAppearance';
|
||||
import { useResolvedTheme } from '../../hooks/useResolvedTheme';
|
||||
import type { ButtonProps } from '../core/Button';
|
||||
import { Heading } from '../core/Heading';
|
||||
import type { IconProps } from '../core/Icon';
|
||||
import { Icon } from '../core/Icon';
|
||||
import { IconButton } from '../core/IconButton';
|
||||
import { Link } from '../core/Link';
|
||||
import type { SelectProps } from '../core/Select';
|
||||
import { Select } from '../core/Select';
|
||||
import { HStack, VStack } from '../core/Stacks';
|
||||
|
||||
const Editor = lazy(() => import('../core/Editor/Editor').then((m) => ({ default: m.Editor })));
|
||||
|
||||
const buttonColors: ButtonProps['color'][] = [
|
||||
'primary',
|
||||
'info',
|
||||
'success',
|
||||
'notice',
|
||||
'warning',
|
||||
'danger',
|
||||
'secondary',
|
||||
'default',
|
||||
];
|
||||
|
||||
const icons: IconProps['icon'][] = [
|
||||
'info',
|
||||
'box',
|
||||
'update',
|
||||
'alert_triangle',
|
||||
'arrow_big_right_dash',
|
||||
'download',
|
||||
'copy',
|
||||
'magic_wand',
|
||||
'settings',
|
||||
'trash',
|
||||
'sparkles',
|
||||
'pencil',
|
||||
'paste',
|
||||
'search',
|
||||
'send_horizontal',
|
||||
];
|
||||
|
||||
export function SettingsTheme() {
|
||||
const workspace = useAtomValue(activeWorkspaceAtom);
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const appearance = useResolvedAppearance();
|
||||
const activeTheme = useResolvedTheme();
|
||||
|
||||
if (settings == null || workspace == null || activeTheme.data == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lightThemes: SelectProps<string>['options'] = activeTheme.data.themes
|
||||
.filter((theme) => !theme.dark)
|
||||
.map((theme) => ({
|
||||
label: theme.label,
|
||||
value: theme.id,
|
||||
}));
|
||||
|
||||
const darkThemes: SelectProps<string>['options'] = activeTheme.data.themes
|
||||
.filter((theme) => theme.dark)
|
||||
.map((theme) => ({
|
||||
label: theme.label,
|
||||
value: theme.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<VStack space={3} className="mb-4">
|
||||
<div className="mb-3">
|
||||
<Heading>Theme</Heading>
|
||||
<p className="text-text-subtle">
|
||||
Make Yaak your own by selecting a theme, or{' '}
|
||||
<Link href="https://yaak.app/docs/plugin-development/plugins-quick-start">
|
||||
Create Your Own
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
name="appearance"
|
||||
label="Appearance"
|
||||
labelPosition="top"
|
||||
size="sm"
|
||||
value={settings.appearance}
|
||||
onChange={(appearance) => patchModel(settings, { appearance })}
|
||||
options={[
|
||||
{ label: 'Automatic', value: 'system' },
|
||||
{ label: 'Light', value: 'light' },
|
||||
{ label: 'Dark', value: 'dark' },
|
||||
]}
|
||||
/>
|
||||
<HStack space={2}>
|
||||
{(settings.appearance === 'system' || settings.appearance === 'light') && (
|
||||
<Select
|
||||
hideLabel
|
||||
leftSlot={<Icon icon="sun" color="secondary" />}
|
||||
name="lightTheme"
|
||||
label="Light Theme"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
value={activeTheme.data.light.id}
|
||||
options={lightThemes}
|
||||
onChange={(themeLight) => patchModel(settings, { themeLight })}
|
||||
/>
|
||||
)}
|
||||
{(settings.appearance === 'system' || settings.appearance === 'dark') && (
|
||||
<Select
|
||||
hideLabel
|
||||
name="darkTheme"
|
||||
className="flex-1"
|
||||
label="Dark Theme"
|
||||
leftSlot={<Icon icon="moon" color="secondary" />}
|
||||
size="sm"
|
||||
value={activeTheme.data.dark.id}
|
||||
options={darkThemes}
|
||||
onChange={(themeDark) => patchModel(settings, { themeDark })}
|
||||
/>
|
||||
)}
|
||||
</HStack>
|
||||
|
||||
<VStack
|
||||
space={3}
|
||||
className="mt-3 w-full bg-surface p-3 border border-dashed border-border-subtle rounded overflow-x-auto"
|
||||
>
|
||||
<HStack className="text" space={1.5}>
|
||||
<Icon icon={appearance === 'dark' ? 'moon' : 'sun'} />
|
||||
<strong>{activeTheme.data.active.label}</strong>
|
||||
<em>(preview)</em>
|
||||
</HStack>
|
||||
<HStack space={1.5} className="w-full">
|
||||
{buttonColors.map((c, i) => (
|
||||
<IconButton
|
||||
key={c}
|
||||
color={c}
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
icon={icons[i % icons.length] ?? 'info'}
|
||||
iconClassName="text"
|
||||
title={`${c}`}
|
||||
/>
|
||||
))}
|
||||
{buttonColors.map((c, i) => (
|
||||
<IconButton
|
||||
key={c}
|
||||
color={c}
|
||||
variant="border"
|
||||
size="2xs"
|
||||
iconSize="xs"
|
||||
icon={icons[i % icons.length] ?? 'info'}
|
||||
iconClassName="text"
|
||||
title={`${c}`}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
<Suspense>
|
||||
<Editor
|
||||
defaultValue={[
|
||||
'let foo = { // Demo code editor',
|
||||
' foo: ("bar" || "baz" ?? \'qux\'),',
|
||||
' baz: [1, 10.2, null, false, true],',
|
||||
'};',
|
||||
].join('\n')}
|
||||
heightMode="auto"
|
||||
language="javascript"
|
||||
stateKey={null}
|
||||
/>
|
||||
</Suspense>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
111
apps/yaak-client/components/SettingsDropdown.tsx
Normal file
111
apps/yaak-client/components/SettingsDropdown.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { useLicense } from '@yaakapp-internal/license';
|
||||
import { useRef } from 'react';
|
||||
import { openSettings } from '../commands/openSettings';
|
||||
import { useCheckForUpdates } from '../hooks/useCheckForUpdates';
|
||||
import { useExportData } from '../hooks/useExportData';
|
||||
import { appInfo } from '../lib/appInfo';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { importData } from '../lib/importData';
|
||||
import type { DropdownRef } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { KeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
|
||||
|
||||
export function SettingsDropdown() {
|
||||
const exportData = useExportData();
|
||||
const dropdownRef = useRef<DropdownRef>(null);
|
||||
const checkForUpdates = useCheckForUpdates();
|
||||
const { check } = useLicense();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
ref={dropdownRef}
|
||||
items={[
|
||||
{
|
||||
label: 'Settings',
|
||||
hotKeyAction: 'settings.show',
|
||||
leftSlot: <Icon icon="settings" />,
|
||||
onSelect: () => openSettings.mutate(null),
|
||||
},
|
||||
{
|
||||
label: 'Keyboard shortcuts',
|
||||
hotKeyAction: 'hotkeys.showHelp',
|
||||
leftSlot: <Icon icon="keyboard" />,
|
||||
onSelect: () => {
|
||||
showDialog({
|
||||
id: 'hotkey',
|
||||
title: 'Keyboard Shortcuts',
|
||||
size: 'dynamic',
|
||||
render: () => <KeyboardShortcutsDialog />,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Plugins',
|
||||
leftSlot: <Icon icon="puzzle" />,
|
||||
onSelect: () => openSettings.mutate('plugins'),
|
||||
},
|
||||
{ type: 'separator', label: 'Share Workspace(s)' },
|
||||
{
|
||||
label: 'Import Data',
|
||||
leftSlot: <Icon icon="folder_input" />,
|
||||
onSelect: () => importData.mutate(),
|
||||
},
|
||||
{
|
||||
label: 'Export Data',
|
||||
leftSlot: <Icon icon="folder_output" />,
|
||||
onSelect: () => exportData.mutate(),
|
||||
},
|
||||
{
|
||||
label: 'Create Run Button',
|
||||
leftSlot: <Icon icon="rocket" />,
|
||||
onSelect: () => openUrl('https://yaak.app/button/new'),
|
||||
},
|
||||
{ type: 'separator', label: `Yaak v${appInfo.version}` },
|
||||
{
|
||||
label: 'Check for Updates',
|
||||
leftSlot: <Icon icon="update" />,
|
||||
hidden: !appInfo.featureUpdater,
|
||||
onSelect: () => checkForUpdates.mutate(),
|
||||
},
|
||||
{
|
||||
label: 'Purchase License',
|
||||
color: 'success',
|
||||
hidden: check.data == null || check.data.status === 'active',
|
||||
leftSlot: <Icon icon="circle_dollar_sign" />,
|
||||
rightSlot: <Icon icon="external_link" color="success" className="opacity-60" />,
|
||||
onSelect: () => openUrl('https://yaak.app/pricing'),
|
||||
},
|
||||
{
|
||||
label: 'Install CLI',
|
||||
hidden: appInfo.cliVersion != null,
|
||||
leftSlot: <Icon icon="square_terminal" />,
|
||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||
onSelect: () => openUrl('https://yaak.app/docs/cli'),
|
||||
},
|
||||
{
|
||||
label: 'Feedback',
|
||||
leftSlot: <Icon icon="chat" />,
|
||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||
onSelect: () => openUrl('https://yaak.app/feedback'),
|
||||
},
|
||||
{
|
||||
label: 'Changelog',
|
||||
leftSlot: <Icon icon="cake" />,
|
||||
rightSlot: <Icon icon="external_link" color="secondary" />,
|
||||
onSelect: () => openUrl(`https://yaak.app/changelog/${appInfo.version}`),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
size="sm"
|
||||
title="Main Menu"
|
||||
icon="settings"
|
||||
iconColor="secondary"
|
||||
className="pointer-events-auto"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
842
apps/yaak-client/components/Sidebar.tsx
Normal file
842
apps/yaak-client/components/Sidebar.tsx
Normal file
@@ -0,0 +1,842 @@
|
||||
import type { Extension } from '@codemirror/state';
|
||||
import { Compartment } from '@codemirror/state';
|
||||
import { debounce } from '@yaakapp-internal/lib';
|
||||
import type {
|
||||
AnyModel,
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
ModelPayload,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from '@yaakapp-internal/models';
|
||||
import {
|
||||
duplicateModel,
|
||||
foldersAtom,
|
||||
getAnyModel,
|
||||
getModel,
|
||||
grpcConnectionsAtom,
|
||||
httpResponsesAtom,
|
||||
patchModel,
|
||||
websocketConnectionsAtom,
|
||||
workspacesAtom,
|
||||
} from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { moveToWorkspace } from '../commands/moveToWorkspace';
|
||||
import { openFolderSettings } from '../commands/openFolderSettings';
|
||||
import { activeFolderIdAtom } from '../hooks/useActiveFolderId';
|
||||
import { activeRequestIdAtom } from '../hooks/useActiveRequestId';
|
||||
import { activeWorkspaceAtom, activeWorkspaceIdAtom } from '../hooks/useActiveWorkspace';
|
||||
import { allRequestsAtom } from '../hooks/useAllRequests';
|
||||
import { getCreateDropdownItems } from '../hooks/useCreateDropdownItems';
|
||||
import { getFolderActions } from '../hooks/useFolderActions';
|
||||
import { getGrpcRequestActions } from '../hooks/useGrpcRequestActions';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import { getHttpRequestActions } from '../hooks/useHttpRequestActions';
|
||||
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
|
||||
import { getModelAncestors } from '../hooks/useModelAncestors';
|
||||
import { sendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { getWebsocketRequestActions } from '../hooks/useWebsocketRequestActions';
|
||||
import { deepEqualAtom } from '../lib/atoms';
|
||||
import { deleteModelWithConfirm } from '../lib/deleteModelWithConfirm';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { resolvedModelName } from '../lib/resolvedModelName';
|
||||
import { isSidebarFocused } from '../lib/scopes';
|
||||
import { navigateToRequestOrFolderOrWorkspace } from '../lib/setWorkspaceSearchParams';
|
||||
import type { ContextMenuProps, DropdownItem } from './core/Dropdown';
|
||||
import { Dropdown } from './core/Dropdown';
|
||||
import type { FieldDef } from './core/Editor/filter/extension';
|
||||
import { filter } from './core/Editor/filter/extension';
|
||||
import { evaluate, parseQuery } from './core/Editor/filter/query';
|
||||
import { HttpMethodTag } from './core/HttpMethodTag';
|
||||
import { HttpStatusTag } from './core/HttpStatusTag';
|
||||
import { Icon } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import type { InputHandle } from './core/Input';
|
||||
import { Input } from './core/Input';
|
||||
import { LoadingIcon } from './core/LoadingIcon';
|
||||
import { collapsedFamily, isSelectedFamily, selectedIdsFamily } from './core/tree/atoms';
|
||||
import type { TreeNode } from './core/tree/common';
|
||||
import type { TreeHandle, TreeProps } from './core/tree/Tree';
|
||||
import { Tree } from './core/tree/Tree';
|
||||
import type { TreeItemProps } from './core/tree/TreeItem';
|
||||
import { GitDropdown } from './git/GitDropdown';
|
||||
|
||||
type SidebarModel = Workspace | Folder | HttpRequest | GrpcRequest | WebsocketRequest;
|
||||
function isSidebarLeafModel(m: AnyModel): boolean {
|
||||
const modelMap: Record<Exclude<SidebarModel['model'], 'workspace'>, null> = {
|
||||
http_request: null,
|
||||
grpc_request: null,
|
||||
websocket_request: null,
|
||||
folder: null,
|
||||
};
|
||||
return m.model in modelMap;
|
||||
}
|
||||
|
||||
const OPACITY_SUBTLE = 'opacity-80';
|
||||
|
||||
function Sidebar({ className }: { className?: string }) {
|
||||
const [hidden, setHidden] = useSidebarHidden();
|
||||
const activeWorkspaceId = useAtomValue(activeWorkspaceAtom)?.id;
|
||||
const treeId = `tree.${activeWorkspaceId ?? 'unknown'}`;
|
||||
const filterText = useAtomValue(sidebarFilterAtom);
|
||||
const [tree, allFields] = useAtomValue(sidebarTreeAtom) ?? [];
|
||||
const wrapperRef = useRef<HTMLElement>(null);
|
||||
const treeRef = useRef<TreeHandle>(null);
|
||||
const filterRef = useRef<InputHandle>(null);
|
||||
const setFilterRef = useCallback((h: InputHandle | null) => {
|
||||
filterRef.current = h;
|
||||
}, []);
|
||||
const allHidden = useMemo(() => {
|
||||
if (tree?.children?.length === 0) return false;
|
||||
if (filterText) return tree?.children?.every((c) => c.hidden);
|
||||
return true;
|
||||
}, [filterText, tree?.children]);
|
||||
|
||||
const focusActiveItem = useCallback(() => {
|
||||
const didFocus = treeRef.current?.focus();
|
||||
// If we weren't able to focus any items, focus the filter bar
|
||||
if (!didFocus) filterRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Focus any new sidebar models when created
|
||||
useListenToTauriEvent<ModelPayload>('model_write', ({ payload }) => {
|
||||
if (!isSidebarLeafModel(payload.model)) return;
|
||||
if (!(payload.change.type === 'upsert' && payload.change.created)) return;
|
||||
treeRef.current?.selectItem(payload.model.id, true);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return jotaiStore.sub(activeIdAtom, () => {
|
||||
const activeId = jotaiStore.get(activeIdAtom);
|
||||
if (activeId) {
|
||||
treeRef.current?.selectItem(activeId, true);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useHotKey(
|
||||
'sidebar.filter',
|
||||
() => {
|
||||
filterRef.current?.focus();
|
||||
},
|
||||
{
|
||||
enable: isSidebarFocused,
|
||||
},
|
||||
);
|
||||
|
||||
useHotKey('sidebar.focus', async function focusHotkey() {
|
||||
// Hide the sidebar if it's already focused
|
||||
if (!hidden && isSidebarFocused()) {
|
||||
await setHidden(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the sidebar if it's hidden
|
||||
if (hidden) {
|
||||
await setHidden(false);
|
||||
}
|
||||
|
||||
// Select the 0th index on focus if none selected
|
||||
setTimeout(focusActiveItem, 100);
|
||||
});
|
||||
|
||||
const handleDragEnd = useCallback(async function handleDragEnd({
|
||||
items,
|
||||
parent,
|
||||
children,
|
||||
insertAt,
|
||||
}: {
|
||||
items: SidebarModel[];
|
||||
parent: SidebarModel;
|
||||
children: SidebarModel[];
|
||||
insertAt: number;
|
||||
}) {
|
||||
const prev = children[insertAt - 1] as Exclude<SidebarModel, Workspace>;
|
||||
const next = children[insertAt] as Exclude<SidebarModel, Workspace>;
|
||||
const folderId = parent.model === 'folder' ? parent.id : null;
|
||||
|
||||
const beforePriority = prev?.sortPriority ?? 0;
|
||||
const afterPriority = next?.sortPriority ?? 0;
|
||||
const shouldUpdateAll = afterPriority - beforePriority < 1;
|
||||
|
||||
try {
|
||||
if (shouldUpdateAll) {
|
||||
// Add items to children at insertAt
|
||||
children.splice(insertAt, 0, ...items);
|
||||
await Promise.all(
|
||||
children.map((m, i) => patchModel(m, { sortPriority: i * 1000, folderId })),
|
||||
);
|
||||
} else {
|
||||
const range = afterPriority - beforePriority;
|
||||
const increment = range / (items.length + 2);
|
||||
await Promise.all(
|
||||
items.map((m, i) =>
|
||||
// Spread item sortPriority out over before/after range
|
||||
patchModel(m, {
|
||||
sortPriority: beforePriority + (i + 1) * increment,
|
||||
folderId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleTreeRefInit = useCallback(
|
||||
(n: TreeHandle) => {
|
||||
treeRef.current = n;
|
||||
if (n == null) return;
|
||||
const activeId = jotaiStore.get(activeIdAtom);
|
||||
if (activeId == null) return;
|
||||
const selectedIds = jotaiStore.get(selectedIdsFamily(treeId));
|
||||
if (selectedIds.length > 0) return;
|
||||
n.selectItem(activeId);
|
||||
},
|
||||
[treeId],
|
||||
);
|
||||
|
||||
const clearFilterText = useCallback(() => {
|
||||
jotaiStore.set(sidebarFilterAtom, { text: '', key: `${Math.random()}` });
|
||||
requestAnimationFrame(() => {
|
||||
filterRef.current?.focus();
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleFilterKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
e.stopPropagation(); // Don't trigger tree navigation hotkeys
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
clearFilterText();
|
||||
}
|
||||
},
|
||||
[clearFilterText],
|
||||
);
|
||||
|
||||
const handleFilterChange = useMemo(
|
||||
() =>
|
||||
debounce((text: string) => {
|
||||
jotaiStore.set(sidebarFilterAtom, (prev) => ({ ...prev, text }));
|
||||
}, 0),
|
||||
[],
|
||||
);
|
||||
|
||||
const actions = useMemo(() => {
|
||||
const enable = () => treeRef.current?.hasFocus() ?? false;
|
||||
|
||||
const actions = {
|
||||
'sidebar.context_menu': {
|
||||
enable,
|
||||
cb: () => treeRef.current?.showContextMenu(),
|
||||
},
|
||||
'sidebar.expand_all': {
|
||||
enable: isSidebarFocused,
|
||||
cb: () => {
|
||||
jotaiStore.set(collapsedFamily(treeId), {});
|
||||
},
|
||||
},
|
||||
'sidebar.collapse_all': {
|
||||
enable: isSidebarFocused,
|
||||
cb: () => {
|
||||
if (tree == null) return;
|
||||
|
||||
const next = (node: TreeNode<SidebarModel>, collapsed: Record<string, boolean>) => {
|
||||
let newCollapsed = { ...collapsed };
|
||||
for (const n of node.children ?? []) {
|
||||
if (n.item.model !== 'folder') continue;
|
||||
newCollapsed[n.item.id] = true;
|
||||
newCollapsed = next(n, newCollapsed);
|
||||
}
|
||||
return newCollapsed;
|
||||
};
|
||||
const collapsed = next(tree, {});
|
||||
jotaiStore.set(collapsedFamily(treeId), collapsed);
|
||||
},
|
||||
},
|
||||
'sidebar.selected.delete': {
|
||||
enable,
|
||||
cb: async (items: SidebarModel[]) => {
|
||||
await deleteModelWithConfirm(items);
|
||||
},
|
||||
},
|
||||
'sidebar.selected.rename': {
|
||||
enable,
|
||||
allowDefault: true,
|
||||
cb: async (items: SidebarModel[]) => {
|
||||
const item = items[0];
|
||||
if (items.length === 1 && item != null) {
|
||||
treeRef.current?.renameItem(item.id);
|
||||
}
|
||||
},
|
||||
},
|
||||
'sidebar.selected.duplicate': {
|
||||
// Higher priority so this takes precedence over model.duplicate (same Meta+d binding)
|
||||
priority: 10,
|
||||
enable,
|
||||
cb: async (items: SidebarModel[]) => {
|
||||
if (items.length === 1 && items[0]) {
|
||||
const item = items[0];
|
||||
const newId = await duplicateModel(item);
|
||||
navigateToRequestOrFolderOrWorkspace(newId, item.model);
|
||||
} else {
|
||||
await Promise.all(items.map(duplicateModel));
|
||||
}
|
||||
},
|
||||
},
|
||||
'sidebar.selected.move': {
|
||||
enable,
|
||||
cb: async (items: SidebarModel[]) => {
|
||||
const requests = items.filter(
|
||||
(i): i is HttpRequest | GrpcRequest | WebsocketRequest =>
|
||||
i.model === 'http_request' || i.model === 'grpc_request' || i.model === 'websocket_request'
|
||||
);
|
||||
if (requests.length > 0) {
|
||||
moveToWorkspace.mutate(requests);
|
||||
}
|
||||
},
|
||||
},
|
||||
'request.send': {
|
||||
enable,
|
||||
cb: async (items: SidebarModel[]) => {
|
||||
await Promise.all(
|
||||
items
|
||||
.filter((i) => i.model === 'http_request')
|
||||
.map((i) => sendAnyHttpRequest.mutate(i.id)),
|
||||
);
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
return actions;
|
||||
}, [tree, treeId]);
|
||||
|
||||
const getContextMenu = useCallback<(items: SidebarModel[]) => Promise<DropdownItem[]>>(
|
||||
async (items) => {
|
||||
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
|
||||
const child = items[0];
|
||||
|
||||
// No children means we're in the root
|
||||
if (child == null) {
|
||||
return getCreateDropdownItems({
|
||||
workspaceId,
|
||||
activeRequest: null,
|
||||
folderId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const workspaces = jotaiStore.get(workspacesAtom);
|
||||
const onlyHttpRequests = items.every((i) => i.model === 'http_request');
|
||||
const requestItems = items.filter(
|
||||
(i) =>
|
||||
i.model === 'http_request' || i.model === 'grpc_request' || i.model === 'websocket_request',
|
||||
);
|
||||
|
||||
const initialItems: ContextMenuProps['items'] = [
|
||||
{
|
||||
label: 'Folder Settings',
|
||||
hidden: !(items.length === 1 && child.model === 'folder'),
|
||||
leftSlot: <Icon icon="folder_cog" />,
|
||||
onSelect: () => openFolderSettings(child.id),
|
||||
},
|
||||
{
|
||||
label: 'Send',
|
||||
hotKeyAction: 'request.send',
|
||||
hotKeyLabelOnly: true,
|
||||
hidden: !onlyHttpRequests,
|
||||
leftSlot: <Icon icon="send_horizontal" />,
|
||||
onSelect: () => actions['request.send'].cb(items),
|
||||
},
|
||||
...(items.length === 1 && child.model === 'http_request'
|
||||
? await getHttpRequestActions()
|
||||
: []
|
||||
).map((a) => ({
|
||||
label: a.label,
|
||||
leftSlot: <Icon icon={a.icon ?? 'empty'} />,
|
||||
onSelect: async () => {
|
||||
const request = getModel('http_request', child.id);
|
||||
if (request != null) await a.call(request);
|
||||
},
|
||||
})),
|
||||
...(items.length === 1 && child.model === 'grpc_request'
|
||||
? await getGrpcRequestActions()
|
||||
: []
|
||||
).map((a) => ({
|
||||
label: a.label,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
leftSlot: <Icon icon={a.icon ?? 'empty'} />,
|
||||
onSelect: async () => {
|
||||
const request = getModel('grpc_request', child.id);
|
||||
if (request != null) await a.call(request);
|
||||
},
|
||||
})),
|
||||
...(items.length === 1 && child.model === 'websocket_request'
|
||||
? await getWebsocketRequestActions()
|
||||
: []
|
||||
).map((a) => ({
|
||||
label: a.label,
|
||||
leftSlot: <Icon icon={a.icon ?? 'empty'} />,
|
||||
onSelect: async () => {
|
||||
const request = getModel('websocket_request', child.id);
|
||||
if (request != null) await a.call(request);
|
||||
},
|
||||
})),
|
||||
...(items.length === 1 && child.model === 'folder' ? await getFolderActions() : []).map(
|
||||
(a) => ({
|
||||
label: a.label,
|
||||
leftSlot: <Icon icon={a.icon ?? 'empty'} />,
|
||||
onSelect: async () => {
|
||||
const model = getModel('folder', child.id);
|
||||
if (model != null) await a.call(model);
|
||||
},
|
||||
}),
|
||||
),
|
||||
];
|
||||
const modelCreationItems: DropdownItem[] =
|
||||
items.length === 1 && child.model === 'folder'
|
||||
? [
|
||||
{ type: 'separator' },
|
||||
...getCreateDropdownItems({
|
||||
workspaceId,
|
||||
activeRequest: null,
|
||||
folderId: child.id,
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const menuItems: ContextMenuProps['items'] = [
|
||||
...initialItems,
|
||||
{
|
||||
type: 'separator',
|
||||
hidden: initialItems.filter((v) => !v.hidden).length === 0,
|
||||
},
|
||||
{
|
||||
label: 'Rename',
|
||||
leftSlot: <Icon icon="pencil" />,
|
||||
hidden: items.length > 1,
|
||||
hotKeyAction: 'sidebar.selected.rename',
|
||||
hotKeyLabelOnly: true,
|
||||
onSelect: () => {
|
||||
treeRef.current?.renameItem(child.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Duplicate',
|
||||
hotKeyAction: 'model.duplicate',
|
||||
hotKeyLabelOnly: true, // Would trigger for every request (bad)
|
||||
leftSlot: <Icon icon="copy" />,
|
||||
onSelect: () => actions['sidebar.selected.duplicate'].cb(items),
|
||||
},
|
||||
{
|
||||
label: items.length <= 1 ? 'Move' : `Move ${requestItems.length} Requests`,
|
||||
hotKeyAction: 'sidebar.selected.move',
|
||||
hotKeyLabelOnly: true,
|
||||
leftSlot: <Icon icon="arrow_right_circle" />,
|
||||
hidden: workspaces.length <= 1 || requestItems.length === 0 || requestItems.length !== items.length,
|
||||
onSelect: () => {
|
||||
actions['sidebar.selected.move'].cb(items);
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
label: 'Delete',
|
||||
hotKeyAction: 'sidebar.selected.delete',
|
||||
hotKeyLabelOnly: true,
|
||||
leftSlot: <Icon icon="trash" />,
|
||||
onSelect: () => actions['sidebar.selected.delete'].cb(items),
|
||||
},
|
||||
...modelCreationItems,
|
||||
];
|
||||
return menuItems;
|
||||
},
|
||||
[actions],
|
||||
);
|
||||
|
||||
const hotkeys = useMemo<TreeProps<SidebarModel>['hotkeys']>(() => ({ actions }), [actions]);
|
||||
|
||||
// Use a language compartment for the filter so we can reconfigure it when the autocompletion changes
|
||||
const filterLanguageCompartmentRef = useRef(new Compartment());
|
||||
const filterCompartmentMountExtRef = useRef<Extension | null>(null);
|
||||
if (filterCompartmentMountExtRef.current == null) {
|
||||
filterCompartmentMountExtRef.current = filterLanguageCompartmentRef.current.of(
|
||||
filter({ fields: allFields ?? [] }),
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const view = filterRef.current;
|
||||
if (!view) return;
|
||||
const ext = filter({ fields: allFields ?? [] });
|
||||
view.dispatch({
|
||||
effects: filterLanguageCompartmentRef.current.reconfigure(ext),
|
||||
});
|
||||
}, [allFields]);
|
||||
|
||||
if (tree == null || hidden) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={wrapperRef}
|
||||
aria-hidden={hidden ?? undefined}
|
||||
className={classNames(className, 'h-full grid grid-rows-[auto_minmax(0,1fr)_auto]')}
|
||||
>
|
||||
<div className="w-full pl-3 pr-0.5 pt-3 grid grid-cols-[minmax(0,1fr)_auto] items-center">
|
||||
{(tree.children?.length ?? 0) > 0 && (
|
||||
<>
|
||||
<Input
|
||||
hideLabel
|
||||
setRef={setFilterRef}
|
||||
size="sm"
|
||||
label="filter"
|
||||
language={null} // Explicitly disable
|
||||
placeholder="Search"
|
||||
onChange={handleFilterChange}
|
||||
defaultValue={filterText.text}
|
||||
forceUpdateKey={filterText.key}
|
||||
onKeyDown={handleFilterKeyDown}
|
||||
stateKey={null}
|
||||
wrapLines={false}
|
||||
extraExtensions={filterCompartmentMountExtRef.current ?? undefined}
|
||||
rightSlot={
|
||||
filterText.text && (
|
||||
<IconButton
|
||||
className="!bg-transparent !h-auto min-h-full opacity-50 hover:opacity-100 -mr-1"
|
||||
icon="x"
|
||||
title="Clear filter"
|
||||
onClick={clearFilterText}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: 'Focus Active Request',
|
||||
leftSlot: <Icon icon="crosshair" />,
|
||||
onSelect: () => {
|
||||
const activeId = jotaiStore.get(activeIdAtom);
|
||||
if (activeId == null) return;
|
||||
|
||||
const folders = jotaiStore.get(foldersAtom);
|
||||
const workspaces = jotaiStore.get(workspacesAtom);
|
||||
const currentModel = getAnyModel(activeId);
|
||||
const ancestors = getModelAncestors(folders, workspaces, currentModel);
|
||||
jotaiStore.set(collapsedFamily(treeId), (prev) => {
|
||||
const n = { ...prev };
|
||||
for (const ancestor of ancestors) {
|
||||
if (ancestor.model === 'folder') {
|
||||
delete n[ancestor.id];
|
||||
}
|
||||
}
|
||||
return n;
|
||||
});
|
||||
treeRef.current?.selectItem(activeId, false);
|
||||
treeRef.current?.focus();
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Expand All Folders',
|
||||
leftSlot: <Icon icon="chevrons_up_down" />,
|
||||
onSelect: actions['sidebar.expand_all'].cb,
|
||||
hotKeyAction: 'sidebar.expand_all',
|
||||
hotKeyLabelOnly: true,
|
||||
},
|
||||
{
|
||||
label: 'Collapse All Folders',
|
||||
leftSlot: <Icon icon="chevrons_down_up" />,
|
||||
onSelect: actions['sidebar.collapse_all'].cb,
|
||||
hotKeyAction: 'sidebar.collapse_all',
|
||||
hotKeyLabelOnly: true,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<IconButton
|
||||
size="xs"
|
||||
className="ml-0.5 text-text-subtle hover:text-text"
|
||||
icon="ellipsis_vertical"
|
||||
title="Show sidebar actions menu"
|
||||
/>
|
||||
</Dropdown>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{allHidden ? (
|
||||
<div className="italic text-text-subtle p-3 text-sm text-center">
|
||||
No results for <InlineCode>{filterText.text}</InlineCode>
|
||||
</div>
|
||||
) : (
|
||||
<Tree
|
||||
ref={handleTreeRefInit}
|
||||
root={tree}
|
||||
treeId={treeId}
|
||||
hotkeys={hotkeys}
|
||||
getItemKey={getItemKey}
|
||||
ItemInner={SidebarInnerItem}
|
||||
ItemLeftSlotInner={SidebarLeftSlot}
|
||||
getContextMenu={getContextMenu}
|
||||
onActivate={handleActivate}
|
||||
getEditOptions={getEditOptions}
|
||||
className="pl-2 pr-3 pt-2 pb-2"
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
)}
|
||||
<GitDropdown />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
|
||||
const activeIdAtom = atom<string | null>((get) => {
|
||||
return get(activeRequestIdAtom) || get(activeFolderIdAtom);
|
||||
});
|
||||
|
||||
function getEditOptions(
|
||||
item: SidebarModel,
|
||||
): ReturnType<NonNullable<TreeItemProps<SidebarModel>['getEditOptions']>> {
|
||||
return {
|
||||
onChange: handleSubmitEdit,
|
||||
defaultValue: resolvedModelName(item),
|
||||
placeholder: item.name,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmitEdit(item: SidebarModel, text: string) {
|
||||
await patchModel(item, { name: text });
|
||||
}
|
||||
|
||||
function handleActivate(item: SidebarModel) {
|
||||
// TODO: Add folder layout support
|
||||
if (item.model !== 'folder' && item.model !== 'workspace') {
|
||||
navigateToRequestOrFolderOrWorkspace(item.id, item.model);
|
||||
}
|
||||
}
|
||||
|
||||
const allPotentialChildrenAtom = atom<SidebarModel[]>((get) => {
|
||||
const requests = get(allRequestsAtom);
|
||||
const folders = get(foldersAtom);
|
||||
return [...requests, ...folders];
|
||||
});
|
||||
|
||||
const memoAllPotentialChildrenAtom = deepEqualAtom(allPotentialChildrenAtom);
|
||||
|
||||
const sidebarFilterAtom = atom<{ text: string; key: string }>({
|
||||
text: '',
|
||||
key: '',
|
||||
});
|
||||
|
||||
const sidebarTreeAtom = atom<[TreeNode<SidebarModel>, FieldDef[]] | null>((get) => {
|
||||
const allModels = get(memoAllPotentialChildrenAtom);
|
||||
const activeWorkspace = get(activeWorkspaceAtom);
|
||||
const filter = get(sidebarFilterAtom);
|
||||
|
||||
const childrenMap: Record<string, Exclude<SidebarModel, Workspace>[]> = {};
|
||||
for (const item of allModels) {
|
||||
if ('folderId' in item && item.folderId == null) {
|
||||
childrenMap[item.workspaceId] = childrenMap[item.workspaceId] ?? [];
|
||||
childrenMap[item.workspaceId]?.push(item);
|
||||
} else if ('folderId' in item && item.folderId != null) {
|
||||
childrenMap[item.folderId] = childrenMap[item.folderId] ?? [];
|
||||
childrenMap[item.folderId]?.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
if (activeWorkspace == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const queryAst = parseQuery(filter.text);
|
||||
|
||||
// returns true if this node OR any child matches the filter
|
||||
const allFields: Record<string, Set<string>> = {};
|
||||
const build = (node: TreeNode<SidebarModel>, depth: number): boolean => {
|
||||
const childItems = childrenMap[node.item.id] ?? [];
|
||||
let matchesSelf = true;
|
||||
const fields = getItemFields(node);
|
||||
const model = node.item.model;
|
||||
const isLeafNode = !(model === 'folder' || model === 'workspace');
|
||||
|
||||
for (const [field, value] of Object.entries(fields)) {
|
||||
if (!value) continue;
|
||||
allFields[field] = allFields[field] ?? new Set();
|
||||
allFields[field].add(value);
|
||||
}
|
||||
|
||||
if (queryAst != null) {
|
||||
matchesSelf = isLeafNode && evaluate(queryAst, { text: getItemText(node.item), fields });
|
||||
}
|
||||
|
||||
let matchesChild = false;
|
||||
|
||||
// Recurse to children
|
||||
node.children = !isLeafNode ? [] : undefined;
|
||||
|
||||
if (node.children != null) {
|
||||
childItems.sort((a, b) => {
|
||||
if (a.sortPriority === b.sortPriority) {
|
||||
return a.updatedAt > b.updatedAt ? 1 : -1;
|
||||
}
|
||||
return a.sortPriority - b.sortPriority;
|
||||
});
|
||||
|
||||
for (const item of childItems) {
|
||||
const childNode = { item, parent: node, depth };
|
||||
const childMatches = build(childNode, depth + 1);
|
||||
if (childMatches) {
|
||||
matchesChild = true;
|
||||
}
|
||||
node.children.push(childNode);
|
||||
}
|
||||
}
|
||||
|
||||
// hide node IFF nothing in its subtree matches
|
||||
const anyMatch = matchesSelf || matchesChild;
|
||||
node.hidden = !anyMatch;
|
||||
|
||||
return anyMatch;
|
||||
};
|
||||
|
||||
const root: TreeNode<SidebarModel> = {
|
||||
item: activeWorkspace,
|
||||
parent: null,
|
||||
children: [],
|
||||
depth: 0,
|
||||
};
|
||||
|
||||
// Build tree and mark visibility in one pass
|
||||
build(root, 1);
|
||||
|
||||
const fields: FieldDef[] = [];
|
||||
for (const [name, values] of Object.entries(allFields)) {
|
||||
fields.push({
|
||||
name,
|
||||
values: Array.from(values).filter((v) => v.length < 20),
|
||||
});
|
||||
}
|
||||
return [root, fields] as const;
|
||||
});
|
||||
|
||||
function getItemKey(item: SidebarModel) {
|
||||
const responses = jotaiStore.get(httpResponsesAtom);
|
||||
const latestResponse = responses.find((r) => r.requestId === item.id) ?? null;
|
||||
const url = 'url' in item ? item.url : 'n/a';
|
||||
const method = 'method' in item ? item.method : 'n/a';
|
||||
const service = 'service' in item ? item.service : 'n/a';
|
||||
return [
|
||||
item.id,
|
||||
item.name,
|
||||
url,
|
||||
method,
|
||||
service,
|
||||
latestResponse?.elapsed,
|
||||
latestResponse?.id ?? 'n/a',
|
||||
].join('::');
|
||||
}
|
||||
|
||||
const SidebarLeftSlot = memo(function SidebarLeftSlot({
|
||||
treeId,
|
||||
item,
|
||||
}: {
|
||||
treeId: string;
|
||||
item: SidebarModel;
|
||||
}) {
|
||||
if (item.model === 'folder') {
|
||||
return <Icon icon="folder" />;
|
||||
}
|
||||
if (item.model === 'workspace') {
|
||||
return null;
|
||||
}
|
||||
const isSelected = jotaiStore.get(isSelectedFamily({ treeId, itemId: item.id }));
|
||||
return (
|
||||
<HttpMethodTag
|
||||
short
|
||||
className={classNames('text-xs pl-1.5', !isSelected && OPACITY_SUBTLE)}
|
||||
request={item}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const SidebarInnerItem = memo(function SidebarInnerItem({
|
||||
item,
|
||||
}: {
|
||||
treeId: string;
|
||||
item: SidebarModel;
|
||||
}) {
|
||||
const response = useAtomValue(
|
||||
useMemo(
|
||||
() =>
|
||||
selectAtom(
|
||||
atom((get) => [
|
||||
...get(grpcConnectionsAtom),
|
||||
...get(httpResponsesAtom),
|
||||
...get(websocketConnectionsAtom),
|
||||
]),
|
||||
(responses) => responses.find((r) => r.requestId === item.id),
|
||||
(a, b) => a?.state === b?.state && a?.id === b?.id, // Only update when the response state changes updated
|
||||
),
|
||||
[item.id],
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-0 h-full w-full text-left">
|
||||
<div className="truncate">{resolvedModelName(item)}</div>
|
||||
{response != null && (
|
||||
<div className="ml-auto">
|
||||
{response.state !== 'closed' ? (
|
||||
<LoadingIcon size="sm" className="text-text-subtlest" />
|
||||
) : response.model === 'http_response' ? (
|
||||
<HttpStatusTag short className="text-xs" response={response} />
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
function getItemFields(node: TreeNode<SidebarModel>): Record<string, string> {
|
||||
const item = node.item;
|
||||
|
||||
if (item.model === 'workspace') return {};
|
||||
|
||||
const fields: Record<string, string> = {};
|
||||
if (item.model === 'http_request') {
|
||||
fields.method = item.method.toUpperCase();
|
||||
}
|
||||
|
||||
if (item.model === 'grpc_request') {
|
||||
fields.grpc_method = item.method ?? '';
|
||||
fields.grpc_service = item.service ?? '';
|
||||
}
|
||||
|
||||
if ('url' in item) fields.url = item.url;
|
||||
fields.name = resolvedModelName(item);
|
||||
|
||||
fields.type = 'http';
|
||||
if (item.model === 'grpc_request') fields.type = 'grpc';
|
||||
else if (item.model === 'websocket_request') fields.type = 'ws';
|
||||
|
||||
if (node.parent?.item.model === 'folder') {
|
||||
fields.folder = node.parent.item.name;
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function getItemText(item: SidebarModel): string {
|
||||
const segments = [];
|
||||
if (item.model === 'http_request') {
|
||||
segments.push(item.method);
|
||||
}
|
||||
|
||||
segments.push(resolvedModelName(item));
|
||||
|
||||
return segments.join(' ');
|
||||
}
|
||||
39
apps/yaak-client/components/SidebarActions.tsx
Normal file
39
apps/yaak-client/components/SidebarActions.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useFloatingSidebarHidden } from '../hooks/useFloatingSidebarHidden';
|
||||
import { useShouldFloatSidebar } from '../hooks/useShouldFloatSidebar';
|
||||
import { useSidebarHidden } from '../hooks/useSidebarHidden';
|
||||
import { CreateDropdown } from './CreateDropdown';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
export function SidebarActions() {
|
||||
const floating = useShouldFloatSidebar();
|
||||
const [normalHidden, setNormalHidden] = useSidebarHidden();
|
||||
const [floatingHidden, setFloatingHidden] = useFloatingSidebarHidden();
|
||||
|
||||
const hidden = floating ? floatingHidden : normalHidden;
|
||||
const setHidden = useMemo(
|
||||
() => (floating ? setFloatingHidden : setNormalHidden),
|
||||
[floating, setFloatingHidden, setNormalHidden],
|
||||
);
|
||||
|
||||
return (
|
||||
<HStack className="h-full">
|
||||
<IconButton
|
||||
onClick={async () => {
|
||||
// NOTE: We're not using the (h) => !h pattern here because the data
|
||||
// might be different if another window changed it (out of sync)
|
||||
await setHidden(!hidden);
|
||||
}}
|
||||
className="pointer-events-auto"
|
||||
size="sm"
|
||||
title="Toggle sidebar"
|
||||
icon={hidden ? 'left_panel_hidden' : 'left_panel_visible'}
|
||||
iconColor="secondary"
|
||||
/>
|
||||
<CreateDropdown hotKeyAction="model.create">
|
||||
<IconButton size="sm" icon="plus_circle" iconColor="secondary" title="Add Resource" />
|
||||
</CreateDropdown>
|
||||
</HStack>
|
||||
);
|
||||
}
|
||||
62
apps/yaak-client/components/SwitchWorkspaceDialog.tsx
Normal file
62
apps/yaak-client/components/SwitchWorkspaceDialog.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import type { Workspace } from '@yaakapp-internal/models';
|
||||
import { patchModel, settingsAtom } from '@yaakapp-internal/models';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useState } from 'react';
|
||||
import { switchWorkspace } from '../commands/switchWorkspace';
|
||||
import { Button } from './core/Button';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { Icon } from './core/Icon';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { HStack, VStack } from './core/Stacks';
|
||||
|
||||
interface Props {
|
||||
hide: () => void;
|
||||
workspace: Workspace;
|
||||
}
|
||||
|
||||
export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const [remember, setRemember] = useState<boolean>(false);
|
||||
|
||||
return (
|
||||
<VStack space={3}>
|
||||
<p>
|
||||
Where would you like to open <InlineCode>{workspace.name}</InlineCode>?
|
||||
</p>
|
||||
<HStack space={2} justifyContent="start" className="flex-row-reverse">
|
||||
<Button
|
||||
className="focus"
|
||||
color="primary"
|
||||
onClick={async () => {
|
||||
hide();
|
||||
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
|
||||
if (remember) {
|
||||
await patchModel(settings, { openWorkspaceNewWindow: false });
|
||||
}
|
||||
}}
|
||||
>
|
||||
This Window
|
||||
</Button>
|
||||
<Button
|
||||
className="focus"
|
||||
color="secondary"
|
||||
rightSlot={<Icon icon="external_link" />}
|
||||
onClick={async () => {
|
||||
hide();
|
||||
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
|
||||
if (remember) {
|
||||
await patchModel(settings, { openWorkspaceNewWindow: true });
|
||||
}
|
||||
}}
|
||||
>
|
||||
New Window
|
||||
</Button>
|
||||
</HStack>
|
||||
{settings && (
|
||||
<HStack justifyContent="end">
|
||||
<Checkbox checked={remember} title="Remember my choice" onChange={setRemember} />
|
||||
</HStack>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
74
apps/yaak-client/components/SyncToFilesystemSetting.tsx
Normal file
74
apps/yaak-client/components/SyncToFilesystemSetting.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { readDir } from '@tauri-apps/plugin-fs';
|
||||
import { useState } from 'react';
|
||||
import { openWorkspaceFromSyncDir } from '../commands/openWorkspaceFromSyncDir';
|
||||
import { Banner } from './core/Banner';
|
||||
import { Button } from './core/Button';
|
||||
import { Checkbox } from './core/Checkbox';
|
||||
import { VStack } from './core/Stacks';
|
||||
import { SelectFile } from './SelectFile';
|
||||
|
||||
export interface SyncToFilesystemSettingProps {
|
||||
onChange: (args: { filePath: string | null; initGit?: boolean }) => void;
|
||||
onCreateNewWorkspace: () => void;
|
||||
value: { filePath: string | null; initGit?: boolean };
|
||||
}
|
||||
|
||||
export function SyncToFilesystemSetting({
|
||||
onChange,
|
||||
onCreateNewWorkspace,
|
||||
value,
|
||||
}: SyncToFilesystemSettingProps) {
|
||||
const [syncDir, setSyncDir] = useState<string | null>(null);
|
||||
return (
|
||||
<VStack className="w-full my-2" space={3}>
|
||||
{syncDir && (
|
||||
<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(syncDir);
|
||||
onCreateNewWorkspace();
|
||||
}}
|
||||
>
|
||||
Open Workspace
|
||||
</Button>
|
||||
</div>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<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) {
|
||||
setSyncDir(filePath);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setSyncDir(null);
|
||||
onChange({ ...value, filePath });
|
||||
}}
|
||||
/>
|
||||
|
||||
{value.filePath && typeof value.initGit === 'boolean' && (
|
||||
<Checkbox
|
||||
checked={value.initGit}
|
||||
onChange={(initGit) => onChange({ ...value, initGit })}
|
||||
title="Initialize Git Repo"
|
||||
/>
|
||||
)}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
299
apps/yaak-client/components/TemplateFunctionDialog.tsx
Normal file
299
apps/yaak-client/components/TemplateFunctionDialog.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import type { EditorView } from '@codemirror/view';
|
||||
import type {
|
||||
Folder,
|
||||
GrpcRequest,
|
||||
HttpRequest,
|
||||
WebsocketRequest,
|
||||
Workspace,
|
||||
} from '@yaakapp-internal/models';
|
||||
import type { TemplateFunction } from '@yaakapp-internal/plugins';
|
||||
import type { FnArg, Tokens } from '@yaakapp-internal/templates';
|
||||
import { parseTemplate } from '@yaakapp-internal/templates';
|
||||
import classNames from 'classnames';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { activeWorkspaceAtom } from '../hooks/useActiveWorkspace';
|
||||
import { useDebouncedValue } from '../hooks/useDebouncedValue';
|
||||
import { useRenderTemplate } from '../hooks/useRenderTemplate';
|
||||
import { useTemplateFunctionConfig } from '../hooks/useTemplateFunctionConfig';
|
||||
import {
|
||||
templateTokensToString,
|
||||
useTemplateTokensToString,
|
||||
} from '../hooks/useTemplateTokensToString';
|
||||
import { useToggle } from '../hooks/useToggle';
|
||||
import { showDialog } from '../lib/dialog';
|
||||
import { convertTemplateToInsecure } from '../lib/encryption';
|
||||
import { jotaiStore } from '../lib/jotai';
|
||||
import { setupOrConfigureEncryption } from '../lib/setupOrConfigureEncryption';
|
||||
import { Button } from './core/Button';
|
||||
import { collectArgumentValues } from './core/Editor/twig/util';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import { InlineCode } from './core/InlineCode';
|
||||
import { LoadingIcon } from './core/LoadingIcon';
|
||||
import { PlainInput } from './core/PlainInput';
|
||||
import { HStack } from './core/Stacks';
|
||||
import { DYNAMIC_FORM_NULL_ARG, DynamicForm } from './DynamicForm';
|
||||
|
||||
interface Props {
|
||||
templateFunction: TemplateFunction;
|
||||
initialTokens: Tokens;
|
||||
hide: () => void;
|
||||
onChange: (insert: string) => void;
|
||||
model: HttpRequest | GrpcRequest | WebsocketRequest | Folder | Workspace;
|
||||
}
|
||||
|
||||
export function TemplateFunctionDialog({ initialTokens, templateFunction, ...props }: Props) {
|
||||
const [initialArgValues, setInitialArgValues] = useState<Record<string, string | boolean> | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (initialArgValues != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const initial = collectArgumentValues(initialTokens, templateFunction);
|
||||
|
||||
// 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,
|
||||
templateFunction.args,
|
||||
templateFunction.name,
|
||||
]);
|
||||
|
||||
if (initialArgValues == null) return null;
|
||||
|
||||
return (
|
||||
<InitializedTemplateFunctionDialog
|
||||
{...props}
|
||||
templateFunction={templateFunction}
|
||||
initialArgValues={initialArgValues}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function InitializedTemplateFunctionDialog({
|
||||
templateFunction: { name, previewType: ogPreviewType },
|
||||
initialArgValues,
|
||||
hide,
|
||||
onChange,
|
||||
model,
|
||||
}: Omit<Props, 'initialTokens'> & {
|
||||
initialArgValues: Record<string, string | boolean>;
|
||||
}) {
|
||||
const previewType = ogPreviewType == null ? 'live' : ogPreviewType;
|
||||
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) => ({
|
||||
name,
|
||||
value:
|
||||
argValues[name] === DYNAMIC_FORM_NULL_ARG
|
||||
? { type: 'null' }
|
||||
: typeof argValues[name] === 'boolean'
|
||||
? { type: 'bool', value: argValues[name] === true }
|
||||
: { type: 'str', text: String(argValues[name] ?? '') },
|
||||
}));
|
||||
|
||||
return {
|
||||
tokens: [
|
||||
{
|
||||
type: 'tag',
|
||||
val: {
|
||||
type: 'fn',
|
||||
name,
|
||||
args: argTokens,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}, [argValues, name]);
|
||||
|
||||
const tagText = useTemplateTokensToString(tokens);
|
||||
const templateFunction = useTemplateFunctionConfig(name, argValues, model);
|
||||
|
||||
const handleDone = () => {
|
||||
if (tagText.data) {
|
||||
onChange(tagText.data);
|
||||
}
|
||||
hide();
|
||||
};
|
||||
|
||||
const debouncedTagText = useDebouncedValue(tagText.data ?? '', 400);
|
||||
const [renderKey, setRenderKey] = useState<string | null>(null);
|
||||
const rendered = useRenderTemplate({
|
||||
template: debouncedTagText,
|
||||
enabled: previewType !== 'none',
|
||||
purpose: previewType === 'click' ? 'send' : 'preview',
|
||||
refreshKey: previewType === 'live' ? renderKey + debouncedTagText : renderKey,
|
||||
ignoreError: false,
|
||||
});
|
||||
|
||||
const tooLarge = rendered.data ? rendered.data.length > 10000 : false;
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Only update this on rendered data change to keep secrets hidden on input change
|
||||
const dataContainsSecrets = useMemo(() => {
|
||||
for (const [name, value] of Object.entries(argValues)) {
|
||||
const arg = templateFunction.data?.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;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, [rendered.data]);
|
||||
|
||||
if (templateFunction.data == null || templateFunction.isPending) {
|
||||
return (
|
||||
<div className="h-full w-full flex items-center justify-center">
|
||||
<LoadingIcon size="xl" className="text-text-subtlest" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid grid-rows-[minmax(0,1fr)_auto_auto] h-full max-h-[90vh]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleDone();
|
||||
}}
|
||||
>
|
||||
<div className="overflow-y-auto h-full px-6">
|
||||
{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.data.args}
|
||||
data={argValues}
|
||||
onChange={setArgValues}
|
||||
stateKey={`template_function.${templateFunction.data.name}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 border-t border-t-border pt-3 pb-6 bg-surface-highlight w-full flex flex-col gap-4">
|
||||
{previewType !== 'none' ? (
|
||||
<div className="w-full grid grid-cols-1 grid-rows-[auto_auto]">
|
||||
<HStack space={0.5}>
|
||||
<HStack className="text-sm text-text-subtle" space={1.5}>
|
||||
Rendered Preview
|
||||
{rendered.isLoading && <LoadingIcon size="xs" />}
|
||||
</HStack>
|
||||
<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>
|
||||
<div className="relative w-full max-h-[10rem]">
|
||||
<InlineCode
|
||||
className={classNames(
|
||||
'block whitespace-pre-wrap !select-text cursor-text max-h-[10rem] overflow-auto hide-scrollbars !border-text-subtlest',
|
||||
tooLarge && 'italic text-danger',
|
||||
)}
|
||||
>
|
||||
{rendered.error || tagText.error ? (
|
||||
<em className="text-danger">
|
||||
{`${rendered.error || tagText.error}`.replace(/^Render Error: /, '')}
|
||||
</em>
|
||||
) : dataContainsSecrets && !showSecretsInPreview ? (
|
||||
<span className="italic text-text-subtle">
|
||||
------ sensitive values hidden ------
|
||||
</span>
|
||||
) : tooLarge ? (
|
||||
'too large to preview'
|
||||
) : (
|
||||
rendered.data || <> </>
|
||||
)}
|
||||
</InlineCode>
|
||||
<div className="absolute right-0.5 top-0 bottom-0 flex items-center">
|
||||
<IconButton
|
||||
size="xs"
|
||||
icon="refresh"
|
||||
className="text-text-subtle"
|
||||
title="Refresh preview"
|
||||
spin={rendered.isPending}
|
||||
onClick={() => {
|
||||
setRenderKey(new Date().toISOString());
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex justify-stretch w-full flex-grow gap-2 [&>*]:flex-1">
|
||||
{templateFunction.data.name === 'secure' && (
|
||||
<Button variant="border" color="secondary" onClick={setupOrConfigureEncryption}>
|
||||
Reveal Encryption Key
|
||||
</Button>
|
||||
)}
|
||||
<Button type="submit" color="primary">
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
TemplateFunctionDialog.show = (
|
||||
fn: TemplateFunction,
|
||||
tagValue: string,
|
||||
startPos: number,
|
||||
view: EditorView,
|
||||
) => {
|
||||
const initialTokens = parseTemplate(tagValue);
|
||||
showDialog({
|
||||
id: `template-function-${Math.random()}`, // Allow multiple at once
|
||||
size: 'md',
|
||||
className: 'h-[60rem]',
|
||||
noPadding: true,
|
||||
title: <InlineCode>{fn.name}(…)</InlineCode>,
|
||||
description: fn.description,
|
||||
render: ({ hide }) => {
|
||||
const model = jotaiStore.get(activeWorkspaceAtom);
|
||||
if (model == null) return null;
|
||||
return (
|
||||
<TemplateFunctionDialog
|
||||
templateFunction={fn}
|
||||
model={model}
|
||||
hide={hide}
|
||||
initialTokens={initialTokens}
|
||||
onChange={(insert) => {
|
||||
view.dispatch({
|
||||
changes: [{ from: startPos, to: startPos + tagValue.length, insert }],
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
43
apps/yaak-client/components/Toasts.tsx
Normal file
43
apps/yaak-client/components/Toasts.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { AnimatePresence } from 'motion/react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { hideToast, toastsAtom } from '../lib/toast';
|
||||
import { Toast, type ToastProps } from './core/Toast';
|
||||
import { ErrorBoundary } from './ErrorBoundary';
|
||||
import { Portal } from './Portal';
|
||||
|
||||
export type ToastInstance = {
|
||||
id: string;
|
||||
uniqueKey: string;
|
||||
message: ReactNode;
|
||||
timeout: 3000 | 5000 | 8000 | (number & {}) | null;
|
||||
onClose?: ToastProps['onClose'];
|
||||
} & Omit<ToastProps, 'onClose' | 'open' | 'children' | 'timeout'>;
|
||||
|
||||
export const Toasts = () => {
|
||||
const toasts = useAtomValue(toastsAtom);
|
||||
return (
|
||||
<Portal name="toasts">
|
||||
<div className="absolute right-0 bottom-0 z-50">
|
||||
<AnimatePresence>
|
||||
{toasts.map((toast: ToastInstance) => {
|
||||
const { message, uniqueKey, ...props } = toast;
|
||||
return (
|
||||
<ErrorBoundary key={uniqueKey} name={`Toast ${uniqueKey}`}>
|
||||
<Toast
|
||||
open
|
||||
{...props}
|
||||
// We call onClose inside actions.hide instead of passing to toast so that
|
||||
// it gets called from external close calls as well
|
||||
onClose={() => hideToast(toast)}
|
||||
>
|
||||
{message}
|
||||
</Toast>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
})}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
113
apps/yaak-client/components/UrlBar.tsx
Normal file
113
apps/yaak-client/components/UrlBar.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import classNames from 'classnames';
|
||||
import type { FormEvent, ReactNode } from 'react';
|
||||
import { memo, useCallback, useRef, useState } from 'react';
|
||||
import { useHotKey } from '../hooks/useHotKey';
|
||||
import type { IconProps } from './core/Icon';
|
||||
import { IconButton } from './core/IconButton';
|
||||
import type { InputHandle, InputProps } from './core/Input';
|
||||
import { Input } from './core/Input';
|
||||
import { HStack } from './core/Stacks';
|
||||
|
||||
type Props = Pick<HttpRequest, 'url'> & {
|
||||
className?: string;
|
||||
placeholder: string;
|
||||
onSend: () => void;
|
||||
onUrlChange: (url: string) => void;
|
||||
onPaste?: (v: string) => void;
|
||||
onPasteOverwrite?: InputProps['onPasteOverwrite'];
|
||||
onCancel: () => void;
|
||||
submitIcon?: IconProps['icon'] | null;
|
||||
isLoading: boolean;
|
||||
forceUpdateKey: string;
|
||||
rightSlot?: ReactNode;
|
||||
leftSlot?: ReactNode;
|
||||
autocomplete?: InputProps['autocomplete'];
|
||||
stateKey: InputProps['stateKey'];
|
||||
};
|
||||
|
||||
export const UrlBar = memo(function UrlBar({
|
||||
forceUpdateKey,
|
||||
onUrlChange,
|
||||
url,
|
||||
placeholder,
|
||||
className,
|
||||
onSend,
|
||||
onCancel,
|
||||
onPaste,
|
||||
onPasteOverwrite,
|
||||
submitIcon = 'send_horizontal',
|
||||
autocomplete,
|
||||
leftSlot,
|
||||
rightSlot,
|
||||
isLoading,
|
||||
stateKey,
|
||||
}: Props) {
|
||||
const inputRef = useRef<InputHandle>(null);
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
|
||||
const handleInitInputRef = useCallback((h: InputHandle | null) => {
|
||||
inputRef.current = h;
|
||||
}, []);
|
||||
|
||||
useHotKey('url_bar.focus', () => {
|
||||
inputRef.current?.selectAll();
|
||||
});
|
||||
|
||||
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (isLoading) onCancel();
|
||||
else onSend();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={classNames('x-theme-urlBar', className)}>
|
||||
<Input
|
||||
setRef={handleInitInputRef}
|
||||
autocompleteFunctions
|
||||
autocompleteVariables
|
||||
stateKey={stateKey}
|
||||
size="sm"
|
||||
wrapLines={isFocused}
|
||||
hideLabel
|
||||
language="url"
|
||||
className="px-1.5 py-0.5"
|
||||
label="Enter URL"
|
||||
name="url"
|
||||
autocomplete={autocomplete}
|
||||
forceUpdateKey={forceUpdateKey}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
onPaste={onPaste}
|
||||
onPasteOverwrite={onPasteOverwrite}
|
||||
onChange={onUrlChange}
|
||||
defaultValue={url}
|
||||
placeholder={placeholder}
|
||||
leftSlot={leftSlot}
|
||||
rightSlot={
|
||||
<HStack space={0.5}>
|
||||
{rightSlot && <div className="py-0.5 h-full">{rightSlot}</div>}
|
||||
{submitIcon !== null && (
|
||||
<div className="py-0.5 h-full">
|
||||
<IconButton
|
||||
size="xs"
|
||||
iconSize="md"
|
||||
title="Send Request"
|
||||
type="submit"
|
||||
className="w-8 mr-0.5 !h-full"
|
||||
iconColor="secondary"
|
||||
icon={isLoading ? 'x' : submitIcon}
|
||||
hotkeyAction="request.send"
|
||||
onMouseDown={(e) => {
|
||||
// Prevent the button from taking focus
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
});
|
||||
55
apps/yaak-client/components/UrlParameterEditor.tsx
Normal file
55
apps/yaak-client/components/UrlParameterEditor.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { HttpRequest } from '@yaakapp-internal/models';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { useRequestEditor, useRequestEditorEvent } from '../hooks/useRequestEditor';
|
||||
import type { PairEditorHandle, PairEditorProps } from './core/PairEditor';
|
||||
import { PairOrBulkEditor } from './core/PairOrBulkEditor';
|
||||
import { VStack } from './core/Stacks';
|
||||
|
||||
type Props = {
|
||||
forceUpdateKey: string;
|
||||
pairs: HttpRequest['headers'];
|
||||
stateKey: PairEditorProps['stateKey'];
|
||||
onChange: (headers: HttpRequest['urlParameters']) => void;
|
||||
};
|
||||
|
||||
export function UrlParametersEditor({ pairs, forceUpdateKey, onChange, stateKey }: Props) {
|
||||
const pairEditorRef = useRef<PairEditorHandle>(null);
|
||||
const handleInitPairEditorRef = useCallback((ref: PairEditorHandle) => {
|
||||
pairEditorRef.current = ref;
|
||||
}, []);
|
||||
|
||||
const [{ urlParametersKey }] = useRequestEditor();
|
||||
|
||||
useRequestEditorEvent(
|
||||
'request_params.focus_value',
|
||||
(name) => {
|
||||
const pair = pairs.find((p) => p.name === name);
|
||||
if (pair?.id != null) {
|
||||
pairEditorRef.current?.focusValue(pair.id);
|
||||
} else {
|
||||
console.log(`Couldn't find pair to focus`, { name, pairs });
|
||||
}
|
||||
},
|
||||
[pairs],
|
||||
);
|
||||
|
||||
return (
|
||||
<VStack className="h-full">
|
||||
<PairOrBulkEditor
|
||||
setRef={handleInitPairEditorRef}
|
||||
allowMultilineValues
|
||||
forceUpdateKey={forceUpdateKey + urlParametersKey}
|
||||
nameAutocompleteFunctions
|
||||
nameAutocompleteVariables
|
||||
namePlaceholder="param_name"
|
||||
onChange={onChange}
|
||||
pairs={pairs}
|
||||
preferenceName="url_parameters"
|
||||
stateKey={stateKey}
|
||||
valueAutocompleteFunctions
|
||||
valueAutocompleteVariables
|
||||
valuePlaceholder="Value"
|
||||
/>
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user