From da382ba0450a5581ac6dc7443e5b33853deb12f2 Mon Sep 17 00:00:00 2001 From: Tapiwa Muzira Date: Wed, 17 Jun 2026 14:11:08 +0200 Subject: [PATCH 1/8] Add nestable Postman-style folders within collections (storage, execution chain, import, MCP, UI, tests). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 6 + client/src/App.tsx | 256 ++++++++++----- client/src/api.ts | 73 ++++- .../{CollectionEditor.tsx => NodeEditor.tsx} | 58 ++-- client/src/components/RequestEditor.tsx | 6 +- client/src/components/Sidebar.tsx | 297 +++++++++++------- client/src/selection.ts | 8 +- client/src/styles.css | 6 +- client/src/types.ts | 11 + server/src/importer.ts | 73 ++++- server/src/mcp.ts | 172 ++++++++-- server/src/postmanImport.test.ts | 46 ++- server/src/postmanImport.ts | 102 +++--- server/src/router.integration.test.ts | 130 ++++++++ server/src/router.ts | 54 +++- server/src/types.ts | 11 + server/src/workspaceFs.test.ts | 70 ++++- server/src/workspaceFs.ts | 250 ++++++++++++--- 18 files changed, 1245 insertions(+), 384 deletions(-) rename client/src/components/{CollectionEditor.tsx => NodeEditor.tsx} (65%) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f9ca6..58dcf0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,12 @@ See [RELEASING.md](RELEASING.md) for the release process. ### Added +- **Folders** inside collections, nestable to any depth (Postman-style). + Folders carry their own description and pre-request/test scripts, which run in + the execution chain (collection → folder(s) → request). Postman imports now + preserve their folder structure instead of flattening it into separate + collections. On disk, folders are mirrored as nested directories; pre-folders + workspaces still load (their requests appear at the collection root). - Documentation now has **edit** and **preview** modes (request docs and collection descriptions). Existing docs open in a read-only rendered preview with an **Edit** button; empty docs open straight in the editor; a **Done** diff --git a/client/src/App.tsx b/client/src/App.tsx index 4955937..f778a39 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { api } from './api'; -import { CollectionEditor } from './components/CollectionEditor'; +import { NodeEditor } from './components/NodeEditor'; import { CookieManager } from './components/CookieManager'; import { EnvironmentEditor } from './components/EnvironmentEditor'; import { PromptModal, type PromptConfig } from './components/PromptModal'; @@ -9,7 +9,13 @@ import { Sidebar } from './components/Sidebar'; import { TopBar } from './components/TopBar'; import { parseCurl } from './curl'; import type { Selection } from './selection'; -import type { ApiRequest, WorkspaceMeta, WorkspaceTree } from './types'; +import type { + ApiRequest, + Collection, + Folder, + WorkspaceMeta, + WorkspaceTree, +} from './types'; import { VariablesContext, type VariablesInfo } from './variables'; const LAST_WORKSPACE_KEY = 'apinotebook.lastWorkspace'; @@ -169,13 +175,40 @@ export default function App() { window.addEventListener('mouseup', onUp); } + function findCollection(collectionId: string): Collection | undefined { + return tree?.collections.find((c) => c.id === collectionId); + } + + /** The container at a folder path — the collection itself when path is []. */ + function findNode( + collectionId: string, + folderPath: string[] + ): { folders: Folder[]; requests: ApiRequest[] } | undefined { + let node: { folders: Folder[]; requests: ApiRequest[] } | undefined = + findCollection(collectionId); + for (const slug of folderPath) { + node = node?.folders.find((f) => f.id === slug); + if (!node) return undefined; + } + return node; + } + + function findFolder( + collectionId: string, + folderPath: string[] + ): Folder | undefined { + if (folderPath.length === 0) return undefined; + return findNode(collectionId, folderPath) as Folder | undefined; + } + function findRequest( collectionId: string, + folderPath: string[], requestId: string ): ApiRequest | undefined { - return tree?.collections - .find((c) => c.id === collectionId) - ?.requests.find((r) => r.id === requestId); + return findNode(collectionId, folderPath)?.requests.find( + (r) => r.id === requestId + ); } // ----- workspace prompts ----- @@ -237,7 +270,7 @@ export default function App() { }); } - function promptNewRequest(collectionId: string) { + function promptNewRequest(collectionId: string, folderPath: string[]) { if (!workspaceId) return; setPrompt({ title: 'New Request', @@ -257,6 +290,7 @@ export default function App() { const request = await api.createRequest( workspaceId, collectionId, + folderPath, values.name, values.type === 'graphql' ? 'graphql' : 'http' ); @@ -265,6 +299,7 @@ export default function App() { setSelection({ kind: 'request', collectionId, + folderPath, requestId: request.id, }); } @@ -272,6 +307,30 @@ export default function App() { }); } + function promptNewFolder(collectionId: string, parentPath: string[]) { + if (!workspaceId) return; + setPrompt({ + title: 'New Folder', + fields: [{ name: 'name', label: 'Name', placeholder: 'Admin' }], + onSubmit: async (values) => { + const folder = await api.createFolder( + workspaceId, + collectionId, + parentPath, + values.name + ); + await loadTree(workspaceId, true); + if (!editorDirtyRef.current) { + setSelection({ + kind: 'folder', + collectionId, + folderPath: [...parentPath, folder.id], + }); + } + }, + }); + } + function promptNewEnvironment() { if (!workspaceId) return; setPrompt({ @@ -287,8 +346,12 @@ export default function App() { }); } - function promptRenameRequest(collectionId: string, requestId: string) { - const request = findRequest(collectionId, requestId); + function promptRenameRequest( + collectionId: string, + folderPath: string[], + requestId: string + ) { + const request = findRequest(collectionId, folderPath, requestId); if (!request || !workspaceId) return; setPrompt({ title: 'Rename Request', @@ -297,7 +360,7 @@ export default function App() { onSubmit: async (values) => { const name = values.name.trim(); if (!name) throw new Error('Name is required'); - await api.updateRequest(workspaceId, collectionId, { + await api.updateRequest(workspaceId, collectionId, folderPath, { ...request, name, }); @@ -306,23 +369,32 @@ export default function App() { }); } - function promptRenameCollection(collectionId: string) { - const collection = tree?.collections.find((c) => c.id === collectionId); - if (!collection || !workspaceId) return; + /** Renames a collection (folderPath []) or a folder. */ + function promptRenameNode(collectionId: string, folderPath: string[]) { + if (!workspaceId) return; + const isFolder = folderPath.length > 0; + const node = isFolder + ? findFolder(collectionId, folderPath) + : findCollection(collectionId); + if (!node) return; setPrompt({ - title: 'Rename Collection', + title: isFolder ? 'Rename Folder' : 'Rename Collection', submitLabel: 'Rename', - fields: [{ name: 'name', label: 'Name', initial: collection.name }], + fields: [{ name: 'name', label: 'Name', initial: node.name }], onSubmit: async (values) => { const name = values.name.trim(); if (!name) throw new Error('Name is required'); - await api.updateCollection(workspaceId, collectionId, { name }); + if (isFolder) { + await api.updateFolder(workspaceId, collectionId, folderPath, { name }); + } else { + await api.updateCollection(workspaceId, collectionId, { name }); + } await loadTree(workspaceId, true); }, }); } - function promptImportCurl(collectionId: string) { + function promptImportCurl(collectionId: string, folderPath: string[]) { if (!workspaceId) return; setPrompt({ title: 'Import cURL', @@ -341,10 +413,11 @@ export default function App() { const created = await api.createRequest( workspaceId, collectionId, + folderPath, parsed.name, 'http' ); - await api.updateRequest(workspaceId, collectionId, { + await api.updateRequest(workspaceId, collectionId, folderPath, { ...parsed, id: created.id, }); @@ -353,6 +426,7 @@ export default function App() { setSelection({ kind: 'request', collectionId, + folderPath, requestId: created.id, }); } @@ -373,8 +447,10 @@ export default function App() { result.kind === 'collection' ? `Imported ${result.requests} request${ result.requests === 1 ? '' : 's' - } into ${result.collections} collection${ - result.collections === 1 ? '' : 's' + }${ + result.folders + ? ` across ${result.folders} folder${result.folders === 1 ? '' : 's'}` + : '' }.` : `Imported environment "${result.name}" with ${result.variables} variable${ result.variables === 1 ? '' : 's' @@ -387,6 +463,7 @@ export default function App() { async function copyRequestInto( collectionId: string, + folderPath: string[], source: ApiRequest, name: string ) { @@ -395,10 +472,11 @@ export default function App() { const created = await api.createRequest( workspaceId, collectionId, + folderPath, name, source.type ); - await api.updateRequest(workspaceId, collectionId, { + await api.updateRequest(workspaceId, collectionId, folderPath, { ...structuredClone(source), id: created.id, name, @@ -409,15 +487,20 @@ export default function App() { } } - async function deleteRequestAction(collectionId: string, request: ApiRequest) { + async function deleteRequestAction( + collectionId: string, + folderPath: string[], + request: ApiRequest + ) { if (!workspaceId) return; if (!confirm(`Delete request "${request.name}"?`)) return; try { - await api.deleteRequest(workspaceId, collectionId, request.id); + await api.deleteRequest(workspaceId, collectionId, folderPath, request.id); setSelection((sel) => sel?.kind === 'request' && sel.collectionId === collectionId && - sel.requestId === request.id + sel.requestId === request.id && + sel.folderPath.join('/') === folderPath.join('/') ? null : sel ); @@ -428,24 +511,37 @@ export default function App() { } } - async function deleteCollectionAction(collection: { - id: string; - name: string; - }) { + /** Deletes a collection (folderPath []) or a folder, with its contents. */ + async function deleteNode(collectionId: string, folderPath: string[]) { if (!workspaceId) return; - if ( - !confirm(`Delete collection "${collection.name}" and all its requests?`) - ) { + const isFolder = folderPath.length > 0; + const node = isFolder + ? findFolder(collectionId, folderPath) + : findCollection(collectionId); + if (!node) return; + const label = isFolder ? 'folder' : 'collection'; + if (!confirm(`Delete ${label} "${node.name}" and everything inside it?`)) { return; } try { - await api.deleteCollection(workspaceId, collection.id); - setSelection((sel) => - (sel?.kind === 'collection' || sel?.kind === 'request') && - sel.collectionId === collection.id - ? null - : sel - ); + if (isFolder) { + await api.deleteFolder(workspaceId, collectionId, folderPath); + } else { + await api.deleteCollection(workspaceId, collectionId); + } + // Clear the selection if it pointed inside the deleted node. + setSelection((sel) => { + if (!sel || sel.kind === 'environment') return sel; + if (sel.collectionId !== collectionId) return sel; + const selPath = + sel.kind === 'collection' ? [] : sel.folderPath; + const prefix = folderPath.join('/'); + const within = + selPath.join('/') === prefix || + selPath.join('/').startsWith(prefix + '/') || + (!isFolder); + return within ? null : sel; + }); handleDirtyChange(false); await loadTree(workspaceId, true); } catch (err) { @@ -486,21 +582,21 @@ export default function App() { } if (selection?.kind === 'request') { - const collection = tree.collections.find( - (c) => c.id === selection.collectionId - ); - const request = collection?.requests.find( - (r) => r.id === selection.requestId - ); + const { collectionId, folderPath, requestId } = selection; + const collection = findCollection(collectionId); + const request = findRequest(collectionId, folderPath, requestId); if (collection && request) { return ( loadTree(tree.meta.id, true)} - onDelete={() => deleteRequestAction(collection.id, request)} + onDelete={() => + deleteRequestAction(collectionId, folderPath, request) + } onDirtyChange={handleDirtyChange} onVariablesChanged={() => loadTree(tree.meta.id, true)} /> @@ -509,19 +605,43 @@ export default function App() { } if (selection?.kind === 'collection') { - const collection = tree.collections.find( - (c) => c.id === selection.collectionId - ); + const collection = findCollection(selection.collectionId); if (collection) { return ( - { await api.updateCollection(tree.meta.id, collection.id, changes); await loadTree(tree.meta.id, true); }} - onDelete={() => deleteCollectionAction(collection)} + onDelete={() => deleteNode(collection.id, [])} + onDirtyChange={handleDirtyChange} + /> + ); + } + } + + if (selection?.kind === 'folder') { + const { collectionId, folderPath } = selection; + const folder = findFolder(collectionId, folderPath); + if (folder) { + return ( + { + await api.updateFolder( + tree.meta.id, + collectionId, + folderPath, + changes + ); + await loadTree(tree.meta.id, true); + }} + onDelete={() => deleteNode(collectionId, folderPath)} onDirtyChange={handleDirtyChange} /> ); @@ -602,36 +722,32 @@ export default function App() { canPaste={clipboard !== null} requestActions={{ onRename: promptRenameRequest, - onCopy: (cid, rid) => { - const request = findRequest(cid, rid); + onCopy: (cid, fp, rid) => { + const request = findRequest(cid, fp, rid); if (request) setClipboard(structuredClone(request)); }, - onDuplicate: (cid, rid) => { - const request = findRequest(cid, rid); + onDuplicate: (cid, fp, rid) => { + const request = findRequest(cid, fp, rid); if (request) { - void copyRequestInto(cid, request, `${request.name} copy`); + void copyRequestInto(cid, fp, request, `${request.name} copy`); } }, - onDelete: (cid, rid) => { - const request = findRequest(cid, rid); - if (request) void deleteRequestAction(cid, request); + onDelete: (cid, fp, rid) => { + const request = findRequest(cid, fp, rid); + if (request) void deleteRequestAction(cid, fp, request); }, }} - collectionActions={{ + nodeActions={{ onNewRequest: promptNewRequest, - onRename: promptRenameCollection, - onPasteRequest: (cid) => { + onNewFolder: promptNewFolder, + onRename: promptRenameNode, + onPasteRequest: (cid, fp) => { if (clipboard) { - void copyRequestInto(cid, clipboard, clipboard.name); + void copyRequestInto(cid, fp, clipboard, clipboard.name); } }, onImportCurl: promptImportCurl, - onDelete: (cid) => { - const collection = tree.collections.find( - (c) => c.id === cid - ); - if (collection) void deleteCollectionAction(collection); - }, + onDelete: deleteNode, }} onSelect={selectGuarded} onNewCollection={promptNewCollection} diff --git a/client/src/api.ts b/client/src/api.ts index 4458716..30207d2 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -3,6 +3,7 @@ import type { Collection, Environment, ExecutionResult, + Folder, KeyValue, RequestType, Scripts, @@ -10,6 +11,12 @@ import type { WorkspaceTree, } from './types'; +/** Encodes a folder path (slugs, outer→inner) as a ?folderPath= query string. */ +const fpQuery = (folderPath: string[]): string => + folderPath.length + ? `?folderPath=${encodeURIComponent(folderPath.join('/'))}` + : ''; + async function http(path: string, init?: RequestInit): Promise { const res = await fetch(path, { headers: { 'content-type': 'application/json' }, @@ -77,15 +84,53 @@ export const api = { method: 'DELETE', }), - createRequest: (id: string, cid: string, name: string, type: RequestType) => + createFolder: (id: string, cid: string, parentPath: string[], name: string) => + http( + `/api/workspaces/${id}/collections/${cid}/folders`, + json({ name, folderPath: parentPath.join('/') }) + ), + + updateFolder: ( + id: string, + cid: string, + folderPath: string[], + changes: { name?: string; description?: string; scripts?: Scripts } + ) => + http( + `/api/workspaces/${id}/collections/${cid}/folders${fpQuery(folderPath)}`, + { + method: 'PATCH', + body: JSON.stringify(changes), + headers: { 'content-type': 'application/json' }, + } + ), + + deleteFolder: (id: string, cid: string, folderPath: string[]) => + http( + `/api/workspaces/${id}/collections/${cid}/folders${fpQuery(folderPath)}`, + { method: 'DELETE' } + ), + + createRequest: ( + id: string, + cid: string, + folderPath: string[], + name: string, + type: RequestType + ) => http( `/api/workspaces/${id}/collections/${cid}/requests`, - json({ name, type }) + json({ name, type, folderPath: folderPath.join('/') }) ), - updateRequest: (id: string, cid: string, request: ApiRequest) => + updateRequest: ( + id: string, + cid: string, + folderPath: string[], + request: ApiRequest + ) => http( - `/api/workspaces/${id}/collections/${cid}/requests/${request.id}`, + `/api/workspaces/${id}/collections/${cid}/requests/${request.id}${fpQuery(folderPath)}`, { method: 'PUT', body: JSON.stringify(request), @@ -93,10 +138,11 @@ export const api = { } ), - deleteRequest: (id: string, cid: string, rid: string) => - http(`/api/workspaces/${id}/collections/${cid}/requests/${rid}`, { - method: 'DELETE', - }), + deleteRequest: (id: string, cid: string, folderPath: string[], rid: string) => + http( + `/api/workspaces/${id}/collections/${cid}/requests/${rid}${fpQuery(folderPath)}`, + { method: 'DELETE' } + ), createEnvironment: (id: string, name: string) => http(`/api/workspaces/${id}/environments`, json({ name })), @@ -123,10 +169,15 @@ export const api = { json({ path }) ), - execute: (id: string, request: ApiRequest, collectionId: string) => + execute: ( + id: string, + request: ApiRequest, + collectionId: string, + folderPath: string[] = [] + ) => http( `/api/workspaces/${id}/execute`, - json({ request, collectionId }) + json({ request, collectionId, folderPath: folderPath.join('/') }) ), listCookies: (id: string) => @@ -152,5 +203,5 @@ export interface StoredCookie { } export type PostmanImportResult = - | { kind: 'collection'; collections: number; requests: number } + | { kind: 'collection'; collections: number; folders: number; requests: number } | { kind: 'environment'; name: string; variables: number }; diff --git a/client/src/components/CollectionEditor.tsx b/client/src/components/NodeEditor.tsx similarity index 65% rename from client/src/components/CollectionEditor.tsx rename to client/src/components/NodeEditor.tsx index 3be3b55..a49635e 100644 --- a/client/src/components/CollectionEditor.tsx +++ b/client/src/components/NodeEditor.tsx @@ -1,9 +1,17 @@ import { useEffect, useState } from 'react'; -import type { Collection, Scripts } from '../types'; +import type { Scripts } from '../types'; import { DocsEditor } from './DocsEditor'; +/** A collection or folder — both carry a name, description and scripts. */ +export interface EditableNode { + name: string; + description: string; + scripts: Scripts; +} + interface Props { - collection: Collection; + kind: 'collection' | 'folder'; + node: EditableNode; onSave: (changes: { name: string; description: string; @@ -15,23 +23,23 @@ interface Props { type Tab = 'description' | 'preReq' | 'tests'; -export function CollectionEditor({ - collection, - onSave, - onDelete, - onDirtyChange, -}: Props) { - const [name, setName] = useState(collection.name); - const [description, setDescription] = useState(collection.description); - const [scripts, setScripts] = useState(collection.scripts); +/** + * Edits a container node: a collection or a folder. Both run pre-request/test + * scripts around every request they contain (folders nest within the + * collection's chain), so the editor is shared. + */ +export function NodeEditor({ kind, node, onSave, onDelete, onDirtyChange }: Props) { + const [name, setName] = useState(node.name); + const [description, setDescription] = useState(node.description); + const [scripts, setScripts] = useState(node.scripts); const [tab, setTab] = useState('description'); const [error, setError] = useState(null); const dirty = - name !== collection.name || - description !== collection.description || - scripts.preRequest !== collection.scripts.preRequest || - scripts.postResponse !== collection.scripts.postResponse; + name !== node.name || + description !== node.description || + scripts.preRequest !== node.scripts.preRequest || + scripts.postResponse !== node.scripts.postResponse; useEffect(() => { onDirtyChange(dirty); @@ -47,10 +55,14 @@ export function CollectionEditor({ } } + const label = kind === 'collection' ? 'collection' : 'folder'; + return (
- COL + + {kind === 'collection' ? 'COL' : 'DIR'} + ( + ).map(([value, tabLabel]) => ( ))}
@@ -91,20 +103,20 @@ export function CollectionEditor({ )} {tab === 'preReq' && (

- Runs before every request in this collection, ahead of the + Runs before every request in this {label}, ahead of the request's own pre-request script.