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(); +});