Filesystem Sync (#142)

This commit is contained in:
Gregory Schier
2025-01-03 20:41:00 -08:00
committed by GitHub
parent 6ad27c4458
commit 31440eea76
159 changed files with 4296 additions and 1016 deletions

View File

@@ -6,6 +6,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useActiveCookieJar } from '../hooks/useActiveCookieJar';
import { useActiveEnvironment } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useCommands } from '../hooks/useCommands';
import { useCreateEnvironment } from '../hooks/useCreateEnvironment';
import { useCreateGrpcRequest } from '../hooks/useCreateGrpcRequest';
import { useCreateHttpRequest } from '../hooks/useCreateHttpRequest';
@@ -53,21 +54,22 @@ type CommandPaletteItem = {
const MAX_PER_GROUP = 8;
export function CommandPalette({ onClose }: { onClose: () => void }) {
export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const [command, setCommand] = useDebouncedState<string>('', 150);
const [selectedItemKey, setSelectedItemKey] = useState<string | null>(null);
const [activeEnvironment, setActiveEnvironmentId] = useActiveEnvironment();
const httpRequestActions = useHttpRequestActions();
const workspaces = useWorkspaces();
const { subEnvironments } = useEnvironments();
const createWorkspace = useCreateWorkspace();
const recentEnvironments = useRecentEnvironments();
const recentWorkspaces = useRecentWorkspaces();
const requests = useRequests();
const activeRequest = useActiveRequest();
const [recentRequests] = useRecentRequests();
const openWorkspace = useOpenWorkspace();
const createWorkspace = useCreateWorkspace();
const createHttpRequest = useCreateHttpRequest();
const { createFolder } = useCommands();
const [activeCookieJar] = useActiveCookieJar();
const createGrpcRequest = useCreateGrpcRequest();
const createEnvironment = useCreateEnvironment();
@@ -91,13 +93,18 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
{
key: 'app.create',
label: 'Create Workspace',
onSelect: createWorkspace.mutate,
onSelect: createWorkspace,
},
{
key: 'http_request.create',
label: 'Create HTTP Request',
onSelect: () => createHttpRequest.mutate({}),
},
{
key: 'folder.create',
label: 'Create Folder',
onSelect: () => createFolder.mutate({}),
},
{
key: 'cookies.show',
label: 'Show Cookies',
@@ -183,9 +190,10 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
activeRequest,
baseEnvironment,
createEnvironment,
createFolder,
createGrpcRequest,
createHttpRequest,
createWorkspace.mutate,
createWorkspace,
deleteRequest.mutate,
dialog,
httpRequestActions,
@@ -230,9 +238,14 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
}, [subEnvironments, recentEnvironments]);
const sortedWorkspaces = useMemo(() => {
const r = [...workspaces].sort((a, b) => {
const aRecentIndex = recentWorkspaces.indexOf(a.id);
const bRecentIndex = recentWorkspaces.indexOf(b.id);
if (recentWorkspaces == null) {
// Should never happen
return workspaces;
}
const r = [...workspaces].sort((a, b) => {
const aRecentIndex = recentWorkspaces?.indexOf(a.id);
const bRecentIndex = recentWorkspaces?.indexOf(b.id);
if (aRecentIndex >= 0 && bRecentIndex >= 0) {
return aRecentIndex - bRecentIndex;
@@ -309,7 +322,7 @@ export function CommandPalette({ onClose }: { onClose: () => void }) {
for (const w of sortedWorkspaces) {
workspaceGroup.items.push({
key: `switch-workspace-${w.id}`,
label: w.id + ' - ' + w.name,
label: w.name,
onSelect: () => openWorkspace.mutate({ workspaceId: w.id, inNewWindow: false }),
});
}

View File

@@ -0,0 +1,54 @@
import { useState } from 'react';
import { useCommands } from '../hooks/useCommands';
import { Button } from './core/Button';
import { PlainInput } from './core/PlainInput';
import { VStack } from './core/Stacks';
import { MarkdownEditor } from './MarkdownEditor';
import { SelectFile } from './SelectFile';
interface Props {
hide: () => void;
}
export function CreateWorkspaceDialog({ hide }: Props) {
const [name, setName] = useState<string>('');
const [description, setDescription] = useState<string>('');
const [settingSyncDir, setSettingSyncDir] = useState<string | null>(null);
const { createWorkspace } = useCommands();
return (
<VStack
as="form"
space={3}
alignItems="start"
className="pb-3 max-h-[50vh]"
onSubmit={async (e) => {
e.preventDefault();
await createWorkspace.mutateAsync({ name, description, settingSyncDir });
hide();
}}
>
<PlainInput label="Workspace Name" defaultValue={name} onChange={setName} />
<MarkdownEditor
name="workspace-description"
placeholder="Workspace description"
className="min-h-[10rem] max-h-[25rem] border border-border px-2"
defaultValue={description}
stateKey={null}
onChange={setDescription}
heightMode="auto"
/>
<div>
<SelectFile
directory
noun="Sync Directory"
filePath={settingSyncDir}
onChange={({ filePath }) => setSettingSyncDir(filePath)}
/>
</div>
<Button type="submit">Create Workspace</Button>
</VStack>
);
}

View File

@@ -1,6 +1,9 @@
import { emit } from '@tauri-apps/api/event';
import type { PromptTextRequest, PromptTextResponse } from '@yaakapp-internal/plugin';
import { useEnsureActiveCookieJar, useSubscribeActiveCookieJarId } from '../hooks/useActiveCookieJar';
import type { PromptTextRequest, PromptTextResponse } from '@yaakapp-internal/plugins';
import {
useEnsureActiveCookieJar,
useSubscribeActiveCookieJarId,
} from '../hooks/useActiveCookieJar';
import { useSubscribeActiveEnvironmentId } from '../hooks/useActiveEnvironment';
import { useActiveRequest } from '../hooks/useActiveRequest';
import { useSubscribeActiveRequestId } from '../hooks/useActiveRequestId';
@@ -13,10 +16,10 @@ import { useHotKey } from '../hooks/useHotKey';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useNotificationToast } from '../hooks/useNotificationToast';
import { usePrompt } from '../hooks/usePrompt';
import {useRecentCookieJars, useSubscribeRecentCookieJars} from '../hooks/useRecentCookieJars';
import {useRecentEnvironments, useSubscribeRecentEnvironments} from '../hooks/useRecentEnvironments';
import { useSubscribeRecentCookieJars } from '../hooks/useRecentCookieJars';
import { useSubscribeRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useSubscribeRecentRequests } from '../hooks/useRecentRequests';
import {useRecentWorkspaces, useSubscribeRecentWorkspaces} from '../hooks/useRecentWorkspaces';
import { useSubscribeRecentWorkspaces } from '../hooks/useRecentWorkspaces';
import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting';
import { useSyncModelStores } from '../hooks/useSyncModelStores';
import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels';
@@ -42,10 +45,6 @@ export function GlobalHooks() {
useSubscribeRecentEnvironments();
useSubscribeRecentCookieJars();
// Include here so they always update, even if no component references them
useRecentWorkspaces();
useRecentEnvironments();
useRecentCookieJars();
useSyncWorkspaceChildModels();
useSubscribeTemplateFunctions();

View File

@@ -120,7 +120,7 @@ export function GrpcConnectionLayout({ style }: Props) {
) : messages.length >= 0 ? (
<GrpcConnectionMessagesPane activeRequest={activeRequest} methodType={methodType} />
) : (
<HotKeyList hotkeys={['grpc_request.send', 'sidebar.focus', 'urlBar.focus']} />
<HotKeyList hotkeys={['grpc_request.send', 'sidebar.focus', 'url_bar.focus']} />
)}
</div>
)

View File

@@ -64,7 +64,7 @@ export function MarkdownEditor({
defaultValue.length === 0 ? (
<p className="text-text-subtle">No description</p>
) : (
<Prose className="max-w-xl">
<Prose className="max-w-xl overflow-y-auto max-h-full">
<Markdown
remarkPlugins={[remarkGfm]}
components={{

View File

@@ -64,17 +64,17 @@ export function MoveToWorkspaceDialog({ onDone, request, activeWorkspaceId }: Pr
</InlineCode>
</>
),
action: (
action: ({ hide }) => (
<Button
size="xs"
color="secondary"
className="mr-auto min-w-[5rem]"
onClick={async () => {
toast.hide('workspace-moved');
await navigate({
to: '/workspaces/$workspaceId',
params: { workspaceId: selectedWorkspaceId },
});
hide();
}}
>
Switch to Workspace

View File

@@ -12,7 +12,7 @@ export function RedirectToLatestWorkspace() {
const navigate = useNavigate();
useEffect(() => {
if (workspaces.length === 0) {
if (workspaces.length === 0 || recentWorkspaces == null) {
console.log('No workspaces found to redirect to. Skipping.');
return;
}

View File

@@ -105,7 +105,7 @@ export const ResponsePane = memo(function ResponsePane({
>
{activeResponse == null ? (
<HotKeyList
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'urlBar.focus']}
hotkeys={['http_request.send', 'http_request.create', 'sidebar.focus', 'url_bar.focus']}
/>
) : (
<div className="h-full w-full grid grid-rows-[auto_minmax(0,1fr)] grid-cols-1">

View File

@@ -6,7 +6,7 @@ import { Button } from './core/Button';
import { IconButton } from './core/IconButton';
import { HStack } from './core/Stacks';
type Props = ButtonProps & {
type Props = Omit<ButtonProps, 'type'> & {
onChange: (value: { filePath: string | null; contentType: string | null }) => void;
filePath: string | null;
directory?: boolean;

View File

@@ -7,7 +7,7 @@ import type {
TemplateFunctionHttpRequestArg,
TemplateFunctionSelectArg,
TemplateFunctionTextArg,
} from '@yaakapp-internal/plugin';
} from '@yaakapp-internal/plugins';
import type { FnArg, Tokens } from '@yaakapp-internal/templates';
import classNames from 'classnames';
import { useCallback, useMemo, useState } from 'react';

View File

@@ -1,9 +1,9 @@
import type { ShowToastRequest } from '@yaakapp-internal/plugin';
import type { ShowToastRequest } from '@yaakapp-internal/plugins';
import { AnimatePresence } from 'framer-motion';
import React, {type ReactNode, useContext, useMemo, useRef, useState} from 'react';
import React, { type ReactNode, useContext, useMemo, useRef, useState } from 'react';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { generateId } from '../lib/generateId';
import {Toast, type ToastProps} from './core/Toast';
import { Toast, type ToastProps } from './core/Toast';
import { Portal } from './Portal';
import { ToastContext } from './ToastContext';
@@ -29,7 +29,6 @@ export interface Actions {
hide: (id: string) => void;
}
export const ToastProvider = ({ children }: { children: React.ReactNode }) => {
const [toasts, setToasts] = useState<ToastState['toasts']>([]);
const timeoutRef = useRef<NodeJS.Timeout>();

View File

@@ -49,7 +49,7 @@ export const UrlBar = memo(function UrlBar({
const inputRef = useRef<EditorView>(null);
const [isFocused, setIsFocused] = useState<boolean>(false);
useHotKey('urlBar.focus', () => {
useHotKey('url_bar.focus', () => {
const head = inputRef.current?.state.doc.length ?? 0;
inputRef.current?.dispatch({
selection: { anchor: 0, head },

View File

@@ -1,21 +1,28 @@
import { applySync, calculateSync } from '@yaakapp-internal/sync';
import classNames from 'classnames';
import { memo, useCallback, useMemo } from 'react';
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useConfirm } from '../hooks/useConfirm';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteSendHistory } from '../hooks/useDeleteSendHistory';
import { useDialog } from '../hooks/useDialog';
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { useSettings } from '../hooks/useSettings';
import { useToast } from '../hooks/useToast';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { pluralizeCount } from '../lib/pluralize';
import { getWorkspace } from '../lib/store';
import type { ButtonProps } from './core/Button';
import { Button } from './core/Button';
import type { DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import { InlineCode } from './core/InlineCode';
import type { RadioDropdownItem } from './core/RadioDropdown';
import { RadioDropdown } from './core/RadioDropdown';
import { VStack } from './core/Stacks';
import { OpenWorkspaceDialog } from './OpenWorkspaceDialog';
import { WorkspaceSettingsDialog } from './WorkpaceSettingsDialog';
import { WorkspaceSettingsDialog } from './WorkspaceSettingsDialog';
type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown' | 'leftSlot'>;
@@ -25,10 +32,11 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
}: Props) {
const workspaces = useWorkspaces();
const activeWorkspace = useActiveWorkspace();
const activeWorkspaceId = activeWorkspace?.id ?? null;
const { mutate: createWorkspace } = useCreateWorkspace();
const createWorkspace = useCreateWorkspace();
const { mutate: deleteSendHistory } = useDeleteSendHistory();
const dialog = useDialog();
const confirm = useConfirm();
const toast = useToast();
const settings = useSettings();
const openWorkspace = useOpenWorkspace();
const openWorkspaceNewWindow = settings?.openWorkspaceNewWindow ?? null;
@@ -46,7 +54,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
key: w.id,
label: w.name,
value: w.id,
leftSlot: w.id === activeWorkspaceId ? <Icon icon="check" /> : <Icon icon="empty" />,
leftSlot: w.id === activeWorkspace?.id ? <Icon icon="check" /> : <Icon icon="empty" />,
}));
const extraItems: DropdownItem[] = [
@@ -54,6 +62,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
key: 'workspace-settings',
label: 'Workspace Settings',
leftSlot: <Icon icon="settings" />,
hotKeyAction: 'workspace_settings.show',
onSelect: async () => {
dialog.show({
id: 'workspace-settings',
@@ -63,6 +72,96 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
});
},
},
{
key: 'sync',
label: 'Sync Workspace',
leftSlot: <Icon icon="folder_sync" />,
hidden: !activeWorkspace?.settingSyncDir,
onSelect: async () => {
if (activeWorkspace == null) return;
const ops = await calculateSync(activeWorkspace);
if (ops.length === 0) {
toast.show({
id: 'no-sync-changes',
message: 'No changes detected for sync',
});
return;
}
const dbChanges = ops.filter((o) => o.type.startsWith('db'));
if (dbChanges.length === 0) {
await applySync(activeWorkspace, ops);
toast.show({
id: 'applied-sync-changes',
message: `Applied ${pluralizeCount('change', ops.length)}`,
});
return;
}
const confirmed = await confirm({
id: 'commit-sync',
title: 'Filesystem Changes Detected',
confirmText: 'Apply Changes',
description: (
<VStack space={3}>
<p>
Some files in the directory have changed. Do you want to apply the updates to your
workspace?
</p>
<table className="w-full text-sm mb-auto min-w-full max-w-full divide-y divide-surface-highlight">
<thead>
<tr>
<th className="py-1 text-left">Name</th>
<th className="py-1 text-right pl-4">Operation</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-highlight">
{dbChanges.map((op, i) => {
let name = '';
let label = '';
let color = '';
if (op.type === 'dbCreate') {
label = 'create';
name = fallbackRequestName(op.fs.model);
color = 'text-success';
} else if (op.type === 'dbUpdate') {
label = 'update';
name = fallbackRequestName(op.fs.model);
color = 'text-info';
} else if (op.type === 'dbDelete') {
label = 'delete';
name = fallbackRequestName(op.model);
color = 'text-danger';
} else {
return null;
}
return (
<tr key={i} className="text-text">
<td className="py-1">{name}</td>
<td className="py-1 pl-4 text-right">
<InlineCode className={color}>{label}</InlineCode>
</td>
</tr>
);
})}
</tbody>
</table>
</VStack>
),
});
if (confirmed) {
await applySync(activeWorkspace, ops);
toast.show({
id: 'applied-confirmed-sync-changes',
message: `Applied ${pluralizeCount('change', ops.length)}`,
});
}
},
},
{
key: 'delete-responses',
label: 'Clear Send History',
@@ -80,12 +179,13 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
return { workspaceItems, extraItems };
}, [
activeWorkspace?.id,
activeWorkspaceId,
createWorkspace,
deleteSendHistory,
dialog,
orderedWorkspaces,
activeWorkspace,
deleteSendHistory,
createWorkspace,
dialog,
confirm,
toast,
]);
const handleChange = useCallback(
@@ -115,7 +215,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
items={workspaceItems}
extraItems={extraItems}
onChange={handleChange}
value={activeWorkspaceId}
value={activeWorkspace?.id ?? null}
>
<Button
size="sm"

View File

@@ -5,15 +5,16 @@ import { Button } from './core/Button';
import { PlainInput } from './core/PlainInput';
import { VStack } from './core/Stacks';
import { MarkdownEditor } from './MarkdownEditor';
import { SelectFile } from './SelectFile';
interface Props {
workspaceId: string | null;
}
export function WorkspaceSettingsDialog({ workspaceId }: Props) {
const updateWorkspace = useUpdateWorkspace(workspaceId ?? null);
const workspaces = useWorkspaces();
const workspace = workspaces.find((w) => w.id === workspaceId);
const { mutate: updateWorkspace } = useUpdateWorkspace(workspaceId ?? null);
const { mutate: deleteWorkspace } = useDeleteWorkspace();
if (workspace == null) return null;
@@ -23,21 +24,27 @@ export function WorkspaceSettingsDialog({ workspaceId }: Props) {
<PlainInput
label="Workspace Name"
defaultValue={workspace.name}
onChange={(name) => updateWorkspace.mutate({ name })}
onChange={(name) => updateWorkspace({ name })}
/>
<MarkdownEditor
name="workspace-description"
placeholder="Workspace description"
className="min-h-[10rem] border border-border px-2"
className="min-h-[10rem] max-h-[25rem] border border-border px-2"
defaultValue={workspace.description}
stateKey={`description.${workspace.id}`}
onChange={(description) => updateWorkspace.mutate({ description })}
onChange={(description) => updateWorkspace({ description })}
heightMode="auto"
/>
<VStack space={3} className="mt-3" alignItems="start">
<Button onClick={() => deleteWorkspace()} color="danger" variant="border">
<SelectFile
directory
noun="Sync Directory"
filePath={workspace.settingSyncDir}
onChange={({ filePath: settingSyncDir }) => updateWorkspace({ settingSyncDir })}
/>
<Button onClick={() => deleteWorkspace()} color="danger" variant="border" size="sm">
Delete Workspace
</Button>
</VStack>

View File

@@ -4,7 +4,7 @@ import type { EditorStateConfig, Extension } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state';
import { keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import type { EnvironmentVariable } from '@yaakapp-internal/models';
import type { TemplateFunction } from '@yaakapp-internal/plugin';
import type { TemplateFunction } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import { EditorView } from 'codemirror';
import type { MutableRefObject, ReactNode } from 'react';

View File

@@ -41,6 +41,8 @@ const icons = {
file_code: lucide.FileCodeIcon,
filter: lucide.FilterIcon,
flask: lucide.FlaskConicalIcon,
folder: lucide.FolderIcon,
folder_sync: lucide.FolderSyncIcon,
folder_input: lucide.FolderInputIcon,
folder_output: lucide.FolderOutputIcon,
git_branch: lucide.GitBranchIcon,

View File

@@ -1,4 +1,4 @@
import type { ShowToastRequest } from '@yaakapp-internal/plugin';
import type { ShowToastRequest } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import { motion } from 'framer-motion';
import type { ReactNode } from 'react';
@@ -15,7 +15,7 @@ export interface ToastProps {
onClose: () => void;
className?: string;
timeout: number | null;
action?: ReactNode;
action?: (args: { hide: () => void }) => ReactNode;
icon?: ShowToastRequest['icon'];
color?: ShowToastRequest['color'];
}
@@ -59,15 +59,15 @@ export function Toast({ children, open, onClose, timeout, action, icon, color }:
`x-theme-toast x-theme-toast--${color}`,
'pointer-events-auto overflow-hidden',
'relative pointer-events-auto bg-surface text-text rounded-lg',
'border border-border shadow-lg max-w-[30rem]',
'border border-border shadow-lg w-[25rem]',
'grid grid-cols-[1fr_auto]',
)}
>
<div className="px-3 py-3 flex items-center gap-2">
{toastIcon && <Icon icon={toastIcon} className="text-text-subtle" />}
<VStack space={2}>
<div className="px-3 py-3 flex items-start gap-2 w-full">
{toastIcon && <Icon icon={toastIcon} className="mt-1 text-text-subtle" />}
<VStack space={2} className="w-full">
<div>{children}</div>
{action}
{action?.({ hide: onClose })}
</VStack>
</div>