From 0bc304af051490c53730e7ec622b1cb68dd90913 Mon Sep 17 00:00:00 2001 From: Andrew Melchor Date: Mon, 2 Mar 2026 16:17:36 -0800 Subject: [PATCH 1/4] feat(web): add request headres draft controller --- .../components/editor/EditorWithExecution.tsx | 22 +- .../src/components/request-workspace/index.ts | 1 + .../request-workspace-tab-panels.tsx | 234 ++++++++++++++++ .../request-workspace-tabs.tsx | 165 ++--------- ...se-request-header-draft-controller.test.ts | 123 ++++++++ .../use-request-header-draft-controller.ts | 180 ++++++++++++ .../use-request-parse-details.ts | 6 +- .../web/src/utils/request-editing.test.ts | 159 +++++++++++ packages/web/src/utils/request-editing.ts | 264 ++++++++++++++++++ 9 files changed, 1016 insertions(+), 138 deletions(-) create mode 100644 packages/web/src/components/request-workspace/request-workspace-tab-panels.tsx create mode 100644 packages/web/src/components/request-workspace/use-request-header-draft-controller.test.ts create mode 100644 packages/web/src/components/request-workspace/use-request-header-draft-controller.ts create mode 100644 packages/web/src/utils/request-editing.test.ts create mode 100644 packages/web/src/utils/request-editing.ts diff --git a/packages/web/src/components/editor/EditorWithExecution.tsx b/packages/web/src/components/editor/EditorWithExecution.tsx index f826eae..cbe7aba 100644 --- a/packages/web/src/components/editor/EditorWithExecution.tsx +++ b/packages/web/src/components/editor/EditorWithExecution.tsx @@ -22,6 +22,7 @@ import { DEFAULT_REQUEST_WORKSPACE_TAB, type RequestWorkspaceTabId, RequestWorkspaceTabs, + useRequestHeaderDraftController, useRequestParseDetails } from '../request-workspace'; import { ScriptPanel } from '../script'; @@ -74,6 +75,17 @@ export const EditorWithExecution: Component = (props) path: () => props.path, requestIndex: () => selectedRequest()?.index }); + const requestHeaderDraft = useRequestHeaderDraftController({ + path: () => props.path, + selectedRequest, + sourceHeaders: requestParseDetails.headers, + sourceUrl: () => selectedRequest()?.url, + getFileContent: () => workspace.fileContents()[props.path]?.content, + setFileContent: (content) => workspace.updateFileContent(props.path, content), + saveFile: (path) => workspace.saveFile(path), + reloadRequests: (path) => workspace.loadRequests(path), + refetchRequestDetails: requestParseDetails.refetch + }); const saveCollapsedState = (collapsed: boolean) => { if (typeof localStorage !== 'undefined') { @@ -213,10 +225,18 @@ export const EditorWithExecution: Component = (props) onTabChange={setActiveRequestTab} selectedRequest={selectedRequest()} requestCount={requests().length} - requestHeaders={requestParseDetails.headers()} + requestHeaders={requestHeaderDraft.draftHeaders()} requestBodySummary={requestParseDetails.bodySummary()} requestDetailsLoading={requestParseDetails.loading()} requestDetailsError={requestParseDetails.error()} + headerDraftDirty={requestHeaderDraft.isDirty()} + headerDraftSaving={requestHeaderDraft.isSaving()} + headerDraftSaveError={requestHeaderDraft.saveError()} + onHeaderChange={requestHeaderDraft.onHeaderChange} + onAddHeader={requestHeaderDraft.onAddHeader} + onRemoveHeader={requestHeaderDraft.onRemoveHeader} + onSaveHeaders={requestHeaderDraft.onSave} + onDiscardHeaders={requestHeaderDraft.onDiscard} />
diff --git a/packages/web/src/components/request-workspace/index.ts b/packages/web/src/components/request-workspace/index.ts index d1f681d..2afb08d 100644 --- a/packages/web/src/components/request-workspace/index.ts +++ b/packages/web/src/components/request-workspace/index.ts @@ -5,4 +5,5 @@ export { type RequestWorkspaceTabId } from './model'; export { RequestWorkspaceTabs } from './request-workspace-tabs'; +export { useRequestHeaderDraftController } from './use-request-header-draft-controller'; export { useRequestParseDetails } from './use-request-parse-details'; diff --git a/packages/web/src/components/request-workspace/request-workspace-tab-panels.tsx b/packages/web/src/components/request-workspace/request-workspace-tab-panels.tsx new file mode 100644 index 0000000..a0f8555 --- /dev/null +++ b/packages/web/src/components/request-workspace/request-workspace-tab-panels.tsx @@ -0,0 +1,234 @@ +import { For, Index, Match, Show, Switch } from 'solid-js'; +import type { RequestBodySummary, RequestDetailsRow } from '../../utils/request-details'; + +interface RequestWorkspaceParamsPanelProps { + requestMethod: string; + requestParams: RequestDetailsRow[]; +} + +interface RequestWorkspaceHeadersPanelProps { + hasRequest: boolean; + requestHeaders: RequestDetailsRow[]; + headerDraftDirty: boolean; + headerDraftSaving: boolean; + headerDraftSaveError?: string; + onHeaderChange: (index: number, field: 'key' | 'value', value: string) => void; + onAddHeader: () => void; + onRemoveHeader: (index: number) => void; + onSaveHeaders: () => void; + onDiscardHeaders: () => void; +} + +interface RequestWorkspaceBodyPanelProps { + requestBodySummary: RequestBodySummary; +} + +export function RequestWorkspaceParamsPanel(props: RequestWorkspaceParamsPanelProps) { + return ( + 0} + fallback={

No query params in URL for {props.requestMethod.toUpperCase()} requests.

} + > +
+ + + + + + + + + + {(param) => ( + + + + + )} + + +
NameValue
{param.key}{param.value}
+
+
+ ); +} + +export function RequestWorkspaceHeadersPanel(props: RequestWorkspaceHeadersPanelProps) { + return ( +
+ + {(message) => ( +
+ {message()} +
+ )} +
+ +
+ + +
+ + Unsaved + + + +
+
+ +
+ + + + + + + + + + 0} + fallback={ + + + + } + > + + {(header, index) => ( + + + + + + )} + + + +
NameValueActions
+ No headers configured for this request. +
+ + props.onHeaderChange(index, 'key', event.currentTarget.value) + } + disabled={!props.hasRequest || props.headerDraftSaving} + /> + + + props.onHeaderChange(index, 'value', event.currentTarget.value) + } + disabled={!props.hasRequest || props.headerDraftSaving} + /> + + +
+
+
+ ); +} + +export function RequestWorkspaceBodyPanel(props: RequestWorkspaceBodyPanelProps) { + return ( +
+

{props.requestBodySummary.description}

+ + + +
+            {props.requestBodySummary.text}
+          
+
+ + + 0} + fallback={

No form-data fields were parsed.

} + > +
+ + + + + + + + + + + {(field) => ( + + + + + + )} + + +
NameTypeValue
{field.name} + {field.isFile ? 'file' : 'text'} + + {field.isFile + ? (field.path ?? field.filename ?? field.value) + : field.value} +
+
+
+
+ + + No request body file path was parsed.

} + > + {(filePath) => ( +
+

{filePath()}

+
+ )} +
+
+
+
+ ); +} diff --git a/packages/web/src/components/request-workspace/request-workspace-tabs.tsx b/packages/web/src/components/request-workspace/request-workspace-tabs.tsx index df23572..24e5efe 100644 --- a/packages/web/src/components/request-workspace/request-workspace-tabs.tsx +++ b/packages/web/src/components/request-workspace/request-workspace-tabs.tsx @@ -3,6 +3,11 @@ import type { WorkspaceRequest } from '../../sdk'; import type { RequestBodySummary, RequestDetailsRow } from '../../utils/request-details'; import { toRequestParams } from '../../utils/request-details'; import { REQUEST_WORKSPACE_TABS, type RequestWorkspaceTabId } from './model'; +import { + RequestWorkspaceBodyPanel, + RequestWorkspaceHeadersPanel, + RequestWorkspaceParamsPanel +} from './request-workspace-tab-panels'; interface RequestWorkspaceTabsProps { activeTab: RequestWorkspaceTabId; @@ -13,6 +18,14 @@ interface RequestWorkspaceTabsProps { requestBodySummary: RequestBodySummary; requestDetailsLoading: boolean; requestDetailsError?: string; + headerDraftDirty: boolean; + headerDraftSaving: boolean; + headerDraftSaveError?: string; + onHeaderChange: (index: number, field: 'key' | 'value', value: string) => void; + onAddHeader: () => void; + onRemoveHeader: (index: number) => void; + onSaveHeaders: () => void; + onDiscardHeaders: () => void; } const TAB_LABELS: Record = { @@ -70,35 +83,10 @@ export function RequestWorkspaceTabs(props: RequestWorkspaceTabsProps) { {(request) => ( - 0} - fallback={ -

No query params in URL for {request().method.toUpperCase()} requests.

- } - > -
- - - - - - - - - - {(param) => ( - - - - - )} - - -
NameValue
{param.key} - {param.value} -
-
-
+
{props.requestDetailsError}

} > - 0} - fallback={

No headers were parsed for this request.

} - > -
- - - - - - - - - - {(header) => ( - - - - - )} - - -
- Name - - Value -
- {header.key} - - {header.value} -
-
-
+
@@ -151,79 +118,7 @@ export function RequestWorkspaceTabs(props: RequestWorkspaceTabsProps) { when={!props.requestDetailsError} fallback={

{props.requestDetailsError}

} > -
-

{props.requestBodySummary.description}

- - - -
-                              {props.requestBodySummary.text}
-                            
-
- - - 0} - fallback={

No form-data fields were parsed.

} - > -
- - - - - - - - - - - {(field) => ( - - - - - - )} - - -
- Name - - Type - - Value -
- {field.name} - - {field.isFile ? 'file' : 'text'} - - {field.isFile - ? (field.path ?? field.filename ?? field.value) - : field.value} -
-
-
-
- - - No request body file path was parsed.

} - > - {(filePath) => ( -
-

{filePath()}

-
- )} -
-
-
-
+ diff --git a/packages/web/src/components/request-workspace/use-request-header-draft-controller.test.ts b/packages/web/src/components/request-workspace/use-request-header-draft-controller.test.ts new file mode 100644 index 0000000..8e8af04 --- /dev/null +++ b/packages/web/src/components/request-workspace/use-request-header-draft-controller.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from 'bun:test'; +import { createRoot, createSignal } from 'solid-js'; +import type { WorkspaceRequest } from '../../sdk'; +import { useRequestHeaderDraftController } from './use-request-header-draft-controller'; + +describe('useRequestHeaderDraftController', () => { + test('tracks header row mutations and dirty state', () => { + createRoot((dispose) => { + const [path] = createSignal('requests.http'); + const [selectedRequest] = createSignal({ + index: 0, + method: 'GET', + url: 'https://api.example.com/users' + }); + const [sourceHeaders] = createSignal([ + { key: 'Accept', value: 'application/json' }, + { key: 'X-Trace-Id', value: 'trace-1' } + ]); + const [fileContent] = createSignal( + [ + 'GET https://api.example.com/users', + 'Accept: application/json', + 'X-Trace-Id: trace-1' + ].join('\n') + ); + + const controller = useRequestHeaderDraftController({ + path, + selectedRequest, + sourceHeaders, + sourceUrl: () => selectedRequest()?.url, + getFileContent: fileContent, + setFileContent: () => {}, + saveFile: async () => {}, + reloadRequests: async () => {}, + refetchRequestDetails: async () => {} + }); + + expect(controller.isDirty()).toBe(false); + expect(controller.draftHeaders()).toEqual(sourceHeaders()); + + controller.onHeaderChange(0, 'value', 'text/plain'); + expect(controller.isDirty()).toBe(true); + expect(controller.draftHeaders()[0]?.value).toBe('text/plain'); + + controller.onAddHeader(); + expect(controller.draftHeaders()).toHaveLength(3); + + controller.onRemoveHeader(1); + expect(controller.draftHeaders()).toEqual([ + { key: 'Accept', value: 'text/plain' }, + { key: '', value: '' } + ]); + + controller.onDiscard(); + expect(controller.isDirty()).toBe(false); + expect(controller.draftHeaders()).toEqual(sourceHeaders()); + + dispose(); + }); + }); + + test('applies header edits through save flow and refetches parse details', async () => { + const setFileContentCalls: string[] = []; + const saveCalls: string[] = []; + const reloadCalls: string[] = []; + let refetchCalls = 0; + + await createRoot(async (dispose) => { + const [path] = createSignal('requests.http'); + const [selectedRequest] = createSignal({ + index: 0, + method: 'GET', + url: 'https://api.example.com/users' + }); + const [sourceHeaders] = createSignal([{ key: 'Accept', value: 'application/json' }]); + const [fileContent, setFileContentSignal] = createSignal( + ['GET https://api.example.com/users', 'Accept: application/json', '', '{"ok":true}'].join( + '\n' + ) + ); + + const controller = useRequestHeaderDraftController({ + path, + selectedRequest, + sourceHeaders, + sourceUrl: () => selectedRequest()?.url, + getFileContent: fileContent, + setFileContent: (content) => { + setFileContentCalls.push(content); + setFileContentSignal(content); + }, + saveFile: async (nextPath) => { + saveCalls.push(nextPath); + }, + reloadRequests: async (nextPath) => { + reloadCalls.push(nextPath); + }, + refetchRequestDetails: async () => { + refetchCalls += 1; + } + }); + + controller.onHeaderChange(0, 'value', 'text/plain'); + controller.onAddHeader(); + controller.onHeaderChange(1, 'key', 'X-Debug'); + controller.onHeaderChange(1, 'value', '1'); + await controller.onSave(); + + expect(controller.saveError()).toBeUndefined(); + expect(controller.isDirty()).toBe(false); + expect(controller.isSaving()).toBe(false); + expect(saveCalls).toEqual(['requests.http']); + expect(reloadCalls).toEqual(['requests.http']); + expect(refetchCalls).toBe(1); + expect(setFileContentCalls).toHaveLength(1); + expect(setFileContentCalls[0]).toContain('Accept: text/plain'); + expect(setFileContentCalls[0]).toContain('X-Debug: 1'); + + dispose(); + }); + }); +}); diff --git a/packages/web/src/components/request-workspace/use-request-header-draft-controller.ts b/packages/web/src/components/request-workspace/use-request-header-draft-controller.ts new file mode 100644 index 0000000..53c8efa --- /dev/null +++ b/packages/web/src/components/request-workspace/use-request-header-draft-controller.ts @@ -0,0 +1,180 @@ +import { type Accessor, createEffect, createSignal, on } from 'solid-js'; +import type { WorkspaceRequest } from '../../sdk'; +import type { RequestDetailsRow } from '../../utils/request-details'; +import { applyRequestEditsToContent, cloneRequestRows } from '../../utils/request-editing'; + +interface UseRequestHeaderDraftControllerInput { + path: Accessor; + selectedRequest: Accessor; + sourceHeaders: Accessor; + sourceUrl: Accessor; + getFileContent: Accessor; + setFileContent: (content: string) => void; + saveFile: (path: string) => Promise; + reloadRequests: (path: string) => Promise; + refetchRequestDetails: () => Promise | unknown; +} + +interface UseRequestHeaderDraftControllerReturn { + draftHeaders: Accessor; + isDirty: Accessor; + isSaving: Accessor; + saveError: Accessor; + onHeaderChange: (index: number, field: 'key' | 'value', value: string) => void; + onAddHeader: () => void; + onRemoveHeader: (index: number) => void; + onDiscard: () => void; + onSave: () => Promise; +} + +const DEFAULT_SAVE_ERROR = 'Unable to save request header edits.'; + +const makeRequestKey = ( + path: string, + request?: Pick +): string | undefined => (request ? `${path}:${request.index}` : undefined); + +function toErrorMessage(value: unknown): string { + if (value instanceof Error && value.message) { + return value.message; + } + return DEFAULT_SAVE_ERROR; +} + +export function useRequestHeaderDraftController( + input: UseRequestHeaderDraftControllerInput +): UseRequestHeaderDraftControllerReturn { + const initialDraftKey = () => makeRequestKey(input.path(), input.selectedRequest()); + const [draftRequestKey, setDraftRequestKey] = createSignal(initialDraftKey()); + const [draftHeaders, setDraftHeaders] = createSignal( + initialDraftKey() ? cloneRequestRows(input.sourceHeaders()) : [] + ); + const [isDirty, setIsDirty] = createSignal(false); + const [isSaving, setIsSaving] = createSignal(false); + const [saveError, setSaveError] = createSignal(undefined); + + const requestDraftKey = () => makeRequestKey(input.path(), input.selectedRequest()); + + createEffect( + on(requestDraftKey, (nextKey, previousKey) => { + if (!nextKey) { + setDraftRequestKey(undefined); + setDraftHeaders([]); + setIsDirty(false); + setIsSaving(false); + setSaveError(undefined); + return; + } + + if (nextKey === previousKey) { + return; + } + + setDraftRequestKey(nextKey); + setDraftHeaders(cloneRequestRows(input.sourceHeaders())); + setIsDirty(false); + setIsSaving(false); + setSaveError(undefined); + }) + ); + + createEffect( + on([requestDraftKey, input.sourceHeaders], ([nextKey, nextHeaders]) => { + if (!nextKey || draftRequestKey() !== nextKey || isDirty()) { + return; + } + setDraftHeaders(cloneRequestRows(nextHeaders)); + }) + ); + + const markDirty = () => { + setIsDirty(true); + setSaveError(undefined); + }; + + const onHeaderChange = (index: number, field: 'key' | 'value', value: string) => { + setDraftHeaders((rows) => { + if (index < 0 || index >= rows.length) { + return rows; + } + return rows.map((row, rowIndex) => (rowIndex === index ? { ...row, [field]: value } : row)); + }); + markDirty(); + }; + + const onAddHeader = () => { + setDraftHeaders((rows) => [...rows, { key: '', value: '' }]); + markDirty(); + }; + + const onRemoveHeader = (index: number) => { + setDraftHeaders((rows) => rows.filter((_, rowIndex) => rowIndex !== index)); + markDirty(); + }; + + const onDiscard = () => { + setDraftHeaders(cloneRequestRows(input.sourceHeaders())); + setIsDirty(false); + setSaveError(undefined); + }; + + const onSave = async () => { + const request = input.selectedRequest(); + const currentPath = input.path(); + const currentContent = input.getFileContent(); + const sourceUrl = input.sourceUrl()?.trim() ?? request?.url; + + if (!request) { + setSaveError('Select a request before saving header edits.'); + return; + } + + if (currentContent === undefined) { + setSaveError('Request file content is still loading. Try saving again.'); + return; + } + + if (!sourceUrl) { + setSaveError('Request URL cannot be empty.'); + return; + } + + const rewrite = applyRequestEditsToContent( + currentContent, + request.index, + sourceUrl, + draftHeaders() + ); + if (!rewrite.ok) { + setSaveError(rewrite.error); + return; + } + + input.setFileContent(rewrite.content); + setIsSaving(true); + setSaveError(undefined); + + try { + await input.saveFile(currentPath); + await input.reloadRequests(currentPath); + await input.refetchRequestDetails(); + setIsDirty(false); + } catch (error) { + setSaveError(toErrorMessage(error)); + } finally { + setIsSaving(false); + } + }; + + return { + draftHeaders, + isDirty, + isSaving, + saveError, + onHeaderChange, + onAddHeader, + onRemoveHeader, + onDiscard, + onSave + }; +} diff --git a/packages/web/src/components/request-workspace/use-request-parse-details.ts b/packages/web/src/components/request-workspace/use-request-parse-details.ts index 8d79f63..2b8e207 100644 --- a/packages/web/src/components/request-workspace/use-request-parse-details.ts +++ b/packages/web/src/components/request-workspace/use-request-parse-details.ts @@ -26,6 +26,7 @@ interface UseRequestParseDetailsReturn { bodySummary: () => RequestBodySummary; loading: () => boolean; error: () => string | undefined; + refetch: (info?: unknown) => unknown; } const DEFAULT_PARSE_ERROR = 'Unable to load request details.'; @@ -45,7 +46,7 @@ export function useRequestParseDetails( }; }); - const [parseResult] = createResource( + const [parseResult, { refetch }] = createResource( source, async (current): Promise => { return await unwrap( @@ -94,6 +95,7 @@ export function useRequestParseDetails( headers, bodySummary, loading: () => parseResult.loading, - error + error, + refetch }; } diff --git a/packages/web/src/utils/request-editing.test.ts b/packages/web/src/utils/request-editing.test.ts new file mode 100644 index 0000000..3ab4aa5 --- /dev/null +++ b/packages/web/src/utils/request-editing.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, test } from 'bun:test'; +import { + applyRequestEditsToContent, + areRequestRowsEqual, + cloneRequestRows +} from './request-editing'; + +describe('cloneRequestRows', () => { + test('returns a deep copy of row data', () => { + const source = [ + { key: 'Accept', value: 'application/json' }, + { key: 'X-Trace', value: 'trace-1' } + ]; + const cloned = cloneRequestRows(source); + expect(cloned).toEqual(source); + expect(cloned).not.toBe(source); + }); +}); + +describe('areRequestRowsEqual', () => { + test('returns true when row values and order match', () => { + expect( + areRequestRowsEqual( + [ + { key: 'Accept', value: 'application/json' }, + { key: 'X-Trace', value: '1' } + ], + [ + { key: 'Accept', value: 'application/json' }, + { key: 'X-Trace', value: '1' } + ] + ) + ).toBe(true); + }); + + test('returns false when row values differ', () => { + expect( + areRequestRowsEqual( + [{ key: 'Accept', value: 'application/json' }], + [{ key: 'Accept', value: 'text/plain' }] + ) + ).toBe(false); + }); +}); + +describe('applyRequestEditsToContent', () => { + test('rewrites selected request URL and headers while preserving body and other requests', () => { + const content = [ + 'GET https://api.example.com/users?limit=10', + 'Accept: application/json', + 'Authorization: Bearer old-token', + '', + '{"cursor":"abc"}', + '###', + 'POST https://api.example.com/login', + 'Content-Type: application/json', + '', + '{"email":"person@example.com"}' + ].join('\n'); + + const result = applyRequestEditsToContent( + content, + 0, + 'https://api.example.com/users?limit=10', + [ + { key: 'Accept', value: 'text/plain' }, + { key: 'X-Trace-Id', value: 'trace-123' } + ] + ); + + expect(result).toEqual({ + ok: true, + content: [ + 'GET https://api.example.com/users?limit=10', + 'Accept: text/plain', + 'X-Trace-Id: trace-123', + '', + '{"cursor":"abc"}', + '###', + 'POST https://api.example.com/login', + 'Content-Type: application/json', + '', + '{"email":"person@example.com"}' + ].join('\n') + }); + }); + + test('supports adding headers to a request with no existing headers', () => { + const content = ['GET https://api.example.com/health', '', '{"ok":true}'].join('\n'); + const result = applyRequestEditsToContent(content, 0, 'https://api.example.com/health', [ + { key: 'Accept', value: 'application/json' } + ]); + + expect(result).toEqual({ + ok: true, + content: [ + 'GET https://api.example.com/health', + 'Accept: application/json', + '', + '{"ok":true}' + ].join('\n') + }); + }); + + test('supports removing all headers from the selected request', () => { + const content = [ + 'GET https://api.example.com/health', + 'Accept: application/json', + 'X-Trace-Id: trace-1', + '', + '{"ok":true}' + ].join('\n'); + const result = applyRequestEditsToContent(content, 0, 'https://api.example.com/health', []); + + expect(result).toEqual({ + ok: true, + content: ['GET https://api.example.com/health', '', '{"ok":true}'].join('\n') + }); + }); + + test('rewrites header block correctly when comment lines are present', () => { + const content = [ + 'GET https://api.example.com/health', + 'Accept: application/json', + '# inline comment', + 'X-Trace-Id: trace-1', + '', + '{"ok":true}' + ].join('\n'); + const result = applyRequestEditsToContent(content, 0, 'https://api.example.com/health', [ + { key: 'Authorization', value: 'Bearer token' } + ]); + + expect(result).toEqual({ + ok: true, + content: [ + 'GET https://api.example.com/health', + 'Authorization: Bearer token', + '# inline comment', + '', + '{"ok":true}' + ].join('\n') + }); + }); + + test('returns an error when the request index does not exist', () => { + const result = applyRequestEditsToContent( + 'GET https://api.example.com/health', + 3, + 'https://api.example.com/health', + [] + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('could not be located'); + } + }); +}); diff --git a/packages/web/src/utils/request-editing.ts b/packages/web/src/utils/request-editing.ts new file mode 100644 index 0000000..be3d788 --- /dev/null +++ b/packages/web/src/utils/request-editing.ts @@ -0,0 +1,264 @@ +import type { RequestDetailsRow } from './request-details'; + +export type ApplyRequestEditsResult = + | { + ok: true; + content: string; + } + | { + ok: false; + error: string; + }; + +type LineRecord = { + text: string; + ending: string; + start: number; +}; + +type RequestSegmentResult = + | { + ok: true; + startOffset: number; + endOffset: number; + segment: string; + } + | { + ok: false; + error: string; + }; + +const REQUEST_LINE_PATTERN = /^([A-Za-z]+)\s+(\S+)(?:\s+HTTP\/\d+(?:\.\d+)?)?\s*$/; +const REQUEST_LINE_PARTS_PATTERN = /^(\s*)([A-Za-z]+)\s+(\S+)(\s+HTTP\/\d+(?:\.\d+)?)?(\s*)$/; +const HEADER_LINE_PATTERN = /^\s*[^:\s][^:]*:.*$/; + +function splitLines(content: string): LineRecord[] { + const lines: LineRecord[] = []; + let offset = 0; + + while (offset < content.length) { + const lineBreak = content.indexOf('\n', offset); + if (lineBreak === -1) { + lines.push({ + text: content.slice(offset), + ending: '', + start: offset + }); + break; + } + + const hasCarriageReturn = lineBreak > offset && content[lineBreak - 1] === '\r'; + const textEnd = hasCarriageReturn ? lineBreak - 1 : lineBreak; + lines.push({ + text: content.slice(offset, textEnd), + ending: hasCarriageReturn ? '\r\n' : '\n', + start: offset + }); + offset = lineBreak + 1; + } + + return lines; +} + +function isRequestLineCandidate(line: string): boolean { + const trimmed = line.trim(); + if (!trimmed) { + return false; + } + if (trimmed.startsWith('#') || trimmed.startsWith('//') || trimmed.startsWith('@')) { + return false; + } + return REQUEST_LINE_PATTERN.test(trimmed); +} + +function isHeaderLine(line: string): boolean { + return HEADER_LINE_PATTERN.test(line); +} + +function isCommentLine(line: string): boolean { + const trimmed = line.trim(); + return trimmed.startsWith('#') || trimmed.startsWith('//'); +} + +function isHeaderSectionLine(line: string): boolean { + return isHeaderLine(line) || isCommentLine(line); +} + +function preferredLineEnding(lines: LineRecord[]): string { + return lines.find((line) => line.ending.length > 0)?.ending ?? '\n'; +} + +function normalizeRows(rows: RequestDetailsRow[]): RequestDetailsRow[] { + return rows + .map((row) => ({ key: row.key.trim(), value: row.value })) + .filter((row) => row.key.length > 0); +} + +function serializeRows(rows: RequestDetailsRow[]): RequestDetailsRow[] { + return normalizeRows(rows).map((row) => ({ + key: row.key, + value: row.value + })); +} + +function rewriteRequestSegment( + segment: string, + nextUrl: string, + nextHeaders: RequestDetailsRow[] +): ApplyRequestEditsResult { + const lines = splitLines(segment); + const requestLine = lines[0]; + if (!requestLine) { + return { + ok: false, + error: 'Selected request content is empty.' + }; + } + + const requestMatch = requestLine.text.match(REQUEST_LINE_PARTS_PATTERN); + if (!requestMatch) { + return { + ok: false, + error: 'Unable to locate request line for selected request.' + }; + } + + const [, indent, method, , suffix = '', trailing = ''] = requestMatch; + const rebuiltRequestLine = `${indent}${method} ${nextUrl}${suffix}${trailing}`; + const restLines = lines.slice(1); + + let headerEndIndex = 0; + while ( + headerEndIndex < restLines.length && + isHeaderSectionLine(restLines[headerEndIndex]?.text ?? '') + ) { + headerEndIndex += 1; + } + + const preservedCommentLines = restLines.filter((line, index) => { + return index < headerEndIndex && isCommentLine(line.text); + }); + const remainingLines = restLines.slice(headerEndIndex); + const normalizedHeaders = serializeRows(nextHeaders); + const lineEnding = preferredLineEnding(lines); + const requestLineEnding = + requestLine.ending || + (normalizedHeaders.length > 0 || remainingLines.length > 0 ? lineEnding : ''); + + let updatedSegment = `${rebuiltRequestLine}${requestLineEnding}`; + const combinedHeaderLines = [ + ...normalizedHeaders.map((header) => ({ + text: `${header.key}${header.value.length > 0 ? `: ${header.value}` : ':'}` + })), + ...preservedCommentLines.map((line) => ({ text: line.text })) + ]; + + for (let index = 0; index < combinedHeaderLines.length; index += 1) { + const line = combinedHeaderLines[index]; + if (!line) { + continue; + } + + const isLastLine = index === combinedHeaderLines.length - 1; + const ending = isLastLine + ? remainingLines.length > 0 + ? lineEnding + : headerEndIndex > 0 + ? (restLines[headerEndIndex - 1]?.ending ?? '') + : '' + : lineEnding; + + updatedSegment += `${line.text}${ending}`; + } + + for (const line of remainingLines) { + updatedSegment += `${line.text}${line.ending}`; + } + + return { + ok: true, + content: updatedSegment + }; +} + +function findRequestSegment(content: string, requestIndex: number): RequestSegmentResult { + const lines = splitLines(content); + const requestLineIndexes = lines.flatMap((line, index) => + isRequestLineCandidate(line.text) ? [index] : [] + ); + + if (requestIndex < 0 || requestIndex >= requestLineIndexes.length) { + return { + ok: false, + error: `Request #${requestIndex + 1} could not be located in file content.` + }; + } + + const startLineIndex = requestLineIndexes[requestIndex]; + if (startLineIndex === undefined) { + return { + ok: false, + error: 'Selected request line was not found.' + }; + } + + const nextLineIndex = requestLineIndexes[requestIndex + 1]; + const startOffset = lines[startLineIndex]?.start ?? 0; + const endOffset = + nextLineIndex !== undefined ? (lines[nextLineIndex]?.start ?? content.length) : content.length; + + return { + ok: true, + startOffset, + endOffset, + segment: content.slice(startOffset, endOffset) + }; +} + +export function cloneRequestRows(rows: RequestDetailsRow[]): RequestDetailsRow[] { + return rows.map((row) => ({ key: row.key, value: row.value })); +} + +export function areRequestRowsEqual( + first: RequestDetailsRow[], + second: RequestDetailsRow[] +): boolean { + if (first.length !== second.length) { + return false; + } + + for (let index = 0; index < first.length; index += 1) { + const firstRow = first[index]; + const secondRow = second[index]; + if (!firstRow || !secondRow) { + return false; + } + if (firstRow.key !== secondRow.key || firstRow.value !== secondRow.value) { + return false; + } + } + + return true; +} + +export function applyRequestEditsToContent( + content: string, + requestIndex: number, + nextUrl: string, + nextHeaders: RequestDetailsRow[] +): ApplyRequestEditsResult { + const requestSegment = findRequestSegment(content, requestIndex); + if (!requestSegment.ok) { + return requestSegment; + } + + const rewritten = rewriteRequestSegment(requestSegment.segment, nextUrl, nextHeaders); + if (!rewritten.ok) { + return rewritten; + } + + return { + ok: true, + content: `${content.slice(0, requestSegment.startOffset)}${rewritten.content}${content.slice(requestSegment.endOffset)}` + }; +} From d4342df751b841e8c89d0bf6a0dc97cfef16433b Mon Sep 17 00:00:00 2001 From: Andrew Melchor Date: Mon, 2 Mar 2026 16:27:33 -0800 Subject: [PATCH 2/4] add regression coverage --- .../web/src/utils/request-editing.test.ts | 62 +++++++++++++++++++ packages/web/src/utils/request-editing.ts | 32 +++++++--- 2 files changed, 87 insertions(+), 7 deletions(-) diff --git a/packages/web/src/utils/request-editing.test.ts b/packages/web/src/utils/request-editing.test.ts index 3ab4aa5..1cf8c43 100644 --- a/packages/web/src/utils/request-editing.test.ts +++ b/packages/web/src/utils/request-editing.test.ts @@ -143,6 +143,68 @@ describe('applyRequestEditsToContent', () => { }); }); + test('ignores non-HTTP body lines when locating request segments', () => { + const content = [ + 'GET https://api.example.com/one', + 'Accept: application/json', + '', + 'token abc', + '', + 'POST https://api.example.com/two', + 'Content-Type: application/json', + '', + '{"email":"person@example.com"}' + ].join('\n'); + + const result = applyRequestEditsToContent(content, 1, 'https://api.example.com/two-updated', [ + { key: 'X-Trace-Id', value: 'trace-123' } + ]); + + expect(result).toEqual({ + ok: true, + content: [ + 'GET https://api.example.com/one', + 'Accept: application/json', + '', + 'token abc', + '', + 'POST https://api.example.com/two-updated', + 'X-Trace-Id: trace-123', + '', + '{"email":"person@example.com"}' + ].join('\n') + }); + }); + + test('removes URL continuation lines before rebuilding headers', () => { + const content = [ + 'GET https://api.example.com/search', + ' ?q=old', + ' &page=1', + 'Accept: application/json', + 'X-Trace-Id: old', + '', + '{"keep":true}' + ].join('\n'); + + const result = applyRequestEditsToContent( + content, + 0, + 'https://api.example.com/search?q=new&page=2', + [{ key: 'Authorization', value: 'Bearer token' }] + ); + + expect(result).toEqual({ + ok: true, + content: [ + 'GET https://api.example.com/search?q=new&page=2', + 'Authorization: Bearer token', + '', + '{"keep":true}' + ].join('\n') + }); + }); + test('returns an error when the request index does not exist', () => { const result = applyRequestEditsToContent( 'GET https://api.example.com/health', diff --git a/packages/web/src/utils/request-editing.ts b/packages/web/src/utils/request-editing.ts index be3d788..fb12971 100644 --- a/packages/web/src/utils/request-editing.ts +++ b/packages/web/src/utils/request-editing.ts @@ -28,8 +28,10 @@ type RequestSegmentResult = error: string; }; -const REQUEST_LINE_PATTERN = /^([A-Za-z]+)\s+(\S+)(?:\s+HTTP\/\d+(?:\.\d+)?)?\s*$/; -const REQUEST_LINE_PARTS_PATTERN = /^(\s*)([A-Za-z]+)\s+(\S+)(\s+HTTP\/\d+(?:\.\d+)?)?(\s*)$/; +const REQUEST_LINE_PATTERN = + /^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE|CONNECT)\s+(\S+)(?:\s+HTTP\/\d+(?:\.\d+)?)?\s*$/i; +const REQUEST_LINE_PARTS_PATTERN = + /^(\s*)(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE|CONNECT)\s+(\S+)(\s+HTTP\/\d+(?:\.\d+)?)?(\s*)$/i; const HEADER_LINE_PATTERN = /^\s*[^:\s][^:]*:.*$/; function splitLines(content: string): LineRecord[] { @@ -84,6 +86,11 @@ function isHeaderSectionLine(line: string): boolean { return isHeaderLine(line) || isCommentLine(line); } +function isUrlContinuationLine(line: string): boolean { + const trimmed = line.trim(); + return trimmed.startsWith('?') || trimmed.startsWith('&'); +} + function preferredLineEnding(lines: LineRecord[]): string { return lines.find((line) => line.ending.length > 0)?.ending ?? '\n'; } @@ -127,18 +134,29 @@ function rewriteRequestSegment( const rebuiltRequestLine = `${indent}${method} ${nextUrl}${suffix}${trailing}`; const restLines = lines.slice(1); + // URL continuation lines immediately after the request line are folded by parser. + // Drop them from the rewritten segment to avoid duplicate/malformed URLs. + let continuationEndIndex = 0; + while ( + continuationEndIndex < restLines.length && + isUrlContinuationLine(restLines[continuationEndIndex]?.text ?? '') + ) { + continuationEndIndex += 1; + } + + const requestDetailLines = restLines.slice(continuationEndIndex); let headerEndIndex = 0; while ( - headerEndIndex < restLines.length && - isHeaderSectionLine(restLines[headerEndIndex]?.text ?? '') + headerEndIndex < requestDetailLines.length && + isHeaderSectionLine(requestDetailLines[headerEndIndex]?.text ?? '') ) { headerEndIndex += 1; } - const preservedCommentLines = restLines.filter((line, index) => { + const preservedCommentLines = requestDetailLines.filter((line, index) => { return index < headerEndIndex && isCommentLine(line.text); }); - const remainingLines = restLines.slice(headerEndIndex); + const remainingLines = requestDetailLines.slice(headerEndIndex); const normalizedHeaders = serializeRows(nextHeaders); const lineEnding = preferredLineEnding(lines); const requestLineEnding = @@ -164,7 +182,7 @@ function rewriteRequestSegment( ? remainingLines.length > 0 ? lineEnding : headerEndIndex > 0 - ? (restLines[headerEndIndex - 1]?.ending ?? '') + ? (requestDetailLines[headerEndIndex - 1]?.ending ?? '') : '' : lineEnding; From e15d6758a8509748ac6e4c66340727ac9c076570 Mon Sep 17 00:00:00 2001 From: Andrew Melchor Date: Mon, 2 Mar 2026 16:31:09 -0800 Subject: [PATCH 3/4] simplify redudant mapping --- ...se-request-header-draft-controller.test.ts | 112 ++++++++++++++++++ .../use-request-header-draft-controller.ts | 6 +- packages/web/src/utils/request-editing.ts | 5 +- 3 files changed, 118 insertions(+), 5 deletions(-) diff --git a/packages/web/src/components/request-workspace/use-request-header-draft-controller.test.ts b/packages/web/src/components/request-workspace/use-request-header-draft-controller.test.ts index 8e8af04..7fdc5e7 100644 --- a/packages/web/src/components/request-workspace/use-request-header-draft-controller.test.ts +++ b/packages/web/src/components/request-workspace/use-request-header-draft-controller.test.ts @@ -120,4 +120,116 @@ describe('useRequestHeaderDraftController', () => { dispose(); }); }); + + test('clears dirty state after successful disk save even when reload fails', async () => { + const saveCalls: string[] = []; + const reloadCalls: string[] = []; + let refetchCalls = 0; + + await createRoot(async (dispose) => { + const [path] = createSignal('requests.http'); + const [selectedRequest] = createSignal({ + index: 0, + method: 'GET', + url: 'https://api.example.com/users' + }); + const [sourceHeaders] = createSignal([{ key: 'Accept', value: 'application/json' }]); + const [fileContent, setFileContentSignal] = createSignal( + ['GET https://api.example.com/users', 'Accept: application/json', '', '{"ok":true}'].join( + '\n' + ) + ); + + const controller = useRequestHeaderDraftController({ + path, + selectedRequest, + sourceHeaders, + sourceUrl: () => selectedRequest()?.url, + getFileContent: fileContent, + setFileContent: (content) => { + setFileContentSignal(content); + }, + saveFile: async (nextPath) => { + saveCalls.push(nextPath); + }, + reloadRequests: async (nextPath) => { + reloadCalls.push(nextPath); + throw new Error('reload failed'); + }, + refetchRequestDetails: async () => { + refetchCalls += 1; + } + }); + + controller.onHeaderChange(0, 'value', 'text/plain'); + expect(controller.isDirty()).toBe(true); + + await controller.onSave(); + + expect(controller.isDirty()).toBe(false); + expect(controller.isSaving()).toBe(false); + expect(controller.saveError()).toBe('reload failed'); + expect(saveCalls).toEqual(['requests.http']); + expect(reloadCalls).toEqual(['requests.http']); + expect(refetchCalls).toBe(0); + + dispose(); + }); + }); + + test('ignores additional save requests while one save is in flight', async () => { + const saveCalls: string[] = []; + let resolveSave: (() => void) | undefined; + + await createRoot(async (dispose) => { + const [path] = createSignal('requests.http'); + const [selectedRequest] = createSignal({ + index: 0, + method: 'GET', + url: 'https://api.example.com/users' + }); + const [sourceHeaders] = createSignal([{ key: 'Accept', value: 'application/json' }]); + const [fileContent, setFileContentSignal] = createSignal( + ['GET https://api.example.com/users', 'Accept: application/json', '', '{"ok":true}'].join( + '\n' + ) + ); + + const controller = useRequestHeaderDraftController({ + path, + selectedRequest, + sourceHeaders, + sourceUrl: () => selectedRequest()?.url, + getFileContent: fileContent, + setFileContent: (content) => { + setFileContentSignal(content); + }, + saveFile: async (nextPath) => { + saveCalls.push(nextPath); + await new Promise((resolve) => { + resolveSave = resolve; + }); + }, + reloadRequests: async () => {}, + refetchRequestDetails: async () => {} + }); + + controller.onHeaderChange(0, 'value', 'text/plain'); + + const firstSave = controller.onSave(); + const secondSave = controller.onSave(); + + expect(controller.isSaving()).toBe(true); + expect(saveCalls).toEqual(['requests.http']); + + resolveSave?.(); + await firstSave; + await secondSave; + + expect(controller.isSaving()).toBe(false); + expect(saveCalls).toEqual(['requests.http']); + + dispose(); + }); + }); }); diff --git a/packages/web/src/components/request-workspace/use-request-header-draft-controller.ts b/packages/web/src/components/request-workspace/use-request-header-draft-controller.ts index 53c8efa..68390a6 100644 --- a/packages/web/src/components/request-workspace/use-request-header-draft-controller.ts +++ b/packages/web/src/components/request-workspace/use-request-header-draft-controller.ts @@ -119,6 +119,10 @@ export function useRequestHeaderDraftController( }; const onSave = async () => { + if (isSaving()) { + return; + } + const request = input.selectedRequest(); const currentPath = input.path(); const currentContent = input.getFileContent(); @@ -156,9 +160,9 @@ export function useRequestHeaderDraftController( try { await input.saveFile(currentPath); + setIsDirty(false); await input.reloadRequests(currentPath); await input.refetchRequestDetails(); - setIsDirty(false); } catch (error) { setSaveError(toErrorMessage(error)); } finally { diff --git a/packages/web/src/utils/request-editing.ts b/packages/web/src/utils/request-editing.ts index fb12971..c7b3457 100644 --- a/packages/web/src/utils/request-editing.ts +++ b/packages/web/src/utils/request-editing.ts @@ -102,10 +102,7 @@ function normalizeRows(rows: RequestDetailsRow[]): RequestDetailsRow[] { } function serializeRows(rows: RequestDetailsRow[]): RequestDetailsRow[] { - return normalizeRows(rows).map((row) => ({ - key: row.key, - value: row.value - })); + return normalizeRows(rows); } function rewriteRequestSegment( From 996259036cfce7a18f0b144549f4bd95efd2bc1c Mon Sep 17 00:00:00 2001 From: Andrew Melchor Date: Mon, 2 Mar 2026 16:43:30 -0800 Subject: [PATCH 4/4] pr comments --- ...se-request-header-draft-controller.test.ts | 39 +++++++++++++ .../use-request-header-draft-controller.ts | 1 - .../web/src/utils/request-editing.test.ts | 48 ++++++++++++++++ packages/web/src/utils/request-editing.ts | 55 +++++++++++++++---- 4 files changed, 132 insertions(+), 11 deletions(-) diff --git a/packages/web/src/components/request-workspace/use-request-header-draft-controller.test.ts b/packages/web/src/components/request-workspace/use-request-header-draft-controller.test.ts index 7fdc5e7..36ca9dc 100644 --- a/packages/web/src/components/request-workspace/use-request-header-draft-controller.test.ts +++ b/packages/web/src/components/request-workspace/use-request-header-draft-controller.test.ts @@ -4,6 +4,45 @@ import type { WorkspaceRequest } from '../../sdk'; import { useRequestHeaderDraftController } from './use-request-header-draft-controller'; describe('useRequestHeaderDraftController', () => { + test('does not mark dirty when adding an empty header row', () => { + createRoot((dispose) => { + const [path] = createSignal('requests.http'); + const [selectedRequest] = createSignal({ + index: 0, + method: 'GET', + url: 'https://api.example.com/users' + }); + const [sourceHeaders] = createSignal([{ key: 'Accept', value: 'application/json' }]); + const [fileContent] = createSignal( + ['GET https://api.example.com/users', 'Accept: application/json', '', '{"ok":true}'].join( + '\n' + ) + ); + + const controller = useRequestHeaderDraftController({ + path, + selectedRequest, + sourceHeaders, + sourceUrl: () => selectedRequest()?.url, + getFileContent: fileContent, + setFileContent: () => {}, + saveFile: async () => {}, + reloadRequests: async () => {}, + refetchRequestDetails: async () => {} + }); + + expect(controller.isDirty()).toBe(false); + controller.onAddHeader(); + expect(controller.isDirty()).toBe(false); + expect(controller.draftHeaders()).toEqual([ + { key: 'Accept', value: 'application/json' }, + { key: '', value: '' } + ]); + + dispose(); + }); + }); + test('tracks header row mutations and dirty state', () => { createRoot((dispose) => { const [path] = createSignal('requests.http'); diff --git a/packages/web/src/components/request-workspace/use-request-header-draft-controller.ts b/packages/web/src/components/request-workspace/use-request-header-draft-controller.ts index 68390a6..aca162b 100644 --- a/packages/web/src/components/request-workspace/use-request-header-draft-controller.ts +++ b/packages/web/src/components/request-workspace/use-request-header-draft-controller.ts @@ -104,7 +104,6 @@ export function useRequestHeaderDraftController( const onAddHeader = () => { setDraftHeaders((rows) => [...rows, { key: '', value: '' }]); - markDirty(); }; const onRemoveHeader = (index: number) => { diff --git a/packages/web/src/utils/request-editing.test.ts b/packages/web/src/utils/request-editing.test.ts index 1cf8c43..b5e248a 100644 --- a/packages/web/src/utils/request-editing.test.ts +++ b/packages/web/src/utils/request-editing.test.ts @@ -143,6 +143,54 @@ describe('applyRequestEditsToContent', () => { }); }); + test('preserves inline comment position when rebuilding headers', () => { + const content = [ + 'GET https://api.example.com/health', + 'Accept: application/json', + '# auth token', + 'Authorization: Bearer old-token', + '', + '{"ok":true}' + ].join('\n'); + + const result = applyRequestEditsToContent(content, 0, 'https://api.example.com/health', [ + { key: 'Accept', value: 'text/plain' }, + { key: 'Authorization', value: 'Bearer new-token' } + ]); + + expect(result).toEqual({ + ok: true, + content: [ + 'GET https://api.example.com/health', + 'Accept: text/plain', + '# auth token', + 'Authorization: Bearer new-token', + '', + '{"ok":true}' + ].join('\n') + }); + }); + + test('trims header values when normalizing edited rows', () => { + const content = ['GET https://api.example.com/health', 'Accept: old', '', '{"ok":true}'].join( + '\n' + ); + + const result = applyRequestEditsToContent(content, 0, 'https://api.example.com/health', [ + { key: 'Accept', value: ' application/json ' } + ]); + + expect(result).toEqual({ + ok: true, + content: [ + 'GET https://api.example.com/health', + 'Accept: application/json', + '', + '{"ok":true}' + ].join('\n') + }); + }); + test('ignores non-HTTP body lines when locating request segments', () => { const content = [ 'GET https://api.example.com/one', diff --git a/packages/web/src/utils/request-editing.ts b/packages/web/src/utils/request-editing.ts index c7b3457..7ec236c 100644 --- a/packages/web/src/utils/request-editing.ts +++ b/packages/web/src/utils/request-editing.ts @@ -97,7 +97,7 @@ function preferredLineEnding(lines: LineRecord[]): string { function normalizeRows(rows: RequestDetailsRow[]): RequestDetailsRow[] { return rows - .map((row) => ({ key: row.key.trim(), value: row.value })) + .map((row) => ({ key: row.key.trim(), value: row.value.trim() })) .filter((row) => row.key.length > 0); } @@ -105,6 +105,48 @@ function serializeRows(rows: RequestDetailsRow[]): RequestDetailsRow[] { return normalizeRows(rows); } +function toHeaderLineText(header: RequestDetailsRow): string { + return `${header.key}${header.value.length > 0 ? `: ${header.value}` : ':'}`; +} + +function buildRewrittenHeaderLines( + headerSectionLines: LineRecord[], + normalizedHeaders: RequestDetailsRow[] +): Array<{ text: string }> { + const rewrittenLines: Array<{ text: string }> = []; + let headerIndex = 0; + + for (const line of headerSectionLines) { + if (isCommentLine(line.text)) { + rewrittenLines.push({ text: line.text }); + continue; + } + + if (!isHeaderLine(line.text)) { + continue; + } + + const nextHeader = normalizedHeaders[headerIndex]; + if (!nextHeader) { + continue; + } + + rewrittenLines.push({ text: toHeaderLineText(nextHeader) }); + headerIndex += 1; + } + + while (headerIndex < normalizedHeaders.length) { + const nextHeader = normalizedHeaders[headerIndex]; + if (!nextHeader) { + break; + } + rewrittenLines.push({ text: toHeaderLineText(nextHeader) }); + headerIndex += 1; + } + + return rewrittenLines; +} + function rewriteRequestSegment( segment: string, nextUrl: string, @@ -150,9 +192,7 @@ function rewriteRequestSegment( headerEndIndex += 1; } - const preservedCommentLines = requestDetailLines.filter((line, index) => { - return index < headerEndIndex && isCommentLine(line.text); - }); + const headerSectionLines = requestDetailLines.slice(0, headerEndIndex); const remainingLines = requestDetailLines.slice(headerEndIndex); const normalizedHeaders = serializeRows(nextHeaders); const lineEnding = preferredLineEnding(lines); @@ -161,12 +201,7 @@ function rewriteRequestSegment( (normalizedHeaders.length > 0 || remainingLines.length > 0 ? lineEnding : ''); let updatedSegment = `${rebuiltRequestLine}${requestLineEnding}`; - const combinedHeaderLines = [ - ...normalizedHeaders.map((header) => ({ - text: `${header.key}${header.value.length > 0 ? `: ${header.value}` : ':'}` - })), - ...preservedCommentLines.map((line) => ({ text: line.text })) - ]; + const combinedHeaderLines = buildRewrittenHeaderLines(headerSectionLines, normalizedHeaders); for (let index = 0; index < combinedHeaderLines.length; index += 1) { const line = combinedHeaderLines[index];