Ability to open workspace from directory, WorkspaceMeta, and many sync improvements

This commit is contained in:
Gregory Schier
2025-01-08 14:57:13 -08:00
parent 37671a50f2
commit cbc443075a
71 changed files with 1012 additions and 1844 deletions

View File

@@ -0,0 +1,150 @@
import type { Folder, Workspace } from '@yaakapp-internal/models';
import { applySync, calculateSync } from '@yaakapp-internal/sync';
import { Banner } from '../components/core/Banner';
import { InlineCode } from '../components/core/InlineCode';
import { VStack } from '../components/core/Stacks';
import { getActiveWorkspaceId } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from '../lib/analytics';
import { showConfirm } from '../lib/confirm';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { pluralizeCount } from '../lib/pluralize';
import { showPrompt } from '../lib/prompt';
import { router } from '../lib/router';
import { invokeCmd } from '../lib/tauri';
export const createWorkspace = createFastMutation<Workspace, void, Partial<Workspace>>({
mutationKey: ['create_workspace'],
mutationFn: (patch) => invokeCmd<Workspace>('cmd_update_workspace', { workspace: patch }),
onSuccess: async (workspace) => {
await router.navigate({
to: '/workspaces/$workspaceId',
params: { workspaceId: workspace.id },
});
},
onSettled: () => trackEvent('workspace', 'create'),
});
export const createFolder = createFastMutation<
Folder | null,
void,
Partial<Pick<Folder, 'name' | 'sortPriority' | 'folderId'>>
>({
mutationKey: ['create_folder'],
mutationFn: async (patch) => {
const workspaceId = getActiveWorkspaceId();
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();
return invokeCmd<Folder>('cmd_update_folder', { folder: { workspaceId, ...patch } });
},
onSettled: () => trackEvent('folder', 'create'),
});
export const syncWorkspace = createFastMutation<
void,
void,
{ workspaceId: string; syncDir: string }
>({
mutationKey: [],
mutationFn: async ({ workspaceId, syncDir }) => {
const ops = (await calculateSync(workspaceId, syncDir)) ?? [];
console.log('SYNCING WORKSPACE', ops);
if (ops.length === 0) {
return;
}
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('Filesystem changes detected', { dbOps, ops });
const confirmed = await showConfirm({
id: 'commit-sync',
title: 'Filesystem Changes Detected',
confirmText: 'Apply Changes',
description: (
<VStack space={3}>
{isDeletingWorkspace && (
<Banner color="danger">
🚨 <strong>Changes contain a workspace deletion!</strong>
</Banner>
)}
<p>
{pluralizeCount('file', dbOps.length)} in the directory have changed. Do you want to
apply the updates to your workspace?
</p>
<div className="overflow-y-auto max-h-[10rem]">
<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">
{dbOps.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>
</div>
</VStack>
),
});
if (confirmed) {
await applySync(workspaceId, syncDir, ops);
}
},
});

View File

@@ -0,0 +1,35 @@
import { open } from '@tauri-apps/plugin-dialog';
import { applySync, calculateSyncFsOnly } from '@yaakapp-internal/sync';
import { createFastMutation } from '../hooks/useFastMutation';
import { showSimpleAlert } from '../lib/alert';
import { router } from '../lib/router';
export const openWorkspace = createFastMutation({
mutationKey: [],
mutationFn: async () => {
const dir = await open({
title: 'Select Workspace Directory',
directory: true,
multiple: false,
});
if (dir == null) return;
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 },
});
},
});

View File

@@ -0,0 +1,19 @@
import type { WorkspaceMeta } from '@yaakapp-internal/models';
import { createFastMutation } from '../hooks/useFastMutation';
import { workspaceMetaAtom } from '../hooks/useWorkspaceMeta';
import { jotaiStore } from '../lib/jotai';
import { invokeCmd } from '../lib/tauri';
export const upsertWorkspaceMeta = createFastMutation<
WorkspaceMeta,
unknown,
Partial<WorkspaceMeta>
>({
mutationKey: ['update_workspace_meta'],
mutationFn: async (patch) => {
const workspaceMeta = jotaiStore.get(workspaceMetaAtom);
return invokeCmd<WorkspaceMeta>('cmd_update_workspace_meta', {
workspaceMeta: { ...workspaceMeta, ...patch },
});
},
});

View File

@@ -16,7 +16,7 @@ import type { HotkeyAction } from '../hooks/useHotKey';
import { useHotKey } from '../hooks/useHotKey';
import { useHttpRequestActions } from '../hooks/useHttpRequestActions';
import { useOpenSettings } from '../hooks/useOpenSettings';
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { useSwitchWorkspace } from '../hooks/useSwitchWorkspace';
import { useRecentEnvironments } from '../hooks/useRecentEnvironments';
import { useRecentRequests } from '../hooks/useRecentRequests';
import { useRecentWorkspaces } from '../hooks/useRecentWorkspaces';
@@ -26,7 +26,6 @@ import { useScrollIntoView } from '../hooks/useScrollIntoView';
import { useSendAnyHttpRequest } from '../hooks/useSendAnyHttpRequest';
import { useSidebarHidden } from '../hooks/useSidebarHidden';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { createFolder } from '../lib/commands';
import { showDialog, toggleDialog } from '../lib/dialog';
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { router } from '../lib/router';
@@ -39,6 +38,7 @@ import { Icon } from './core/Icon';
import { PlainInput } from './core/PlainInput';
import { HStack } from './core/Stacks';
import { EnvironmentEditDialog } from './EnvironmentEditDialog';
import { createFolder } from '../commands/commands';
interface CommandPaletteGroup {
key: string;
@@ -71,7 +71,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const [, setSidebarHidden] = useSidebarHidden();
const { baseEnvironment } = useEnvironments();
const { mutate: openSettings } = useOpenSettings();
const { mutate: openWorkspace } = useOpenWorkspace();
const { mutate: switchWorkspace } = useSwitchWorkspace();
const { mutate: createHttpRequest } = useCreateHttpRequest();
const { mutate: createGrpcRequest } = useCreateGrpcRequest();
const { mutate: createEnvironment } = useCreateEnvironment();
@@ -315,7 +315,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
workspaceGroup.items.push({
key: `switch-workspace-${w.id}`,
label: w.name,
onSelect: () => openWorkspace({ workspaceId: w.id, inNewWindow: false }),
onSelect: () => switchWorkspace({ workspaceId: w.id, inNewWindow: false }),
});
}
@@ -327,7 +327,7 @@ export function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
activeEnvironment?.id,
setActiveEnvironmentId,
sortedWorkspaces,
openWorkspace,
switchWorkspace,
]);
const allItems = useMemo(() => groups.flatMap((g) => g.items), [groups]);

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { createWorkspace } from '../lib/commands';
import { createWorkspace } from '../commands/commands';
import { Button } from './core/Button';
import { PlainInput } from './core/PlainInput';
import { VStack } from './core/Stacks';
@@ -32,7 +32,11 @@ export function CreateWorkspaceDialog({ hide }: Props) {
>
<PlainInput require label="Workspace Name" defaultValue={name} onChange={setName} />
<SyncToFilesystemSetting onChange={setSettingSyncDir} value={settingSyncDir.value} />
<SyncToFilesystemSetting
onChange={setSettingSyncDir}
value={settingSyncDir.value}
allowNonEmptyDirectory // Will do initial import when the workspace is created
/>
<Button
type="submit"
color="primary"

View File

@@ -1,15 +1,13 @@
import { emit } from '@tauri-apps/api/event';
import type { PromptTextRequest, PromptTextResponse } from '@yaakapp-internal/plugins';
import { useWatchWorkspace } from '@yaakapp-internal/sync';
import type { ShowToastRequest } from '@yaakapp/api';
import { useActiveWorkspace, useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace';
import { useSubscribeActiveWorkspaceId } from '../hooks/useActiveWorkspace';
import { useActiveWorkspaceChangedToast } from '../hooks/useActiveWorkspaceChangedToast';
import { useGenerateThemeCss } from '../hooks/useGenerateThemeCss';
import { useListenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { useNotificationToast } from '../hooks/useNotificationToast';
import { useSyncFontSizeSetting } from '../hooks/useSyncFontSizeSetting';
import { useSyncModelStores } from '../hooks/useSyncModelStores';
import { useSyncWorkspace } from '../hooks/useSyncWorkspace';
import { useSyncWorkspaceChildModels } from '../hooks/useSyncWorkspaceChildModels';
import { useSyncZoomSetting } from '../hooks/useSyncZoomSetting';
import { useSubscribeTemplateFunctions } from '../hooks/useTemplateFunctions';
@@ -31,12 +29,6 @@ export function GlobalHooks() {
useNotificationToast();
useActiveWorkspaceChangedToast();
// Trigger workspace sync operation when workspace files change
const activeWorkspace = useActiveWorkspace();
const { debouncedSync } = useSyncWorkspace(activeWorkspace, { debounceMillis: 1000 });
useListenToTauriEvent('upserted_model', debouncedSync);
useWatchWorkspace(activeWorkspace, debouncedSync);
// Listen for toasts
useListenToTauriEvent<ShowToastRequest>('show_toast', (event) => {
showToast({ ...event.payload });

View File

@@ -29,7 +29,7 @@ export function SelectFile({
}: Props) {
const handleClick = async () => {
const filePath = await open({
title: 'Select File',
title: directory ? 'Select Folder' : 'Select File',
multiple: false,
directory,
});

View File

@@ -20,7 +20,7 @@ export function SettingsGeneral() {
const settings = useSettings();
const updateSettings = useUpdateSettings();
const appInfo = useAppInfo();
const checkForUpdates = useCheckForUpdates();
const checkForUpdates = useCheckForUpdates();
if (settings == null || workspace == null) {
return null;
@@ -53,12 +53,12 @@ export function SettingsGeneral() {
/>
</div>
<Select
name="openWorkspace"
label="Open Workspace"
name="switchWorkspaceBehavior"
label="Switch Workspace Behavior"
labelPosition="left"
labelClassName="w-[12rem]"
size="sm"
event="workspace-open"
event="workspace-switch-behavior"
value={
settings.openWorkspaceNewWindow === true
? 'new'

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { useSwitchWorkspace } from '../hooks/useSwitchWorkspace';
import { useSettings } from '../hooks/useSettings';
import { useUpdateSettings } from '../hooks/useUpdateSettings';
import type { Workspace } from '@yaakapp-internal/models';
@@ -14,8 +14,8 @@ interface Props {
workspace: Workspace;
}
export function OpenWorkspaceDialog({ hide, workspace }: Props) {
const openWorkspace = useOpenWorkspace();
export function SwitchWorkspaceDialog({ hide, workspace }: Props) {
const switchWorkspace = useSwitchWorkspace();
const settings = useSettings();
const updateSettings = useUpdateSettings();
const [remember, setRemember] = useState<boolean>(false);
@@ -31,7 +31,7 @@ export function OpenWorkspaceDialog({ hide, workspace }: Props) {
color="primary"
onClick={() => {
hide();
openWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: false });
if (remember) {
updateSettings.mutate({ openWorkspaceNewWindow: false });
}
@@ -45,7 +45,7 @@ export function OpenWorkspaceDialog({ hide, workspace }: Props) {
rightSlot={<Icon icon="external_link" />}
onClick={() => {
hide();
openWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
switchWorkspace.mutate({ workspaceId: workspace.id, inNewWindow: true });
if (remember) {
updateSettings.mutate({ openWorkspaceNewWindow: true });
}

View File

@@ -1,3 +1,4 @@
import { readDir } from '@tauri-apps/plugin-fs';
import { useState } from 'react';
import { Checkbox } from './core/Checkbox';
import { VStack } from './core/Stacks';
@@ -6,10 +7,16 @@ import { SelectFile } from './SelectFile';
export interface SyncToFilesystemSettingProps {
onChange: (args: { value: string | null; enabled: boolean }) => void;
value: string | null;
allowNonEmptyDirectory?: boolean;
}
export function SyncToFilesystemSetting({ onChange, value }: SyncToFilesystemSettingProps) {
export function SyncToFilesystemSetting({
onChange,
value,
allowNonEmptyDirectory,
}: SyncToFilesystemSettingProps) {
const [useSyncDir, setUseSyncDir] = useState<boolean>(!!value);
const [error, setError] = useState<string | null>(null);
return (
<VStack space={1.5} className="w-full">
@@ -26,19 +33,28 @@ export function SyncToFilesystemSetting({ onChange, value }: SyncToFilesystemSet
}}
title="Sync to a filesystem directory"
/>
{error && <div className="text-danger">{error}</div>}
{useSyncDir && (
<>
<SelectFile
directory
size="xs"
noun="Directory"
filePath={value}
onChange={({ filePath }) => {
if (filePath == null) setUseSyncDir(false);
onChange({ value: filePath, enabled: useSyncDir });
}}
/>
</>
<SelectFile
directory
size="xs"
noun="Directory"
filePath={value}
onChange={async ({ filePath }) => {
setError(null);
if (filePath == null) {
setUseSyncDir(false);
} else {
const files = await readDir(filePath);
if (files.length > 0 && !allowNonEmptyDirectory) {
setError('Directory must be empty');
return;
}
}
onChange({ value: filePath, enabled: useSyncDir });
}}
/>
)}
</VStack>
);

View File

@@ -1,9 +1,10 @@
import classNames from 'classnames';
import { memo, useCallback, useMemo } from 'react';
import {openWorkspace} from "../commands/openWorkspace";
import { useActiveWorkspace } from '../hooks/useActiveWorkspace';
import { useCreateWorkspace } from '../hooks/useCreateWorkspace';
import { useDeleteSendHistory } from '../hooks/useDeleteSendHistory';
import { useOpenWorkspace } from '../hooks/useOpenWorkspace';
import { useSwitchWorkspace } from '../hooks/useSwitchWorkspace';
import { useSettings } from '../hooks/useSettings';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { showDialog } from '../lib/dialog';
@@ -14,7 +15,7 @@ import type { DropdownItem } from './core/Dropdown';
import { Icon } from './core/Icon';
import type { RadioDropdownItem } from './core/RadioDropdown';
import { RadioDropdown } from './core/RadioDropdown';
import { OpenWorkspaceDialog } from './OpenWorkspaceDialog';
import { SwitchWorkspaceDialog } from './SwitchWorkspaceDialog';
import { WorkspaceSettingsDialog } from './WorkspaceSettingsDialog';
type Props = Pick<ButtonProps, 'className' | 'justify' | 'forDropdown' | 'leftSlot'>;
@@ -28,7 +29,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
const createWorkspace = useCreateWorkspace();
const { mutate: deleteSendHistory } = useDeleteSendHistory();
const settings = useSettings();
const openWorkspace = useOpenWorkspace();
const switchWorkspace = useSwitchWorkspace();
const openWorkspaceNewWindow = settings?.openWorkspaceNewWindow ?? null;
const orderedWorkspaces = useMemo(
@@ -77,6 +78,12 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
leftSlot: <Icon icon="plus" />,
onSelect: createWorkspace,
},
{
key: 'open-workspace',
label: 'Open Workspace',
leftSlot: <Icon icon="folder" />,
onSelect: openWorkspace.mutate,
},
];
return { workspaceItems, extraItems };
@@ -87,7 +94,7 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
if (workspaceId == null) return;
if (typeof openWorkspaceNewWindow === 'boolean') {
openWorkspace.mutate({ workspaceId, inNewWindow: openWorkspaceNewWindow });
switchWorkspace.mutate({ workspaceId, inNewWindow: openWorkspaceNewWindow });
return;
}
@@ -95,13 +102,13 @@ export const WorkspaceActionsDropdown = memo(function WorkspaceActionsDropdown({
if (workspace == null) return;
showDialog({
id: 'open-workspace',
id: 'switch-workspace',
size: 'sm',
title: 'Open Workspace',
render: ({ hide }) => <OpenWorkspaceDialog workspace={workspace} hide={hide} />,
title: 'Switch Workspace',
render: ({ hide }) => <SwitchWorkspaceDialog workspace={workspace} hide={hide} />,
});
},
[openWorkspace, openWorkspaceNewWindow],
[switchWorkspace, openWorkspaceNewWindow],
);
return (

View File

@@ -1,8 +1,13 @@
import { upsertWorkspaceMeta } from '../commands/upsertWorkspaceMeta';
import { useDeleteActiveWorkspace } from '../hooks/useDeleteActiveWorkspace';
import { useUpdateWorkspace } from '../hooks/useUpdateWorkspace';
import { useWorkspaceMeta } from '../hooks/useWorkspaceMeta';
import { useWorkspaces } from '../hooks/useWorkspaces';
import { Banner } from './core/Banner';
import { Button } from './core/Button';
import { InlineCode } from './core/InlineCode';
import { Input } from './core/Input';
import { Separator } from './core/Separator';
import { VStack } from './core/Stacks';
import { MarkdownEditor } from './MarkdownEditor';
import { SyncToFilesystemSetting } from './SyncToFilesystemSetting';
@@ -15,10 +20,17 @@ interface Props {
export function WorkspaceSettingsDialog({ workspaceId, hide }: Props) {
const workspaces = useWorkspaces();
const workspace = workspaces.find((w) => w.id === workspaceId);
const workspaceMeta = useWorkspaceMeta();
const { mutate: updateWorkspace } = useUpdateWorkspace(workspaceId ?? null);
const { mutateAsync: deleteActiveWorkspace } = useDeleteActiveWorkspace();
if (workspace == null) return null;
if (workspaceMeta == null)
return (
<Banner color="danger">
<InlineCode>WorkspaceMeta</InlineCode> not found for workspace
</Banner>
);
return (
<VStack space={3} alignItems="start" className="pb-3 h-full">
@@ -39,21 +51,24 @@ export function WorkspaceSettingsDialog({ workspaceId, hide }: Props) {
heightMode="auto"
/>
<VStack space={3} className="mt-3" alignItems="start">
<VStack space={6} className="mt-3 w-full" alignItems="start">
<SyncToFilesystemSetting
value={workspace.settingSyncDir}
value={workspaceMeta.settingSyncDir}
onChange={({ value: settingSyncDir }) => {
updateWorkspace({ settingSyncDir });
upsertWorkspaceMeta.mutate({ settingSyncDir });
}}
/>
<Separator />
<Button
onClick={async () => {
await deleteActiveWorkspace();
hide();
const workspace = await deleteActiveWorkspace();
if (workspace) {
hide(); // Only hide if actually deleted workspace
}
}}
color="danger"
variant="border"
size="sm"
size="xs"
>
Delete Workspace
</Button>

View File

@@ -11,6 +11,7 @@ export interface DialogProps {
children: ReactNode;
open: boolean;
onClose?: () => void;
disableBackdropClose?: boolean;
title?: ReactNode;
description?: ReactNode;
className?: string;
@@ -27,6 +28,7 @@ export function Dialog({
size = 'full',
open,
onClose,
disableBackdropClose,
title,
description,
hideX,
@@ -51,7 +53,7 @@ export function Dialog({
);
return (
<Overlay open={open} onClose={onClose} portalName="dialog">
<Overlay open={open} onClose={disableBackdropClose ? undefined : onClose} portalName="dialog">
<div
role="dialog"
className={classNames(

View File

@@ -10,7 +10,7 @@ interface Props {
export function Separator({ className, dashed, orientation = 'horizontal', children }: Props) {
return (
<div role="separator" className={classNames(className, 'flex items-center')}>
<div role="separator" className={classNames(className, 'flex items-center w-full')}>
{children && (
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
)}

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { createFolder } from '../commands/commands';
import type { DropdownItem } from '../components/core/Dropdown';
import { Icon } from '../components/core/Icon';
import { createFolder } from '../lib/commands';
import { generateId } from '../lib/generateId';
import { BODY_TYPE_GRAPHQL } from '../lib/model_util';
import { getActiveRequest } from './useActiveRequest';

View File

@@ -36,6 +36,8 @@ export function createFastMutation<TData = unknown, TError = unknown, TVariables
} finally {
onSettled?.();
}
return null;
};
const mutate = (

View File

@@ -1,22 +1,24 @@
import type {EventCallback, EventName} from '@tauri-apps/api/event';
import {listen} from '@tauri-apps/api/event';
import {getCurrentWebviewWindow} from '@tauri-apps/api/webviewWindow';
import {useEffect} from 'react';
import type { EventCallback, EventName } from '@tauri-apps/api/event';
import { listen } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { useEffect } from 'react';
/**
* React hook to listen to a Tauri event.
*/
export function useListenToTauriEvent<T>(event: EventName, fn: EventCallback<T>) {
useEffect(() => {
const unlisten = listen<T>(
event,
fn,
// Listen to `emit_all()` events or events specific to the current window
{ target: { label: getCurrentWebviewWindow().label, kind: 'Window' } },
);
return () => {
unlisten.then((fn) => fn());
}
}, [event, fn]);
useEffect(() => listenToTauriEvent(event, fn), [event, fn]);
}
export function listenToTauriEvent<T>(event: EventName, fn: EventCallback<T>) {
const unlisten = listen<T>(
event,
fn,
// Listen to `emit_all()` events or events specific to the current window
{ target: { label: getCurrentWebviewWindow().label, kind: 'Window' } },
);
return () => {
unlisten.then((fn) => fn());
};
}

View File

@@ -5,7 +5,7 @@ import { getRecentCookieJars } from './useRecentCookieJars';
import { getRecentEnvironments } from './useRecentEnvironments';
import { getRecentRequests } from './useRecentRequests';
export function useOpenWorkspace() {
export function useSwitchWorkspace() {
return useFastMutation({
mutationKey: ['open_workspace'],
mutationFn: async ({

View File

@@ -19,6 +19,7 @@ import { useListenToTauriEvent } from './useListenToTauriEvent';
import { pluginsAtom } from './usePlugins';
import { useRequestUpdateKey } from './useRequestUpdateKey';
import { settingsAtom } from './useSettings';
import { workspaceMetaAtom } from './useWorkspaceMeta';
import { workspacesAtom } from './useWorkspaces';
export function useSyncModelStores() {
@@ -51,6 +52,8 @@ export function useSyncModelStores() {
if (payload.model.model === 'workspace') {
jotaiStore.set(workspacesAtom, updateModelList(payload.model));
} else if (payload.model.model === 'workspace_meta') {
jotaiStore.set(workspaceMetaAtom, payload.model);
} else if (payload.model.model === 'plugin') {
jotaiStore.set(pluginsAtom, updateModelList(payload.model));
} else if (payload.model.model === 'http_request') {

View File

@@ -1,102 +0,0 @@
import { debounce } from '@yaakapp-internal/lib';
import type { Workspace } from '@yaakapp-internal/models';
import { applySync, calculateSync } from '@yaakapp-internal/sync';
import { useCallback, useMemo } from 'react';
import { InlineCode } from '../components/core/InlineCode';
import { VStack } from '../components/core/Stacks';
import {showConfirm} from "../lib/confirm";
import { fallbackRequestName } from '../lib/fallbackRequestName';
import { pluralizeCount } from '../lib/pluralize';
export function useSyncWorkspace(
workspace: Workspace | null,
{
debounceMillis = 1000,
}: {
debounceMillis?: number;
} = {},
) {
const sync = useCallback(async () => {
if (workspace == null || !workspace.settingSyncDir) return;
const ops = await calculateSync(workspace) ?? [];
if (ops.length === 0) {
return;
}
const dbChanges = ops.filter((o) => o.type.startsWith('db'));
if (dbChanges.length === 0) {
await applySync(workspace, ops);
return;
}
console.log("Filesystem changes detected", dbChanges);
const confirmed = await showConfirm({
id: 'commit-sync',
title: 'Filesystem Changes Detected',
confirmText: 'Apply Changes',
description: (
<VStack space={3}>
<p>
{pluralizeCount('file', dbChanges.length)} in the directory have changed. Do you want to
apply the updates to your workspace?
</p>
<div className="overflow-y-auto max-h-[10rem]">
<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>
</div>
</VStack>
),
});
if (confirmed) {
await applySync(workspace, ops);
}
}, [workspace]);
const debouncedSync = useMemo(() => {
return debounce(sync, debounceMillis);
}, [debounceMillis, sync]);
return { sync, debouncedSync };
}

View File

@@ -1,3 +1,4 @@
import type { WorkspaceMeta } from '@yaakapp-internal/models';
import { useEffect } from 'react';
import { jotaiStore } from '../lib/jotai';
import { invokeCmd } from '../lib/tauri';
@@ -10,6 +11,7 @@ import { grpcRequestsAtom } from './useGrpcRequests';
import { httpRequestsAtom } from './useHttpRequests';
import { httpResponsesAtom } from './useHttpResponses';
import { keyValuesAtom } from './useKeyValue';
import { workspaceMetaAtom } from './useWorkspaceMeta';
export function useSyncWorkspaceChildModels() {
useEffect(() => {
@@ -39,4 +41,7 @@ async function sync() {
jotaiStore.set(httpResponsesAtom, await invokeCmd('cmd_list_http_responses', args));
jotaiStore.set(grpcConnectionsAtom, await invokeCmd('cmd_list_grpc_connections', args));
jotaiStore.set(environmentsAtom, await invokeCmd('cmd_list_environments', args));
// Single models
jotaiStore.set(workspaceMetaAtom, await invokeCmd<WorkspaceMeta>('cmd_get_workspace_meta', args));
}

View File

@@ -1,13 +1,9 @@
import { useFastMutation } from './useFastMutation';
import type { Workspace } from '@yaakapp-internal/models';
import {useSetAtom} from "jotai/index";
import { getWorkspace } from '../lib/store';
import { invokeCmd } from '../lib/tauri';
import {updateModelList} from "./useSyncModelStores";
import {workspacesAtom} from "./useWorkspaces";
import { useFastMutation } from './useFastMutation';
export function useUpdateWorkspace(id: string | null) {
const setWorkspaces = useSetAtom(workspacesAtom);
return useFastMutation<Workspace, unknown, Partial<Workspace> | ((w: Workspace) => Workspace)>({
mutationKey: ['update_workspace', id],
mutationFn: async (v) => {
@@ -19,8 +15,5 @@ export function useUpdateWorkspace(id: string | null) {
const newWorkspace = typeof v === 'function' ? v(workspace) : { ...workspace, ...v };
return invokeCmd('cmd_update_workspace', { workspace: newWorkspace });
},
onSuccess: async (workspace) => {
setWorkspaces(updateModelList(workspace));
},
});
}

View File

@@ -0,0 +1,13 @@
import type { WorkspaceMeta } from '@yaakapp-internal/models';
import { atom, useAtomValue } from 'jotai';
export const workspaceMetaAtom = atom<WorkspaceMeta>();
export function useWorkspaceMeta() {
const workspaceMeta = useAtomValue(workspaceMetaAtom);
if (!workspaceMeta) {
throw new Error('WorkspaceMeta not found');
}
return workspaceMeta;
}

55
src-web/init/sync.ts Normal file
View File

@@ -0,0 +1,55 @@
import { debounce } from '@yaakapp-internal/lib';
import type { AnyModel, ModelPayload } from '@yaakapp-internal/models';
import { watchWorkspaceFiles } from '@yaakapp-internal/sync';
import { syncWorkspace } from '../commands/commands';
import { listenToTauriEvent } from '../hooks/useListenToTauriEvent';
import { workspaceMetaAtom } from '../hooks/useWorkspaceMeta';
import { jotaiStore } from '../lib/jotai';
export function initSync() {
let unsub: (() => void) | undefined;
jotaiStore.sub(workspaceMetaAtom, () => {
unsub?.(); // Unsub from any previous watcher
const workspaceMeta = jotaiStore.get(workspaceMetaAtom);
if (workspaceMeta == null) return;
unsub = initForWorkspace(workspaceMeta.workspaceId, workspaceMeta.settingSyncDir);
});
}
// TODO: This list should be derived from something, because we might forget something here
const relevantModels: AnyModel['model'][] = [
'workspace',
'folder',
'environment',
'http_request',
'grpc_request',
];
function initForWorkspace(workspaceId: string, syncDir: string | null) {
console.log('Initializing directory sync for', workspaceId, syncDir);
const debouncedSync = debounce(() => {
if (syncDir == null) return;
syncWorkspace.mutate({ workspaceId, syncDir });
});
// Sync on model upsert
listenToTauriEvent<ModelPayload>('upserted_model', (p) => {
const isRelevant = relevantModels.includes(p.payload.model.model);
if (isRelevant) debouncedSync();
});
// Sync on model deletion
listenToTauriEvent<ModelPayload>('deleted_model', (p) => {
const isRelevant = relevantModels.includes(p.payload.model.model);
if (isRelevant) debouncedSync();
});
// Sync on sync dir changes
if (syncDir != null) {
return watchWorkspaceFiles(workspaceId, syncDir, debouncedSync);
}
// Perform an initial sync operation
debouncedSync();
}

View File

@@ -16,6 +16,15 @@ export function showAlert({ id, title, body, size = 'sm' }: AlertArgs) {
title,
hideX: true,
size,
disableBackdropClose: true, // Prevent accidental dismisses
render: ({ hide }) => Alert({ onHide: hide, body }),
});
}
export function showSimpleAlert(title: string, message: string) {
showAlert({
id: 'simple-alert',
body: message,
title: title,
});
}

View File

@@ -1,51 +0,0 @@
import type { Folder, Workspace } from '@yaakapp-internal/models';
import { getActiveWorkspaceId } from '../hooks/useActiveWorkspace';
import { createFastMutation } from '../hooks/useFastMutation';
import { trackEvent } from './analytics';
import { showPrompt } from './prompt';
import { router } from './router';
import { invokeCmd } from './tauri';
export const createWorkspace = createFastMutation<Workspace, void, Partial<Workspace>>({
mutationKey: ['create_workspace'],
mutationFn: (patch) => invokeCmd<Workspace>('cmd_update_workspace', { workspace: patch }),
onSuccess: async (workspace) => {
await router.navigate({
to: '/workspaces/$workspaceId',
params: { workspaceId: workspace.id },
});
},
onSettled: () => trackEvent('workspace', 'create'),
});
export const createFolder = createFastMutation<
Folder | null,
void,
Partial<Pick<Folder, 'name' | 'sortPriority' | 'folderId'>>
>({
mutationKey: ['create_folder'],
mutationFn: async (patch) => {
const workspaceId = getActiveWorkspaceId();
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();
return invokeCmd<Folder>('cmd_update_folder', { folder: { workspaceId, ...patch } });
},
onSettled: () => trackEvent('folder', 'create'),
});

View File

@@ -3,13 +3,10 @@ import { Confirm } from '../components/core/Confirm';
import type { DialogProps } from '../components/core/Dialog';
import { showDialog } from './dialog';
interface ConfirmArgs {
type ConfirmArgs = {
id: string;
title: DialogProps['title'];
description?: DialogProps['description'];
variant?: ConfirmProps['variant'];
confirmText?: ConfirmProps['confirmText'];
}
} & Pick<DialogProps, 'title' | 'description'> &
Pick<ConfirmProps, 'variant' | 'confirmText'>;
export async function showConfirm({ id, title, description, variant, confirmText }: ConfirmArgs) {
return new Promise((onResult: ConfirmProps['onResult']) => {
@@ -19,6 +16,7 @@ export async function showConfirm({ id, title, description, variant, confirmText
description,
hideX: true,
size: 'sm',
disableBackdropClose: true, // Prevent accidental dismisses
render: ({ hide }) => Confirm({ onHide: hide, variant, onResult, confirmText }),
});
});

View File

@@ -14,6 +14,7 @@ export async function showPrompt({ id, title, description, ...props }: PromptArg
description,
hideX: true,
size: 'sm',
disableBackdropClose: true, // Prevent accidental dismisses
onClose: () => {
// Click backdrop, close, or escape
resolve(null);

View File

@@ -37,6 +37,7 @@ type TauriCmd =
| 'cmd_get_key_value'
| 'cmd_get_settings'
| 'cmd_get_workspace'
| 'cmd_get_workspace_meta'
| 'cmd_grpc_go'
| 'cmd_grpc_reflect'
| 'cmd_http_request_actions'
@@ -75,6 +76,7 @@ type TauriCmd =
| 'cmd_update_http_request'
| 'cmd_update_settings'
| 'cmd_update_workspace'
| 'cmd_update_workspace_meta'
| 'cmd_write_file_dev';
export async function invokeCmd<T>(cmd: TauriCmd, args?: InvokeArgs): Promise<T> {

View File

@@ -4,6 +4,7 @@ import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
import { type } from '@tauri-apps/plugin-os';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { initSync } from './init/sync';
import { router } from './lib/router';
import('react-pdf').then(({ pdfjs }) => {
@@ -36,6 +37,9 @@ window.addEventListener('keydown', (e) => {
}
});
// Initialize a bunch of watchers
initSync();
console.log('Creating React root');
createRoot(document.getElementById('root') as HTMLElement).render(
<StrictMode>