Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3e2c21b
docs: add Bruno import & YAML folder sync implementation plan
claude Feb 8, 2026
104c0d2
docs: rewrite plan to use OpenCollection YAML instead of .bru format
claude Feb 8, 2026
aad4e34
docs: major plan update — Open YAML format, workspace sync modes, SQL…
claude Feb 8, 2026
bc54091
docs: correct architecture — folder is source of truth, SQLite is cache
claude Feb 8, 2026
b20b034
docs: align plan with DevTools conventions — reuse yamlflowsimplev2 t…
claude Feb 8, 2026
f48d151
docs: simplify plan — no topenyaml package, no config files, skip non…
claude Feb 8, 2026
0c34cfe
docs: wrapper package for directory I/O, clean Bruno separation
claude Feb 8, 2026
b9088ab
docs: rename openyaml references — it's the format, not just I/O
claude Feb 8, 2026
d2f7fa0
feat: implement Bruno import, OpenYAML format, and workspace sync schema
claude Feb 8, 2026
8038b38
test: add migration and workspace sync service tests
claude Feb 8, 2026
7508f08
chore: update go.work.sum after test dependency resolution
claude Feb 8, 2026
63d75e0
refactor: return WorkspaceBundle from topencollection, extract httpLo…
claude Feb 8, 2026
c7904a6
feat: add sync fields to workspace TypeSpec and RPC handler
claude Feb 8, 2026
bcb531f
feat: add folder sync UI — Import from Folder button + sync settings …
claude Feb 8, 2026
ba3afe8
fix: handle union types for sync fields in workspace RPC handler
claude Feb 8, 2026
982b844
fix: correct modernc.org/sqlite checksum in go.work.sum
claude Feb 8, 2026
1823198
style: run prettier on all new/modified files
claude Feb 8, 2026
b55143f
fix: add sqlc overrides for workspace sync columns
claude Feb 8, 2026
72e3c8a
fix: resolve golangci-lint issues in openyaml, topencollection, rwork…
claude Feb 8, 2026
0678e3f
fix: suppress gosec G304 for openyaml directory reads
claude Feb 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 328 additions & 0 deletions go.work.sum

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions packages/client/src/pages/dashboard/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useApiCollection } from '~/shared/api';
import { getNextOrder, handleCollectionReorder, pick } from '~/shared/lib';
import { routes } from '~/shared/routes';
import { DashboardLayout } from '~/shared/ui';
import { useFolderSyncDialog, useImportFolderDialog } from '~/widgets/folder-sync';

export const Route = createFileRoute('/(dashboard)/')({
component: RouteComponent,
Expand Down Expand Up @@ -65,8 +66,12 @@ export const WorkspaceListPage = () => {
renderDropIndicator: () => <DropIndicatorHorizontal />,
});

const importFolderDialog = useImportFolderDialog();

return (
<div className={tw`container mx-auto my-12 grid min-h-0 gap-x-10 gap-y-6`}>
{importFolderDialog.render}

<div className={tw`col-span-full`}>
<span className={tw`mb-1 text-sm leading-5 tracking-tight text-slate-500`}>
{pipe(DateTime.unsafeNow(), DateTime.formatLocal({ dateStyle: 'full' }))}
Expand All @@ -77,6 +82,9 @@ export const WorkspaceListPage = () => {
<div className={tw`relative flex min-h-0 flex-col rounded-lg border border-slate-200`} ref={containerRef}>
<div className={tw`flex items-center gap-2 border-b border-inherit px-5 py-3`}>
<span className={tw`flex-1 font-semibold tracking-tight text-slate-800`}>Your Workspaces</span>
<Button onPress={() => void importFolderDialog.open()} variant='secondary'>
Import from Folder
</Button>
<Button
onPress={async () =>
void workspaceCollection.utils.insert({
Expand Down Expand Up @@ -152,10 +160,12 @@ const Item = ({ containerRef, id }: ItemProps) => {
});

const deleteModal = useProgrammaticModal();
const folderSyncDialog = useFolderSyncDialog();

return (
<ListBoxItem id={id} textValue={name}>
{deleteModal.children && <Modal {...deleteModal} className={tw`h-auto`} size='xs' />}
{folderSyncDialog.render}

<div className={tw`flex items-center gap-3 px-5 py-4`} onContextMenu={onContextMenu}>
<Avatar shape='square' size='md'>
Expand Down Expand Up @@ -221,6 +231,10 @@ const Item = ({ containerRef, id }: ItemProps) => {
<Menu {...menuProps}>
<MenuItem onAction={() => void edit()}>Rename</MenuItem>

<MenuItem onAction={() => void folderSyncDialog.open({ workspaceId: workspaceUlid.bytes })}>
Folder Sync...
</MenuItem>

<MenuItem
onAction={() =>
void deleteModal.onOpenChange(
Expand Down
275 changes: 275 additions & 0 deletions packages/client/src/widgets/folder-sync/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import { ReactNode, useState, useTransition } from 'react';
import { Dialog, Heading, Label, Radio, RadioGroup } from 'react-aria-components';
import { FiFolder } from 'react-icons/fi';
import { Ulid } from 'id128';
import { WorkspaceCollectionSchema } from '@the-dev-tools/spec/tanstack-db/v1/api/workspace';
import { Button } from '@the-dev-tools/ui/button';
import { Modal, useProgrammaticModal } from '@the-dev-tools/ui/modal';
import { tw } from '@the-dev-tools/ui/tailwind-literal';
import { TextInputField } from '@the-dev-tools/ui/text-field';
import { useApiCollection } from '~/shared/api';
import { getNextOrder } from '~/shared/lib';

type SyncFormat = 'openyaml' | 'bruno';

// --- Folder Sync Dialog (for existing workspaces) ---

interface FolderSyncDialogProps {
workspaceId: Uint8Array;
currentPath?: string;
currentFormat?: string;
currentEnabled?: boolean;
}

export const useFolderSyncDialog = () => {
const modal = useProgrammaticModal();

const open = (props: FolderSyncDialogProps): void =>
void modal.onOpenChange(true, <FolderSyncDialogContent {...props} />);

const render: ReactNode = modal.children && <Modal {...modal} className={tw`h-auto`} size='sm' />;

return { open, render };
};

const FolderSyncDialogContent = ({
workspaceId,
currentPath,
currentFormat,
currentEnabled,
}: FolderSyncDialogProps) => {
const workspaceCollection = useApiCollection(WorkspaceCollectionSchema);

const [folderPath, setFolderPath] = useState(currentPath ?? '');
const [format, setFormat] = useState<SyncFormat>((currentFormat as SyncFormat) ?? 'openyaml');
const [isPending, startTransition] = useTransition();

const browseFolder = async () => {
if (!window.electron?.dialog) return;
const result = await window.electron.dialog('showOpenDialog', {
properties: ['openDirectory'],
title: 'Select folder to sync',
});
if (!result.canceled && result.filePaths[0]) {
setFolderPath(result.filePaths[0]);
}
};

const enableSync = () =>
startTransition(async () => {
await workspaceCollection.utils.update({
workspaceId,
syncPath: folderPath,
syncFormat: format,
syncEnabled: true,
});
});

const disableSync = () =>
startTransition(async () => {
await workspaceCollection.utils.update({
workspaceId,
syncEnabled: false,
});
});

return (
<Dialog className={tw`flex flex-col p-5 outline-hidden`}>
{({ close }) => (
<>
<Heading className={tw`text-base leading-5 font-semibold tracking-tight text-slate-800`} slot='title'>
Folder Sync
</Heading>

<div className={tw`mt-3 flex flex-col gap-3`}>
<div className={tw`flex items-end gap-2`}>
<TextInputField
aria-label='Folder path'
className={tw`flex-1`}
label='Folder Path'
onChange={setFolderPath}
placeholder='/path/to/your/collection'
value={folderPath}
/>
<Button onPress={() => void browseFolder()} variant='secondary'>
<FiFolder className={tw`mr-1 size-4`} />
Browse
</Button>
</div>

<RadioGroup aria-label='Sync format' onChange={(v) => setFormat(v as SyncFormat)} value={format}>
<Label className={tw`text-sm font-medium text-slate-700`}>Format</Label>
<div className={tw`mt-1 flex gap-4`}>
<Radio className={tw`flex cursor-pointer items-center gap-2 text-sm text-slate-700`} value='openyaml'>
<div
className={tw`size-4 rounded-full border-2 border-slate-300 data-[selected]:border-violet-600 data-[selected]:bg-violet-600`}
/>
OpenYAML
</Radio>
<Radio className={tw`flex cursor-pointer items-center gap-2 text-sm text-slate-700`} value='bruno'>
<div
className={tw`size-4 rounded-full border-2 border-slate-300 data-[selected]:border-violet-600 data-[selected]:bg-violet-600`}
/>
Bruno
</Radio>
</div>
</RadioGroup>
</div>

<div className={tw`mt-5 flex justify-end gap-2`}>
{currentEnabled && (
<Button
isPending={isPending}
onPress={() => {
disableSync();
close();
}}
variant='danger'
>
Disable Sync
</Button>
)}
<div className={tw`flex-1`} />
<Button onPress={() => void close()}>Cancel</Button>
<Button
isDisabled={!folderPath}
isPending={isPending}
onPress={() => {
enableSync();
close();
}}
variant='primary'
>
{currentEnabled ? 'Update Sync' : 'Enable Sync'}
</Button>
</div>
</>
)}
</Dialog>
);
};

// --- Import from Folder Dialog (creates new workspace) ---

export const useImportFolderDialog = () => {
const modal = useProgrammaticModal();

const open = (): void => void modal.onOpenChange(true, <ImportFolderDialogContent />);

const render: ReactNode = modal.children && <Modal {...modal} className={tw`h-auto`} size='sm' />;

return { open, render };
};

const ImportFolderDialogContent = () => {
const workspaceCollection = useApiCollection(WorkspaceCollectionSchema);

const [folderPath, setFolderPath] = useState('');
const [workspaceName, setWorkspaceName] = useState('');
const [format, setFormat] = useState<SyncFormat>('openyaml');
const [isPending, startTransition] = useTransition();

const browseFolder = async () => {
if (!window.electron?.dialog) return;
const result = await window.electron.dialog('showOpenDialog', {
properties: ['openDirectory'],
title: 'Select collection folder',
});
if (!result.canceled && result.filePaths[0]) {
const path = result.filePaths[0];
setFolderPath(path);
// Auto-fill name from folder name if empty
if (!workspaceName) {
const folderName = path.split('/').pop() ?? path.split('\\').pop() ?? '';
setWorkspaceName(folderName);
}
}
};

const importFolder = () =>
startTransition(async () => {
const name = workspaceName || folderPath.split('/').pop() || 'Imported Workspace';
await workspaceCollection.utils.insert({
name,
order: await getNextOrder(workspaceCollection),
workspaceId: Ulid.generate().bytes,
syncPath: folderPath,
syncFormat: format,
syncEnabled: true,
});
});

return (
<Dialog className={tw`flex flex-col p-5 outline-hidden`}>
{({ close }) => (
<>
<Heading className={tw`text-base leading-5 font-semibold tracking-tight text-slate-800`} slot='title'>
Import from Folder
</Heading>

<div className={tw`mt-1 text-sm leading-5 text-slate-500`}>
Create a workspace synced to a local folder. Changes in the folder will automatically appear in DevTools.
</div>

<div className={tw`mt-4 flex flex-col gap-3`}>
<div className={tw`flex items-end gap-2`}>
<TextInputField
aria-label='Folder path'
className={tw`flex-1`}
label='Folder Path'
onChange={setFolderPath}
placeholder='/path/to/your/collection'
value={folderPath}
/>
<Button onPress={() => void browseFolder()} variant='secondary'>
<FiFolder className={tw`mr-1 size-4`} />
Browse
</Button>
</div>

<TextInputField
aria-label='Workspace name'
label='Workspace Name'
onChange={setWorkspaceName}
placeholder='My Collection'
value={workspaceName}
/>

<RadioGroup aria-label='Collection format' onChange={(v) => setFormat(v as SyncFormat)} value={format}>
<Label className={tw`text-sm font-medium text-slate-700`}>Format</Label>
<div className={tw`mt-1 flex gap-4`}>
<Radio className={tw`flex cursor-pointer items-center gap-2 text-sm text-slate-700`} value='openyaml'>
<div
className={tw`size-4 rounded-full border-2 border-slate-300 data-[selected]:border-violet-600 data-[selected]:bg-violet-600`}
/>
OpenYAML
</Radio>
<Radio className={tw`flex cursor-pointer items-center gap-2 text-sm text-slate-700`} value='bruno'>
<div
className={tw`size-4 rounded-full border-2 border-slate-300 data-[selected]:border-violet-600 data-[selected]:bg-violet-600`}
/>
Bruno
</Radio>
</div>
</RadioGroup>
</div>

<div className={tw`mt-5 flex justify-end gap-2`}>
<Button onPress={() => void close()}>Cancel</Button>
<Button
isDisabled={!folderPath}
isPending={isPending}
onPress={() => {
importFolder();
close();
}}
variant='primary'
>
Import
</Button>
</div>
</>
)}
</Dialog>
);
};
Loading
Loading