From c3ab16df069cfccd7572cca8360a4201dc886a97 Mon Sep 17 00:00:00 2001 From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:27:47 +0200 Subject: [PATCH] Select an endpoint in the collection you clicked it in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two collections describing the same API — staging and production — give every loaded endpoint the same id. That is not an accident: a loaded id is `endpointKey`, `"GET /users"`, and it carries no section because it is the identity a saved body and a refresh have to agree on. Putting the section in it would orphan every overlay entry on disk. Selection was keyed on that id alone, so all three of these were the same bug: both rows highlighted, `findRequest` answered with whichever collection sorted first regardless of which you clicked, and the second one could not be opened at all — the click set an id the store already held, so nothing changed and the row was inert. So the selection carries `selectedSectionId` alongside the id, and the sidebar's highlight compares both. `findRequest` takes the section as a *preference* rather than a filter: a history entry recorded before this existed names no section, and answering nothing for it would lose the request it points at, so the search still falls back to the first match. The regression test asserts the highlight count as well as the resolution. Without it the test passed against the broken build — the base-URL chip is driven by `selected`, which the store fix alone repairs, and the duplicated highlight needs its own assertion to be caught. Reverting `isSelected` now fails it with "Expected: 1, Received: 2". Response history is still bucketed by request id, so staging and production share one history for the same endpoint. Same root cause, not fixed here: re-keying it would orphan entries already written, and that wants its own change with a fallback rather than a silent loss. --- .changeset/select-by-section-too.md | 11 +++++ src/lib/collections.svelte.ts | 58 +++++++++++++++++++++---- src/lib/components/Sidebar.svelte | 25 ++++++++--- src/routes/+page.svelte | 10 ++++- tests/e2e/collections.spec.ts | 65 +++++++++++++++++++++++++++++ 5 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 .changeset/select-by-section-too.md diff --git a/.changeset/select-by-section-too.md b/.changeset/select-by-section-too.md new file mode 100644 index 0000000..ae01181 --- /dev/null +++ b/.changeset/select-by-section-too.md @@ -0,0 +1,11 @@ +--- +'fiber': patch +--- + +Select an endpoint in the collection you clicked it in. + +Two collections describing the same API — staging and production — give every loaded endpoint the same id, because a loaded id is `METHOD /path` and deliberately carries no section: that is the identity a saved body and a refresh have to agree on, so a re-run re-attaches instead of orphaning. + +Selection was keyed on that id alone. So both rows highlighted at once, the pane always resolved to whichever collection sorted first, and the second one could not be opened at all — clicking it set an id the store already held, so nothing changed. The selection now carries the section as well. + +Note that response history is still bucketed by request id, so the same endpoint in two collections shares one history. That is the same root cause and is not fixed here. diff --git a/src/lib/collections.svelte.ts b/src/lib/collections.svelte.ts index c022e25..1443601 100644 --- a/src/lib/collections.svelte.ts +++ b/src/lib/collections.svelte.ts @@ -76,6 +76,22 @@ function rejectedCredential(message: string): boolean { class Collections { sections = $state([]); selectedRequestId = $state(null); + /** + * Which collection the selected request belongs to. + * + * A request id is only unique *within* a section. A loaded endpoint's id is + * `endpointKey` — `"GET /users"` — because that is the identity a saved body + * and a refresh have to agree on, and it is deliberately free of the section + * so a re-run re-attaches rather than orphaning. The cost is that two + * collections describing the same API, staging and production, give every + * endpoint the same id in both. + * + * Selecting by id alone then meant both rows highlighted, `findRequest` + * always answered with whichever collection sorted first, and clicking the + * other one set the id it already held — so nothing changed and the row + * could not be opened at all. + */ + selectedSectionId = $state(null); loaded = $state(false); error = $state(null); @@ -182,7 +198,7 @@ class Collections { } get selected(): Selection | null { - return this.findRequest(this.selectedRequestId); + return this.findRequest(this.selectedRequestId, this.selectedSectionId); } /** @@ -190,13 +206,29 @@ class Collections { * * History entries outlive their requests — a request can be deleted, and * some entries never had one, having come from a loader or the MCP server. + * + * `sectionId` disambiguates an id two collections both hold, which is every + * loaded endpoint when the same API is set up twice. It is a preference + * rather than a filter: a history entry recorded before this existed names + * no section, and answering nothing for it would lose the request it points + * at — so the search falls back to the first match, which is what it always + * did. */ - findRequest(id: string | null): Selection | null { + findRequest(id: string | null, sectionId?: string | null): Selection | null { if (!id) return null; + + const inSection = (section: Section) => + section.requests.find((candidate) => candidate.id === id) ?? + section.overlay.find((candidate) => candidate.id === id); + + if (sectionId) { + const named = this.sections.find((section) => section.id === sectionId); + const request = named && inSection(named); + if (named && request) return { section: named, request }; + } + for (const section of this.sections) { - const request = - section.requests.find((candidate) => candidate.id === id) ?? - section.overlay.find((candidate) => candidate.id === id); + const request = inSection(section); if (request) return { section, request }; } return null; @@ -309,6 +341,7 @@ class Collections { this.touch(section); } this.selectedRequestId = request.id; + this.selectedSectionId = section.id; } /** Requests belonging to no collection, if any have been made. */ @@ -730,10 +763,15 @@ class Collections { // mirror is worse than a row that briefly came back. const index = this.sections.findIndex((candidate) => candidate.id === section.id); const selected = this.selectedRequestId; + const selectedIn = this.selectedSectionId; this.sections = this.sections.filter((candidate) => candidate.id !== section.id); - if (section.requests.some((r) => r.id === this.selectedRequestId)) { + // The whole collection is going, so anything selected inside it goes too — + // by section rather than by request id, which the collection next door may + // also hold. + if (this.selectedSectionId === section.id) { this.selectedRequestId = null; + this.selectedSectionId = null; } clearTimeout(this.#timers.get(section.id)); this.#timers.delete(section.id); @@ -744,6 +782,7 @@ class Collections { restored.splice(Math.max(0, index), 0, section); this.sections = restored; this.selectedRequestId = selected; + this.selectedSectionId = selectedIn; this.error = String(error); } } @@ -773,13 +812,17 @@ class Collections { target.requests.push(request); target.collapsed = false; this.selectedRequestId = request.id; + this.selectedSectionId = target.id; await this.flush(target); return request; } async removeRequest(section: Section, request: SavedRequest): Promise { section.requests = section.requests.filter((candidate) => candidate.id !== request.id); - if (this.selectedRequestId === request.id) this.selectedRequestId = null; + if (this.selectedRequestId === request.id && this.selectedSectionId === section.id) { + this.selectedRequestId = null; + this.selectedSectionId = null; + } await this.flush(section); } @@ -792,6 +835,7 @@ class Collections { const at = section.requests.findIndex((candidate) => candidate.id === request.id); section.requests.splice(at + 1, 0, copy); this.selectedRequestId = copy.id; + this.selectedSectionId = section.id; await this.flush(section); } } diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte index 5823e58..a5c67c2 100644 --- a/src/lib/components/Sidebar.svelte +++ b/src/lib/components/Sidebar.svelte @@ -316,9 +316,21 @@ history.stopViewing(); } - function selectRequest(id: string) { + function selectRequest(section: Section, id: string) { history.stopViewing(); collections.selectedRequestId = id; + collections.selectedSectionId = section.id; + } + + /** + * Whether this row is the selected one. + * + * By section *and* id: two collections describing the same API give every + * loaded endpoint the same id, so comparing the id alone lit up the row in + * both of them. + */ + function isSelected(section: Section, id: string): boolean { + return collections.selectedRequestId === id && collections.selectedSectionId === section.id; } function selectLoaded(section: Section, row: LoadedRow) { @@ -350,7 +362,10 @@ /** Drops user data for an endpoint the loader no longer reports. */ function dropOverlay(section: Section, id: string) { section.overlay = section.overlay.filter((entry) => entry.id !== id); - if (collections.selectedRequestId === id) collections.selectedRequestId = null; + if (isSelected(section, id)) { + collections.selectedRequestId = null; + collections.selectedSectionId = null; + } collections.flush(section); } @@ -563,8 +578,8 @@ selectRequest(request.id)} + {isSelected(section, request.id) ? 'bg-raised' : ''}" + onclick={() => selectRequest(section, request.id)} > selectLoaded(section, row)} > { + await install(page, { + sections: [ + section({ + id: 'staging', + name: 'Staging', + baseUrl: 'https://staging.acme.com', + order: 0, + loader: { + enabled: true, + url: '/openapi.json', + method: 'GET', + query: '.', + next: '', + ttlSeconds: 0 + } + }), + section({ + id: 'prod', + name: 'Production', + baseUrl: 'https://api.acme.com', + order: 1, + loader: { + enabled: true, + url: '/openapi.json', + method: 'GET', + query: '.', + next: '', + ttlSeconds: 0 + } + }) + ], + loaded: [{ method: 'GET', path: '/users', name: 'List users', description: '', body: '' }] + }); + await page.goto('/'); + + const rows = page.getByText('List users', { exact: true }); + await expect(rows).toHaveCount(2); + + // Selecting in staging must not light up production's copy. `bg-raised` is + // what marks the selected row, so exactly one of the two carries it. + const highlighted = page.locator('.cursor-default.bg-raised', { hasText: 'List users' }); + + await rows.nth(0).click(); + await expect(page.getByText('https://staging.acme.com', { exact: true })).toBeVisible(); + await expect(highlighted).toHaveCount(1); + + // And production is still reachable, which it was not when the id alone + // decided: the click set a value the store already held. + await rows.nth(1).click(); + await expect(page.getByText('https://api.acme.com', { exact: true })).toBeVisible(); + await expect(highlighted).toHaveCount(1); + + // Back again, to prove it is not one-way. + await rows.nth(0).click(); + await expect(page.getByText('https://staging.acme.com', { exact: true })).toBeVisible(); +});