Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/select-by-section-too.md
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 51 additions & 7 deletions src/lib/collections.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ function rejectedCredential(message: string): boolean {
class Collections {
sections = $state<Section[]>([]);
selectedRequestId = $state<string | null>(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<string | null>(null);
loaded = $state(false);
error = $state<string | null>(null);

Expand Down Expand Up @@ -182,21 +198,37 @@ class Collections {
}

get selected(): Selection | null {
return this.findRequest(this.selectedRequestId);
return this.findRequest(this.selectedRequestId, this.selectedSectionId);
}

/**
* The request an id names, if one still exists.
*
* 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;
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}
}
Expand Down Expand Up @@ -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<void> {
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);
}

Expand All @@ -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);
}
}
Expand Down
25 changes: 20 additions & 5 deletions src/lib/components/Sidebar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -563,8 +578,8 @@
<ContextMenu.Root>
<ContextMenu.Trigger
class="flex items-center gap-2 {indent} pr-4 py-1 w-full text-left cursor-default transition-colors hover:bg-raised
{collections.selectedRequestId === request.id ? 'bg-raised' : ''}"
onclick={() => selectRequest(request.id)}
{isSelected(section, request.id) ? 'bg-raised' : ''}"
onclick={() => selectRequest(section, request.id)}
>
<span
class="font-mono text-2.5 font-bold shrink-0 w-9 {methodColor(request.method)}"
Expand Down Expand Up @@ -648,7 +663,7 @@
<ContextMenu.Root>
<ContextMenu.Trigger
class="flex items-center gap-2 {indent} pr-4 py-1 w-full text-left cursor-default transition-colors hover:bg-raised
{collections.selectedRequestId === row.request.id ? 'bg-raised' : ''}"
{isSelected(section, row.request.id) ? 'bg-raised' : ''}"
onclick={() => selectLoaded(section, row)}
>
<span
Expand Down
10 changes: 9 additions & 1 deletion src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -645,12 +645,20 @@
// request come back showing whichever entry you last inspected.
history.viewingId = entry.id;

if (collections.findRequest(entry.requestId)) {
// A history entry names a request but not the collection it was in, so an
// id two collections share resolves to whichever sorts first. Keeping the
// section it resolved to at least makes the selection self-consistent —
// the pane and the highlighted row agree — rather than leaving the
// section pointing at whatever was open before.
const found = collections.findRequest(entry.requestId);
if (found) {
collections.selectedRequestId = entry.requestId;
collections.selectedSectionId = found.section.id;
return;
}

collections.selectedRequestId = null;
collections.selectedSectionId = null;
scratch.method = entry.method;
scratch.path = entry.url;
// Unconditionally, including when empty: what's on screen has to be what
Expand Down
65 changes: 65 additions & 0 deletions tests/e2e/collections.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,68 @@ test('right-clicking the empty list offers to create either kind of thing', asyn
await expect(page.getByRole('menuitem', { name: 'New collection' })).toBeVisible();
await expect(page.getByRole('menuitem', { name: 'New request' })).toBeVisible();
});

/**
* Two collections describing the same API — staging and production — give every
* loaded endpoint the same id, because a loaded id is `METHOD /path` and carries
* no section. Selecting by that id alone lit both rows, resolved to whichever
* collection sorted first, and left the second one unopenable: clicking it set
* an id already held, so nothing changed.
*/
test('the same endpoint in two collections selects independently', async ({ page }) => {
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();
});
Loading