Skip to content

feat(web): add editable request params panel#118

Closed
andrewmelchor wants to merge 2 commits intomainfrom
feat(web)/request-params
Closed

feat(web): add editable request params panel#118
andrewmelchor wants to merge 2 commits intomainfrom
feat(web)/request-params

Conversation

@andrewmelchor
Copy link
Copy Markdown
Member

Summary

This PR implements an editable query parameters panel for the request workspace, bringing it to feature parity with headers and body editing.
Changes:

  • New draft controller (use-request-param-draft-controller.ts): Manages param state with add, edit, remove, save, and discard operations
  • Enhanced RequestWorkspaceParamsPanel: Converted from read-only display to interactive form with editable key/value inputs, add/remove buttons, and save/discard actions
  • Integration with workspace state: Param drafts integrated into useHttpRequestWorkspace and HttpEditorWithExecution for auto-save before execution
  • URL building utilities (buildUrlWithQueryRows): Reconstructs request URLs with updated query strings while preserving hash fragments and handling value-less flags
  • Type updates: RequestDetailsRow now includes optional hasValue flag to distinguish between ?flag and ?flag= query params

Implement full CRUD support for query parameters in the request workspace.
Adds draft controller for param state management, editable form UI with
add/remove/save/discard actions, and utilities to rebuild URLs with
updated query strings. Params now persist to the .http file on save.
@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 5, 2026

Greptile Summary

This PR promotes the query-params tab from a read-only display to a full edit-save-discard panel, mirroring the existing headers and body draft controllers. The utility work (buildUrlWithQueryRows, hasValue on RequestDetailsRow, template-expression-aware encoding) is solid and well-tested. The main concerns are in the async lifecycle of the new draft controller and the changed save-shortcut behaviour.

  • Race condition in persistRewrite — switching requests while a file save is in-flight causes setDraft('isDirty', false) and setDraft('isSaving', false) to land on the new request's draft state, not the one that was being saved. A user who switches and immediately makes edits can have their dirty flag silently cleared, making their changes invisible to the save/execute guards.
  • onSave missing an isDirty guard — the function guards against concurrent saves (isSaving) but not against a redundant save on a clean draft, triggering unnecessary file rewrites and reloads.
  • Partial-save opacity in persistAllDirtyDrafts — drafts are flushed sequentially; if an earlier draft succeeds but a later one fails, the succeeded changes are already on disk with no visible indication to the user, while persistAllDirtyDrafts returns false and blocks execution.
  • Fragile paramDraftRef closureheaderDraft is initialized before paramDraft, yet sourceUrlForHeaderSave (passed to headerDraft) reads paramDraftRef which is only assigned afterwards. This is safe today because sourceUrl is never called during construction, but it is an implicit ordering contract not enforced by types.
  • Ctrl+S now saves all dirty drafts — the old handleHttpSave saved only the currently-active tab's draft; the new version saves every dirty draft regardless of which tab is visible. This is a broader behavioural change that may surprise users editing one tab while another tab has outstanding changes.

Confidence Score: 2/5

  • Not safe to merge as-is — the mid-save request-switch race condition can silently clear a dirty flag and cause unsaved param edits to be lost.
  • The utility layer (buildUrlWithQueryRows, hasValue semantics) is correct and well-tested. However, use-request-param-draft-controller.ts contains a real data-loss race: async callbacks in persistRewrite mutate shared store state after await points without verifying the selected request hasn't changed, which can corrupt the isDirty flag of an unrelated request the user has switched to. The missing isDirty guard in onSave and the opaque partial-save failure mode in persistAllDirtyDrafts are additional reliability concerns that should be addressed before shipping.
  • packages/web/src/components/request-workspace/use-request-param-draft-controller.ts (race condition + missing guard) and packages/web/src/components/editor/HttpEditorWithExecution.tsx (partial-save UX).

Important Files Changed

Filename Overview
packages/web/src/components/request-workspace/use-request-param-draft-controller.ts New draft controller for editable query params; contains a race condition where switching requests mid-save corrupts the dirty flag of the newly selected request, and onSave lacks an isDirty guard.
packages/web/src/components/editor/HttpEditorWithExecution.tsx Replaces tab-specific save logic with persistAllDirtyDrafts, which saves all dirty drafts sequentially; partial failures leave state inconsistent with no per-draft feedback to the user.
packages/web/src/hooks/useHttpRequestWorkspace.ts Wires in param draft controller and cross-tab sync; uses a mutable paramDraftRef closure that is fragile but functional given current initialization ordering.
packages/web/src/utils/request-editing.ts Adds buildUrlWithQueryRows with template-expression-aware encoding and hash fragment preservation; logic is correct and well-tested.
packages/web/src/utils/request-editing.test.ts Adds 6 new tests for buildUrlWithQueryRows and hasValue handling; existing tests for applyRequestEditsToContent and other core functions are preserved (addressing the previously flagged regression).

Sequence Diagram

sequenceDiagram
    participant UI as UI (ParamsPanel)
    participant PDC as ParamDraftController
    participant HKWSP as useHttpRequestWorkspace
    participant PEDF as persistAllDirtyDrafts
    participant RE as request-editing utils
    participant WS as WorkspaceStore

    UI->>PDC: onParamChange / onAddParam / onRemoveParam
    PDC->>PDC: setDraft('params', ...) + markDirty()

    UI->>PDC: onSave()
    PDC->>RE: buildUrlWithQueryRows(sourceUrl, draft.params)
    RE-->>PDC: nextUrl
    PDC->>RE: applyRequestEditsToContent(content, requestIndex, nextUrl, sourceHeaders)
    RE-->>PDC: rewrite result
    PDC->>WS: setFileContent(nextContent)
    PDC->>WS: saveFile(path)
    WS-->>PDC: saved
    PDC->>PDC: setDraft('isDirty', false)
    PDC->>WS: reloadRequests(path)
    PDC->>WS: refetchRequestDetails()

    Note over PEDF,PDC: On Ctrl+S / Execute
    PEDF->>PDC: isDirty()?
    PDC-->>PEDF: true/false
    alt dirty
        PEDF->>PDC: onSave()
        PDC-->>PEDF: resolved
        PEDF->>PDC: isDirty()? (check for save error)
        PDC-->>PEDF: false (success) / true (error → abort)
    end
    PEDF->>HKWSP: headerDraft.onSave() (if dirty)
    PEDF->>HKWSP: bodyDraft.onSave() (if dirty)
Loading

Last reviewed commit: 04f15a7

Comment on lines +186 to +192
const nextUrl = buildUrlWithQueryRows(sourceUrl, draft.params);
const rewrite = applyRequestEditsToContent(
currentContent,
request.index,
nextUrl,
input.sourceHeaders()
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsaved changes on other tabs are silently dropped on save

When onSave is called, it rewrites the file using input.sourceHeaders() — the persisted (server-side) headers, not any in-progress header draft edits. If a user simultaneously has unsaved header changes and then saves their param edits, their header draft is silently discarded from the written file.

The same issue exists in the opposite direction: saving headers calls applyRequestEditsToContent with the saved URL (not the current param draft). This is an architectural constraint of the independent draft model, but it may surprise users. At minimum, a comment explaining this limitation would help future maintainers, and ideally the UI could warn when another tab has unsaved changes before allowing a save.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 029b07d7cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return [encodedKey];
}

return [`${encodedKey}=${encodeQueryComponent(row.value)}`];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve template markers when serializing query rows

This re-encodes every edited query value with encodeURIComponent, which converts {{...}} placeholders into %7B%7B...%7D%7D; in this codebase interpolation depends on literal braces (see packages/core/src/interpolate.ts), so saving params can silently break runtime variables/resolvers in URLs such as ?t={{$timestamp()}} and send incorrect requests.

Useful? React with 👍 / 👎.

Comment on lines +113 to +128
const persistRewrite = async (path: string, nextContent: string) => {
input.setFileContent(nextContent);
setDraft('isSaving', true);
setDraft('saveError', undefined);

try {
await input.saveFile(path);
setDraft('isDirty', false);
await input.reloadRequests(path);
await input.refetchRequestDetails();
} catch (error) {
setDraft('saveError', toErrorMessage(error));
} finally {
setDraft('isSaving', false);
}
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale isDirty flag after request switch mid-save

persistRewrite is an async function that holds references to setDraft over multiple await points. If the user switches to a different request between await input.saveFile(path) completing and the final setDraft('isDirty', false) / setDraft('isSaving', false) calls, those mutations are applied to the new request's draft state, not the one that was being saved.

A concrete race scenario:

  1. User edits request A's params → isDirty = true
  2. User clicks Save → isSaving = true, save begins
  3. While the saveFile network call is in-flight, user switches to request B and makes edits → replaceDraft resets draft to B's state, markDirty() sets isDirty = true
  4. Request A's saveFile completes → setDraft('isDirty', false) incorrectly clears request B's dirty flag
  5. B's unsaved changes are now silently invisible — the Unsaved badge disappears and Save is disabled

A guard can prevent the late callbacks from touching unrelated state:

const persistRewrite = async (path: string, nextContent: string, forRequestKey: string) => {
  input.setFileContent(nextContent);
  setDraft('isSaving', true);
  setDraft('saveError', undefined);

  try {
    await input.saveFile(path);
    if (draft.requestKey !== forRequestKey) return; // request changed mid-save
    setDraft('isDirty', false);
    await input.reloadRequests(path);
    await input.refetchRequestDetails();
  } catch (error) {
    if (draft.requestKey !== forRequestKey) return;
    setDraft('saveError', toErrorMessage(error));
  } finally {
    if (draft.requestKey === forRequestKey) {
      setDraft('isSaving', false);
    }
  }
};

And call it as await persistRewrite(currentPath, rewrite.content, requestDraftKey() ?? '').

Comment on lines +158 to +161
const onSave = async () => {
if (draft.isSaving) {
return;
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onSave proceeds even when the draft is clean

onSave guards against a concurrent save (draft.isSaving) but never guards against a redundant save when draft.isDirty is false. As a result, calling onSave on a clean draft (e.g. via a programmatic path or a keyboard shortcut that bypasses the disabled-button guard) will silently rewrite and overwrite the .http file with identical content, triggering a full reloadRequests + refetchRequestDetails cycle unnecessarily.

Suggested change
const onSave = async () => {
if (draft.isSaving) {
return;
}
const onSave = async () => {
if (draft.isSaving || !draft.isDirty) {
return;
}

Comment on lines +54 to +73
const persistAllDirtyDrafts = async (): Promise<boolean> => {
const drafts = [
httpWorkspace.drafts.param,
httpWorkspace.drafts.header,
httpWorkspace.drafts.body
];

for (const draft of drafts) {
if (!draft.isDirty()) {
continue;
}

await draft.onSave();
if (draft.isDirty()) {
return false;
}
}

return true;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partial-save leaves state inconsistent with no user feedback

persistAllDirtyDrafts saves drafts sequentially and returns false on the first failure. When more than one draft is dirty, earlier drafts that succeeded are already flushed to disk, but later drafts that haven't run yet are silently skipped. The function returns false to the caller, which only acts as a gate — it does not surface which drafts were saved and which weren't.

For example: if a user has both param edits and header edits outstanding and the param save fails:

  • The save error appears in the Params panel
  • The header draft is not saved but remains dirty
  • handleHttpSave (and handleHttpExecute) both bail out, so the file is not sent

But if the param save succeeds and the header save fails:

  • Param changes are now on disk
  • Header changes are still in the draft, shown as "Unsaved"
  • persistAllDirtyDrafts returns false, blocking execution
  • The user has no indication that their params were already written

At minimum, a code comment explaining this partial-save contract would help future maintainers reason about the failure modes. Ideally each tab's save error should remain visible so the user understands which draft failed.

Comment on lines +85 to +100
let paramDraftRef: ReturnType<typeof useRequestParamDraftController> | undefined;

// Keep cross-tab edits in sync: header saves include unsaved param drafts when present.
const sourceUrlForHeaderSave = () => {
const requestUrl = selectedRequest()?.url;
if (!requestUrl) {
return undefined;
}

const paramDraft = paramDraftRef;
if (!paramDraft || !paramDraft.isDirty()) {
return requestUrl;
}

return buildUrlWithQueryRows(requestUrl, paramDraft.draftParams());
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

paramDraftRef introduces a mutable temporal dependency

paramDraftRef is declared as undefined, used inside sourceUrlForHeaderSave (which is passed to headerDraft at initialization time), and then assigned after headerDraft is constructed:

let paramDraftRef: ReturnType<typeof useRequestParamDraftController> | undefined;

const sourceUrlForHeaderSave = () => {
  const paramDraft = paramDraftRef; // undefined during headerDraft init
  ...
};

const headerDraft = useRequestHeaderDraftController({ sourceUrl: sourceUrlForHeaderSave, ... });
// ...
const paramDraft = useRequestParamDraftController(...);
paramDraftRef = paramDraft; // assigned after

This is safe only because sourceUrlForHeaderSave is never called during headerDraft's constructor — it is only invoked later when the user triggers a save. But this is an implicit contract not enforced by the type system. If useRequestHeaderDraftController's initialization ever calls sourceUrl() eagerly (e.g. to pre-populate state), paramDraftRef will be undefined and the cross-tab sync will silently degrade to returning the raw requestUrl.

A more robust pattern would be to initialise paramDraft first and pass a lambda that closes over the already-initialised value, or to restructure so headerDraft is initialized after paramDraft.

@andrewmelchor
Copy link
Copy Markdown
Member Author

rolling this up myself

@andrewmelchor andrewmelchor deleted the feat(web)/request-params branch March 6, 2026 05:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant