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/history-knows-its-collection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'fiber': patch
---

Keep each collection's response history to itself.

Response history was bucketed by request id alone. A loaded endpoint's id is `METHOD /path` and carries no section, so two collections describing the same API — staging and production — shared one list: opening either showed whichever had been sent last, and clearing one deleted both.

The database has stored `section_id` since the column was added; it was simply never handed back. It is now, so the window can tell the two apart, and clearing is scoped to the collection you cleared.

Entries recorded before this still show for either collection rather than disappearing, since nothing knows which one they came from. A scoped clear takes them too — they are the same request's older entries, and leaving them behind would look like the clear half-worked.
35 changes: 31 additions & 4 deletions src-tauri/src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,15 @@ pub struct HistoryRecord {
pub id: String,
/// The saved request this belongs to, or `scratch`.
pub request_id: String,
/// The collection it was sent from, when it had one.
///
/// Stored since the column was added but never handed back, which left the
/// window unable to tell two collections apart: a request id is unique
/// within a section, and a loaded endpoint's id — `METHOD /path` — is the
/// same in every collection describing the same API. `None` for entries
/// written before this was returned, and for a scratch send.
#[serde(default)]
pub section_id: Option<String>,
/// Epoch milliseconds.
pub at: i64,
pub method: String,
Expand Down Expand Up @@ -285,7 +294,7 @@ impl HistoryStore {
let mut statement = connection.prepare(
"SELECT id, request_id, at, method, url, request_body, error,
status, status_text, final_url, headers, is_binary, truncated,
size_bytes, ttfb_ms, total_ms
size_bytes, ttfb_ms, total_ms, section_id
FROM history ORDER BY at DESC LIMIT ?1",
)?;

Expand Down Expand Up @@ -326,6 +335,7 @@ impl HistoryStore {
Ok(HistoryRecord {
id: row.get(0)?,
request_id: row.get(1)?,
section_id: row.get(16).unwrap_or_default(),
at: row.get(2)?,
method: row.get(3)?,
url: row.get(4)?,
Expand Down Expand Up @@ -396,8 +406,25 @@ impl HistoryStore {
self.remove_where("id = ?1", params![id])
}

pub fn clear_request(&self, request_id: &str) -> Result<(), HistoryError> {
self.remove_where("request_id = ?1", params![request_id])
/// Clears one request's history, within one collection when given.
///
/// Unscoped still means every collection, which is what a scratch request
/// and an entry written before `section_id` was returned both need. Scoped
/// also takes the null-section rows for that id: they are the same
/// request's older entries, and leaving them behind would look like the
/// clear half-worked.
pub fn clear_request(
&self,
request_id: &str,
section_id: Option<&str>,
) -> Result<(), HistoryError> {
match section_id {
Some(section) => self.remove_where(
"request_id = ?1 AND (section_id = ?2 OR section_id IS NULL)",
params![request_id, section],
),
None => self.remove_where("request_id = ?1", params![request_id]),
}
}

pub fn clear_all(&self) -> Result<(), HistoryError> {
Expand Down Expand Up @@ -673,7 +700,7 @@ mod tests {
.record(&spec("b1", "req-b"), 2, "u", &Ok(response("{}")))
.unwrap();

store.clear_request("req-a").unwrap();
store.clear_request("req-a", None).unwrap();
let left = store.list(10).unwrap();
assert_eq!(left.len(), 1);
assert_eq!(left[0].request_id, "req-b");
Expand Down
3 changes: 2 additions & 1 deletion src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -607,8 +607,9 @@ mod gui {
async fn history_clear_request(
log: State<'_, HistoryStore>,
request_id: String,
section_id: Option<String>,
) -> Result<(), HistoryError> {
log.clear_request(&request_id)
log.clear_request(&request_id, section_id.as_deref())
}

#[tauri::command]
Expand Down
6 changes: 4 additions & 2 deletions src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ export interface ResponseData extends ResponseMeta {
export interface HistoryRecord {
id: string;
requestId: string;
/** The collection it was sent from. Null for scratch, and for old entries. */
sectionId: string | null;
at: number;
method: string;
url: string;
Expand All @@ -77,8 +79,8 @@ export function historyDelete(id: string): Promise<void> {
return invoke<void>('history_delete', { id });
}

export function historyClearRequest(requestId: string): Promise<void> {
return invoke<void>('history_clear_request', { requestId });
export function historyClearRequest(requestId: string, sectionId?: string | null): Promise<void> {
return invoke<void>('history_clear_request', { requestId, sectionId: sectionId ?? null });
}

export function historyClearAll(): Promise<void> {
Expand Down
78 changes: 62 additions & 16 deletions src/lib/history.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ export interface HistoryEntry {
id: string;
/** The saved request this belongs to, or `SCRATCH_ID`. */
requestId: string;
/**
* The collection it was sent from, when it had one.
*
* A request id is unique only *within* a section, and a loaded endpoint's id
* — `METHOD /path` — is identical in every collection describing the same
* API. Bucketing on the id alone put staging's and production's replies in
* one list, so opening either showed whichever was sent last.
*
* `null` for a scratch send, and for anything recorded before this was
* returned; those match any section rather than disappearing.
*/
sectionId: string | null;
at: number;
method: string;
url: string;
Expand All @@ -33,6 +45,7 @@ function fromRecord(record: HistoryRecord): HistoryEntry {
return {
id: record.id,
requestId: record.requestId,
sectionId: record.sectionId ?? null,
at: record.at,
method: record.method,
url: record.url,
Expand Down Expand Up @@ -76,20 +89,41 @@ class History {
}
}

/** Newest first. */
forRequest(requestId: string): HistoryEntry[] {
return this.entries.filter((entry) => entry.requestId === requestId);
/**
* The bucket a request's entries live in.
*
* Section first, because a request id is only unique inside one: two
* collections describing the same API give every loaded endpoint the same
* id, and keying on that alone merged their histories.
*/
static #bucket(requestId: string, sectionId: string | null | undefined): string {
return sectionId ? `${sectionId}\u0000${requestId}` : requestId;
}

/**
* Newest first.
*
* An entry with no section belongs to whichever collection asks. It was
* written before the section came back from the database, and the request it
* names is real — dropping it would look like history had been lost.
*/
forRequest(requestId: string, sectionId?: string | null): HistoryEntry[] {
return this.entries.filter(
(entry) =>
entry.requestId === requestId &&
(!sectionId || !entry.sectionId || entry.sectionId === sectionId)
);
}

/** The entry on screen for a request — an explicit pick, else its newest. */
selectedFor(requestId: string): HistoryEntry | undefined {
const mine = this.forRequest(requestId);
const picked = this.#selected[requestId];
selectedFor(requestId: string, sectionId?: string | null): HistoryEntry | undefined {
const mine = this.forRequest(requestId, sectionId);
const picked = this.#selected[History.#bucket(requestId, sectionId)];
return mine.find((entry) => entry.id === picked) ?? mine[0];
}

select(requestId: string, entryId: string): void {
this.#selected[requestId] = entryId;
select(requestId: string, entryId: string, sectionId?: string | null): void {
this.#selected[History.#bucket(requestId, sectionId)] = entryId;
}

/** The entry opened from the History tab, if it's still around. */
Expand Down Expand Up @@ -125,7 +159,7 @@ class History {
// Sending shows the new response, not whatever history was open.
this.viewingId = null;
this.entries.unshift({ ...entry, pending: true, bodyLoaded: false });
this.select(entry.requestId, entry.id);
this.select(entry.requestId, entry.id, entry.sectionId);
}

/**
Expand Down Expand Up @@ -213,18 +247,30 @@ class History {
}
}

async clearFor(requestId: string): Promise<void> {
const removed = this.entries.filter((entry) => entry.requestId === requestId);
const picked = this.#selected[requestId];
this.entries = this.entries.filter((entry) => entry.requestId !== requestId);
delete this.#selected[requestId];
/**
* Clears one request's history, in one collection when it has one.
*
* Scoped by the same rule `forRequest` reads by, so what disappears is
* exactly what was on screen — clearing staging used to take production's
* entries with it, since both sat under one key.
*/
async clearFor(requestId: string, sectionId?: string | null): Promise<void> {
const mine = (entry: HistoryEntry) =>
entry.requestId === requestId &&
(!sectionId || !entry.sectionId || entry.sectionId === sectionId);

const removed = this.entries.filter(mine);
const key = History.#bucket(requestId, sectionId);
const picked = this.#selected[key];
this.entries = this.entries.filter((entry) => !mine(entry));
delete this.#selected[key];
try {
await historyClearRequest(requestId);
await historyClearRequest(requestId, sectionId);
} catch (error) {
// Newest-first order survives: the survivors kept theirs, and the
// removed slice kept its own, so a merge by timestamp restores both.
this.entries = [...this.entries, ...removed].sort((a, b) => b.at - a.at);
if (picked !== undefined) this.#selected[requestId] = picked;
if (picked !== undefined) this.#selected[key] = picked;
this.error = String(error);
}
}
Expand Down
13 changes: 11 additions & 2 deletions src/routes/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@
bodyKind === 'json' ? validateJsonBody(bodySchema, draft.body) : []
);
const requestKey = $derived(selection?.request.id ?? SCRATCH_ID);
/**
* Which collection the history bucket belongs to.
*
* `requestKey` alone is not a bucket: a loaded endpoint's id is the same in
* every collection describing the same API, so staging and production shared
* one list and each showed whichever was sent last.
*/
const requestSection = $derived(selection?.section.id ?? null);
const baseUrl = $derived(selection?.section.baseUrl ?? '');
const bodilessMethod = $derived(draft.method === 'GET' || draft.method === 'HEAD');
/**
Expand Down Expand Up @@ -141,7 +149,7 @@

// An entry opened from the History tab wins; otherwise a request shows its
// own most recent response and never another request's.
const shown = $derived(history.viewing ?? history.selectedFor(requestKey));
const shown = $derived(history.viewing ?? history.selectedFor(requestKey, requestSection));

/**
* The section to sign back into, when the shown response reads as the API
Expand Down Expand Up @@ -554,6 +562,7 @@
history.start({
id,
requestId: requestKey,
sectionId: requestSection,
at: Date.now(),
method: draft.method,
url,
Expand Down Expand Up @@ -1318,7 +1327,7 @@
<ContextMenu.Separator class="menu-separator" />
<ContextMenu.Item
class="menu-item-bad"
onSelect={() => history.clearFor(requestKey)}
onSelect={() => history.clearFor(requestKey, requestSection)}
>
<span class="i-lucide-trash-2 text-3"></span>
Clear this request's history
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/mock-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export function historyRecord(over: Partial<HistoryRecord> = {}): HistoryRecord
return {
id: 'h1',
requestId: 'r1',
sectionId: null,
at: 1_700_000_000_000,
method: 'GET',
url: 'https://api.acme.com/users',
Expand Down
Loading
Loading