diff --git a/CHANGELOG.md b/CHANGELOG.md index f9f9ca6..2a6284c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,47 @@ See [RELEASING.md](RELEASING.md) for the release process. ### Added +- **WebSocket requests**: a new request type that opens a live ws/wss connection + (routed through the server, so `{{variables}}`, headers, auth, and the cookie + jar apply), with a message log, a composer, and reusable saved messages. + Backed by a new multiplexed live channel (`/live`) shared by future streaming + protocols. +- **Socket.IO requests**: connect to a Socket.IO server (routed through the + server like WebSocket, with a configurable handshake path, JSON `auth` + payload, query parameters, and an events filter), emit named events with JSON + arguments, and watch incoming events in the live log, with reusable saved + emits. +- **MCP requests**: connect to an external MCP server over Streamable HTTP + (routed through the server, reusing headers and auth), browse its tools, fill + in a tool's arguments via a form generated from its JSON Schema, call it, and + view the result. STDIO transport is not supported yet. +- **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). +- **Drag-and-drop** to move requests and folders between folders and + collections in the sidebar (moving a folder takes its contents along). +- **Batch import**: the sidebar import button now offers *Import file…* or + *Import folder…* — the latter recursively imports every Postman + collection/environment export found in a chosen folder (skipping anything + that isn't a recognised export). +- **Saved responses**: each request now keeps its last 3 responses, so reopening + a request shows its recent results (with a switcher to step back through them). + Responses are a local cache stored beside each request and are gitignored + (they can contain tokens/PII), like secret environment values. - 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** button returns to preview. +### Changed + +- Switching between requests/collections no longer prompts about unsaved + changes; unsaved edits are discarded silently on navigation. The + unsaved-changes warning now appears only when closing the browser tab. + ## [1.0.0] - 2026-06-17 First tagged release. diff --git a/client/src/App.tsx b/client/src/App.tsx index 4955937..f7a04be 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,16 @@ 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 { ConnectionEditor } from './components/ConnectionEditor'; +import { McpEditor } from './components/McpEditor'; +import type { + ApiRequest, + Collection, + Folder, + RequestType, + WorkspaceMeta, + WorkspaceTree, +} from './types'; import { VariablesContext, type VariablesInfo } from './variables'; const LAST_WORKSPACE_KEY = 'apinotebook.lastWorkspace'; @@ -60,16 +69,9 @@ export default function App() { setEditorDirty(dirty); }, []); - /** True when it is OK to discard the current editor state. */ - function confirmDiscard(): boolean { - return ( - !editorDirtyRef.current || - confirm('You have unsaved changes that will be lost. Continue?') - ); - } - - function selectGuarded(sel: Selection) { - if (!confirmDiscard()) return; + // Navigating between requests/collections discards unsaved edits silently — + // the only unsaved-changes warning is the browser tab-close prompt below. + function selectItem(sel: Selection) { handleDirtyChange(false); setSelection(sel); } @@ -169,13 +171,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 +266,7 @@ export default function App() { }); } - function promptNewRequest(collectionId: string) { + function promptNewRequest(collectionId: string, folderPath: string[]) { if (!workspaceId) return; setPrompt({ title: 'New Request', @@ -250,21 +279,30 @@ export default function App() { options: [ { value: 'http', label: 'HTTP' }, { value: 'graphql', label: 'GraphQL' }, + { value: 'websocket', label: 'WebSocket' }, + { value: 'socketio', label: 'Socket.IO' }, + { value: 'mcp', label: 'MCP (HTTP)' }, ], }, ], onSubmit: async (values) => { + const allowed: RequestType[] = ['http', 'graphql', 'websocket', 'socketio', 'mcp']; + const type = (allowed as string[]).includes(values.type) + ? (values.type as RequestType) + : 'http'; const request = await api.createRequest( workspaceId, collectionId, + folderPath, values.name, - values.type === 'graphql' ? 'graphql' : 'http' + type ); await loadTree(workspaceId, true); if (!editorDirtyRef.current) { setSelection({ kind: 'request', collectionId, + folderPath, requestId: request.id, }); } @@ -272,6 +310,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 +349,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 +363,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 +372,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 +416,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 +429,7 @@ export default function App() { setSelection({ kind: 'request', collectionId, + folderPath, requestId: created.id, }); } @@ -373,8 +450,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' @@ -385,8 +464,39 @@ export default function App() { } } + async function importPostmanFolder() { + if (!workspaceId) return; + try { + const picked = await api.pickFolder( + 'Choose a folder of Postman exports to import' + ); + if (!picked.path) return; + const r = await api.importPostmanDir(workspaceId, picked.path); + await loadTree(workspaceId, true); + if (r.collections === 0 && r.environments === 0) { + setToast( + r.files === 0 + ? 'No JSON files found in that folder.' + : `No Postman exports found (skipped ${r.skipped} file${r.skipped === 1 ? '' : 's'}).` + ); + return; + } + const parts: string[] = []; + if (r.collections) + parts.push(`${r.collections} collection${r.collections === 1 ? '' : 's'} (${r.requests} request${r.requests === 1 ? '' : 's'})`); + if (r.environments) + parts.push(`${r.environments} environment${r.environments === 1 ? '' : 's'}`); + setToast( + `Imported ${parts.join(' and ')}${r.skipped ? `; skipped ${r.skipped}` : ''}.` + ); + } catch (err) { + showError(err); + } + } + async function copyRequestInto( collectionId: string, + folderPath: string[], source: ApiRequest, name: string ) { @@ -395,10 +505,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 +520,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,26 +544,94 @@ 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); + 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) { + showError(err); + } + } + + async function moveRequestAction( + from: { collectionId: string; folderPath: string[]; requestId: string }, + to: { collectionId: string; folderPath: string[] } + ) { + if (!workspaceId) return; + try { + const result = await api.moveRequest(workspaceId, from, to); + await loadTree(workspaceId, true); setSelection((sel) => - (sel?.kind === 'collection' || sel?.kind === 'request') && - sel.collectionId === collection.id - ? null + sel?.kind === 'request' && + sel.collectionId === from.collectionId && + sel.requestId === from.requestId && + sel.folderPath.join('/') === from.folderPath.join('/') + ? { + kind: 'request', + collectionId: result.collectionId, + folderPath: result.folderPath, + requestId: result.requestId, + } : sel ); - handleDirtyChange(false); + } catch (err) { + showError(err); + } + } + + async function moveFolderAction( + from: { collectionId: string; folderPath: string[] }, + to: { collectionId: string; folderPath: string[] } + ) { + if (!workspaceId) return; + try { + const result = await api.moveFolder(workspaceId, from, to); await loadTree(workspaceId, true); + // Remap a selection that pointed inside the moved subtree to its new path. + setSelection((sel) => { + if (!sel || sel.kind === 'environment' || sel.kind === 'collection') { + return sel; + } + if (sel.collectionId !== from.collectionId) return sel; + const fromKey = from.folderPath.join('/'); + const selKey = sel.folderPath.join('/'); + if (selKey !== fromKey && !selKey.startsWith(fromKey + '/')) return sel; + const suffix = sel.folderPath.slice(from.folderPath.length); + return { + ...sel, + collectionId: result.collectionId, + folderPath: [...result.folderPath, ...suffix], + }; + }); } catch (err) { showError(err); } @@ -486,21 +670,54 @@ 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) { + const key = `${collectionId}/${folderPath.join('/')}/${request.id}`; + if (request.type === 'mcp') { + return ( + loadTree(tree.meta.id, true)} + onDelete={() => + deleteRequestAction(collectionId, folderPath, request) + } + onDirtyChange={handleDirtyChange} + /> + ); + } + if (request.type === 'websocket' || request.type === 'socketio') { + return ( + loadTree(tree.meta.id, true)} + onDelete={() => + deleteRequestAction(collectionId, folderPath, request) + } + onDirtyChange={handleDirtyChange} + /> + ); + } 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 +726,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} /> ); @@ -580,7 +821,6 @@ export default function App() { workspaces={workspaces} currentWorkspaceId={workspaceId} onSelectWorkspace={(id) => { - if (!confirmDiscard()) return; handleDirtyChange(false); void loadTree(id); }} @@ -602,41 +842,40 @@ 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} + onMoveRequest={moveRequestAction} + onMoveFolder={moveFolderAction} + onSelect={selectItem} onNewCollection={promptNewCollection} onNewEnvironment={promptNewEnvironment} - onImportPostman={importPostman} + onImportFile={importPostman} + onImportFolder={importPostmanFolder} />
+ 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,81 @@ 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' } + ), + + moveRequest: ( + id: string, + from: { collectionId: string; folderPath: string[]; requestId: string }, + to: { collectionId: string; folderPath: string[] } + ) => + http<{ collectionId: string; folderPath: string[]; requestId: string }>( + `/api/workspaces/${id}/move`, + json({ + kind: 'request', + from: { ...from, folderPath: from.folderPath.join('/') }, + to: { ...to, folderPath: to.folderPath.join('/') }, + }) + ), + + moveFolder: ( + id: string, + from: { collectionId: string; folderPath: string[] }, + to: { collectionId: string; folderPath: string[] } + ) => + http<{ collectionId: string; folderPath: string[] }>( + `/api/workspaces/${id}/move`, + json({ + kind: 'folder', + from: { ...from, folderPath: from.folderPath.join('/') }, + to: { ...to, folderPath: to.folderPath.join('/') }, + }) + ), + + 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 +166,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 +197,26 @@ export const api = { json({ path }) ), - execute: (id: string, request: ApiRequest, collectionId: string) => + importPostmanDir: (id: string, path: string) => + http( + `/api/workspaces/${id}/import/postman-dir`, + json({ path }) + ), + + execute: ( + id: string, + request: ApiRequest, + collectionId: string, + folderPath: string[] = [] + ) => http( `/api/workspaces/${id}/execute`, - json({ request, collectionId }) + json({ request, collectionId, folderPath: folderPath.join('/') }) + ), + + getResponses: (id: string, cid: string, folderPath: string[], rid: string) => + http( + `/api/workspaces/${id}/collections/${cid}/requests/${rid}/responses${fpQuery(folderPath)}` ), listCookies: (id: string) => @@ -152,5 +242,16 @@ 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 }; + +export interface BatchImportResult { + kind: 'batch'; + files: number; + collections: number; + folders: number; + requests: number; + environments: number; + variables: number; + skipped: number; +} diff --git a/client/src/components/ConnectionEditor.tsx b/client/src/components/ConnectionEditor.tsx new file mode 100644 index 0000000..5f55f86 --- /dev/null +++ b/client/src/components/ConnectionEditor.tsx @@ -0,0 +1,397 @@ +import { useEffect, useRef, useState } from 'react'; +import { api } from '../api'; +import { createLiveSession, type LiveSession } from '../live'; +import type { ApiRequest, SavedMessage } from '../types'; +import { AuthEditor } from './AuthEditor'; +import { DocsEditor } from './DocsEditor'; +import { KeyValueEditor } from './KeyValueEditor'; +import { MessageLogPanel, type LogEntry } from './MessageLogPanel'; +import { VarField } from './VarField'; + +interface Props { + workspaceId: string; + collectionId: string; + folderPath: string[]; + request: ApiRequest; + onSaved: (request: ApiRequest) => void; + onDelete: () => void; + onDirtyChange: (dirty: boolean) => void; +} + +type Tab = 'connection' | 'headers' | 'auth' | 'messages' | 'docs'; +type Status = 'idle' | 'connecting' | 'open' | 'closed' | 'error'; + +let logSeq = 0; +const entry = (dir: LogEntry['dir'], text: string): LogEntry => ({ + id: `${Date.now()}-${logSeq++}`, + dir, + text, + ts: Date.now(), +}); + +export function ConnectionEditor({ + workspaceId, + collectionId, + folderPath, + request, + onSaved, + onDelete, + onDirtyChange, +}: Props) { + const [saved, setSaved] = useState(request); + const [draft, setDraft] = useState(request); + const [tab, setTab] = useState('connection'); + const [status, setStatus] = useState('idle'); + const [entries, setEntries] = useState([]); + const [saveError, setSaveError] = useState(null); + const [eventName, setEventName] = useState(''); + const sessionRef = useRef(null); + + const isIo = draft.type === 'socketio'; + const dirty = JSON.stringify(draft) !== JSON.stringify(saved); + const connected = status === 'open'; + const url = isIo ? draft.socketio.url : draft.websocket.url; + + useEffect(() => { + onDirtyChange(dirty); + }, [dirty, onDirtyChange]); + + // Close the live connection when navigating away / unmounting. + useEffect( + () => () => { + sessionRef.current?.close(); + sessionRef.current = null; + onDirtyChange(false); + }, + [onDirtyChange] + ); + + const patch = (changes: Partial) => + setDraft((d) => ({ ...d, ...changes })); + const patchWs = (changes: Partial) => + setDraft((d) => ({ ...d, websocket: { ...d.websocket, ...changes } })); + const patchIo = (changes: Partial) => + setDraft((d) => ({ ...d, socketio: { ...d.socketio, ...changes } })); + + const log = (e: LogEntry) => setEntries((prev) => [...prev, e]); + + async function save() { + setSaveError(null); + try { + await api.updateRequest(workspaceId, collectionId, folderPath, draft); + setSaved(draft); + onSaved(draft); + } catch (err) { + setSaveError(err instanceof Error ? err.message : String(err)); + } + } + + function connect() { + if (sessionRef.current) return; + setStatus('connecting'); + log(entry('system', `Connecting to ${url}…`)); + sessionRef.current = createLiveSession({ + workspaceId, + collectionId, + folderPath, + request: draft, + onOpen: () => { + setStatus('open'); + log(entry('system', 'Connected.')); + }, + onMessage: (payload) => { + if (isIo) { + const p = payload as { event?: string; args?: unknown[] }; + const args = p?.args ?? []; + log(entry('in', `${p?.event ?? ''} ${args.map((a) => JSON.stringify(a)).join(', ')}`.trim())); + } else { + const p = payload as { data?: string; binary?: boolean }; + log(entry('in', p?.binary ? `[binary ${p.data?.length ?? 0}b base64] ${p.data ?? ''}` : p?.data ?? '')); + } + }, + onError: (message) => { + setStatus('error'); + log(entry('system', `Error: ${message}`)); + }, + onClosed: (payload) => { + const p = payload as { code?: number; reason?: string } | undefined; + setStatus('closed'); + log(entry('system', `Closed${p?.code ? ` (${p.code})` : ''}${p?.reason ? `: ${p.reason}` : ''}`)); + sessionRef.current = null; + }, + }); + } + + function disconnect() { + sessionRef.current?.close(); + sessionRef.current = null; + setStatus('closed'); + log(entry('system', 'Disconnected.')); + } + + function sendMessage(text: string) { + if (isIo) { + const event = eventName.trim(); + if (!event) return; + let args: unknown[]; + try { + const parsed = text.trim() ? JSON.parse(text) : []; + args = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + args = [text]; + } + sessionRef.current?.send({ event, args }); + log(entry('out', `${event} ${text}`.trim())); + } else { + sessionRef.current?.send({ data: text }); + log(entry('out', text)); + } + } + + useEffect(() => { + function onKeyDown(e: KeyboardEvent) { + if ((e.metaKey || e.ctrlKey) && e.key === 's') { + e.preventDefault(); + void save(); + } + } + window.addEventListener('keydown', onKeyDown); + return () => window.removeEventListener('keydown', onKeyDown); + }); + + const savedMessages = isIo ? draft.socketio.emitEvents : draft.websocket.messages; + + return ( +
+
+ {isIo ? 'IO' : 'WS'} + patch({ name: e.target.value })} + /> + + +
+ {saveError &&

{saveError}

} + +
+ {status} + (isIo ? patchIo({ url: value }) : patchWs({ url: value }))} + /> + {connected || status === 'connecting' ? ( + + ) : ( + + )} +
+ +
+ {( + [ + ['connection', 'Connection'], + ['headers', 'Headers'], + ['auth', 'Auth'], + ['messages', isIo ? 'Saved Emits' : 'Saved Messages'], + ['docs', 'Docs'], + ] as [Tab, string][] + ).map(([value, label]) => ( + + ))} +
+ +
+ {tab === 'connection' && + (isIo ? ( +
+

+ The path in the URL above is the Socket.IO{' '} + namespace — e.g.{' '} + wss://ws.postman-echo.com/socketio joins the{' '} + /socketio namespace. +

+ +