From 12ed08e69077e27d3de11f139a017496a0e0cef0 Mon Sep 17 00:00:00 2001
From: Mathias Picker <48158184+MathiasWP@users.noreply.github.com>
Date: Mon, 24 Aug 2026 18:17:08 +0200
Subject: [PATCH] Open where you left off
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Collections, requests and responses were already durable. Everything about
looking at them was not: the selected endpoint, both panes' tabs, the sidebar
tab, which loader folders were expanded, and whatever had been typed into the
scratch request. Every launch started on an empty scratch request with the
sidebar back on Collections — a small thing after a deliberate quit, and a rude
one after an auto-update restart nobody asked for.
All of it now lives in `session.svelte.ts`, one blob in local storage, restored
before the first frame rather than a moment after it, so the window opens as it
was rather than visibly rearranging itself. A stored endpoint that no longer
exists falls back to the scratch request rather than leaving the pane blank.
An update restart also flushes the debounced section writes now. It never did:
Rust holds an exit long enough for the frontend to save, but only for an exit it
did not ask for itself, and `relaunch` carries a restart code that sails past
that handler. The last few hundred milliseconds of edits went with the old
process. The same flush runs on `pagehide`, covering a webview torn down without
going through either quit path.
Window geometry needed nothing — tauri-plugin-window-state already writes it on
exit, and the restart path does run through exit.
---
.changeset/open-where-you-left-off.md | 13 ++
src/lib/components/Sidebar.svelte | 40 ++--
src/lib/session.svelte.ts | 263 ++++++++++++++++++++++++++
src/lib/update.svelte.ts | 10 +
src/routes/+page.svelte | 70 ++++---
tests/e2e/session.spec.ts | 153 +++++++++++++++
6 files changed, 504 insertions(+), 45 deletions(-)
create mode 100644 .changeset/open-where-you-left-off.md
create mode 100644 src/lib/session.svelte.ts
create mode 100644 tests/e2e/session.spec.ts
diff --git a/.changeset/open-where-you-left-off.md b/.changeset/open-where-you-left-off.md
new file mode 100644
index 0000000..55bb0ca
--- /dev/null
+++ b/.changeset/open-where-you-left-off.md
@@ -0,0 +1,13 @@
+---
+'fiber': patch
+---
+
+Open where you left off.
+
+Collections, requests and responses were already durable. Everything about *looking* at them was not: which endpoint was open, which tab of it, which sidebar tab, which loader folders were expanded, and whatever had been typed into the scratch request. Every launch started on an empty scratch request with the sidebar back on Collections — which is a small thing after a deliberate quit, and a rude one after an auto-update restart nobody asked for.
+
+All of it is now stored, and restored before the first frame rather than a moment after it, so the window opens as it was rather than visibly rearranging itself. A stored endpoint that no longer exists — deleted elsewhere, or dropped by a loader — falls back to the scratch request instead of leaving the pane blank.
+
+An update restart also flushes the debounced section writes now. It never did: Rust holds an exit long enough for the frontend to save, but only for an exit it did not ask for itself, and a restart carries a code that sails straight past that. The last few hundred milliseconds of edits went with the old process. The same flush runs on `pagehide`, which covers a webview torn down and brought back without going through either quit path.
+
+Window size and position needed nothing — `tauri-plugin-window-state` already writes those on exit, and the restart path does run through exit.
diff --git a/src/lib/components/Sidebar.svelte b/src/lib/components/Sidebar.svelte
index a5c67c2..d6980ff 100644
--- a/src/lib/components/Sidebar.svelte
+++ b/src/lib/components/Sidebar.svelte
@@ -18,6 +18,7 @@
type DragHint
} from '$lib/dnd.svelte';
import { history, type HistoryEntry } from '$lib/history.svelte';
+ import { session } from '$lib/session.svelte';
import { theme } from '$lib/theme.svelte';
import DotLoader from '$lib/components/DotLoader.svelte';
import McpTab from '$lib/components/McpTab.svelte';
@@ -32,8 +33,6 @@
let { onOpenSettings, onPickHistory }: Props = $props();
- let tab = $state<'collections' | 'history' | 'mcp'>('collections');
-
// Read from the bundle rather than package.json, so what the footer shows is
// the version that is actually running — which is the number to quote when
// something misbehaves.
@@ -211,26 +210,27 @@
return order.map((tag) => ({ tag, rows: groups.get(tag)! }));
}
+ function tagKey(sectionId: string, tag: string): string {
+ return `${sectionId}\0${tag}`;
+ }
+
/**
* Folders the user has opened. Closed is the default: a loader can report
* hundreds of endpoints across a dozen tags, and opening a collection to a
* wall of them is no better than not grouping at all. The folder names are
* the map; you open the one you want.
+ *
+ * Which ones are open is kept in the session rather than here, so the
+ * folders you had open are still open the next time the app starts.
*/
- let openTags = $state>({});
-
- function tagKey(sectionId: string, tag: string): string {
- return `${sectionId}\0${tag}`;
- }
-
function tagOpen(sectionId: string, tag: string): boolean {
if (searching || !tag) return true;
- return openTags[tagKey(sectionId, tag)] ?? false;
+ return session.openTags[tagKey(sectionId, tag)] ?? false;
}
function toggleTag(sectionId: string, tag: string): void {
const key = tagKey(sectionId, tag);
- openTags[key] = !openTags[key];
+ session.openTags[key] = !session.openTags[key];
}
function loadMoreEndpoints(sectionId: string): void {
@@ -303,7 +303,7 @@
* kept showing that entry rather than the request's own current response.
*/
function showCollections() {
- tab = 'collections';
+ session.sidebarTab = 'collections';
history.stopViewing();
}
@@ -312,7 +312,7 @@
* were looking at stops overriding the response pane.
*/
function showMcp() {
- tab = 'mcp';
+ session.sidebarTab = 'mcp';
history.stopViewing();
}
@@ -807,7 +807,7 @@
- {:else if responseTab === 'pretty'}
+ {:else if session.responseTab === 'pretty'}
{/if}
{#if responseSchemaErrors.length}
@@ -1279,7 +1299,7 @@
- {#if responseTab === 'raw'}
+ {#if session.responseTab === 'raw'}
{/if}
diff --git a/tests/e2e/session.spec.ts b/tests/e2e/session.spec.ts
new file mode 100644
index 0000000..ef0e8f9
--- /dev/null
+++ b/tests/e2e/session.spec.ts
@@ -0,0 +1,153 @@
+import { expect, test } from '@playwright/test';
+import { install, response, savedRequest, section } from './mock-ipc';
+
+/**
+ * Opening where you left off — `src/lib/session.svelte.ts`.
+ *
+ * A reload stands in for a relaunch: the webview starts from nothing either
+ * way, and the mock backend replies with the same fixtures both times, which
+ * is exactly what a real restart onto the same files looks like.
+ */
+
+const users = savedRequest();
+
+const loader = {
+ enabled: true,
+ url: '/openapi.json',
+ method: 'GET',
+ query: '.paths',
+ next: '',
+ ttlSeconds: 0
+};
+
+test('the endpoint that was open is open again', async ({ page }) => {
+ await install(page, { sections: [section({ requests: [users] })] });
+ await page.goto('/');
+ await page.getByText('List users').click();
+ await expect(page.getByPlaceholder('/user/get')).toHaveValue('/users');
+
+ await page.reload();
+ // No click this time: the request pane comes back on the same request.
+ await expect(page.getByPlaceholder('/user/get')).toHaveValue('/users');
+ await expect(page.getByText('https://api.acme.com', { exact: true })).toBeVisible();
+});
+
+test('the scratch request comes back with what was typed into it', async ({ page }) => {
+ await install(page);
+ await page.goto('/');
+
+ await page.getByPlaceholder('https://api.example.com/users').fill('https://example.com/ping');
+
+ await page.reload();
+ await expect(page.getByPlaceholder('https://api.example.com/users')).toHaveValue(
+ 'https://example.com/ping'
+ );
+ await expect(page.getByRole('button', { name: 'Send' })).toBeEnabled();
+});
+
+test('the sidebar tab that was open is open again', async ({ page }) => {
+ await install(page, { sections: [section({ requests: [users] })] });
+ await page.goto('/');
+ await page.getByRole('button', { name: 'History', exact: true }).click();
+ await expect(page.getByPlaceholder('Search history…')).toBeVisible();
+
+ await page.reload();
+ await expect(page.getByPlaceholder('Search history…')).toBeVisible();
+});
+
+test('the response tab that was open is open again', async ({ page }) => {
+ await install(page, {
+ sendResponse: response({ body: '{"hello":"world"}' }),
+ sections: [section({ requests: [users] })]
+ });
+ await page.goto('/');
+ await page.getByText('List users').click();
+ await page.getByRole('button', { name: 'Send' }).click();
+ await page.getByRole('tab', { name: 'Raw' }).click();
+ await expect(page.getByRole('tab', { name: 'Raw' })).toHaveAttribute('data-state', 'active');
+
+ await page.reload();
+ await page.getByText('List users').click();
+ await page.getByRole('button', { name: 'Send' }).click();
+ await expect(page.getByRole('tab', { name: 'Raw' })).toHaveAttribute('data-state', 'active');
+});
+
+test('a folder that was opened is still open', async ({ page }) => {
+ await install(page, {
+ sections: [section({ loader })],
+ loaded: [
+ {
+ method: 'GET',
+ path: '/users',
+ name: 'List users',
+ description: '',
+ tag: 'Users',
+ body: ''
+ }
+ ]
+ });
+ await page.goto('/');
+
+ // Folders start closed, so the endpoint inside is not on screen yet.
+ await expect(page.getByText('List users')).toBeHidden();
+ await page.getByRole('button', { name: /^Users/ }).click();
+ await expect(page.getByText('List users')).toBeVisible();
+
+ await page.reload();
+ await expect(page.getByText('List users')).toBeVisible();
+});
+
+test('a stored selection whose request is gone falls back to scratch', async ({ page }) => {
+ await install(page, { sections: [section({ requests: [users] })] });
+ // A request deleted in another window, or one a loader has stopped
+ // reporting: the id resolves to nothing, and the pane must not be left
+ // blank waiting for it.
+ await page.addInitScript(() =>
+ localStorage.setItem(
+ 'fiber:session',
+ JSON.stringify({ requestId: 'long-gone', sectionId: 'sec-1' })
+ )
+ );
+ await page.goto('/');
+
+ await expect(page.getByPlaceholder('https://api.example.com/users')).toBeVisible();
+ await expect(page.getByRole('button', { name: 'Send' })).toBeDisabled();
+});
+
+test('a session blob that is nonsense is ignored rather than fatal', async ({ page }) => {
+ await install(page, { sections: [section({ requests: [users] })] });
+ await page.addInitScript(() => localStorage.setItem('fiber:session', '{not json'));
+ await page.goto('/');
+
+ await expect(page.getByText('List users')).toBeVisible();
+ await expect(page.getByPlaceholder('https://api.example.com/users')).toBeVisible();
+});
+
+test('an update restart saves the pending edit before relaunching', async ({ page }) => {
+ await install(page, {
+ update: { version: '1.2.3' },
+ sections: [section({ requests: [users] })]
+ });
+ await page.goto('/');
+ await page.getByText('List users').click();
+
+ // Inside the save debounce, which is the whole point: a restart nobody
+ // asked for lands mid-edit, and the write has to be forced before the
+ // process goes away.
+ await page.getByPlaceholder('/user/get').fill('/edited');
+ await page.getByRole('button', { name: 'Update' }).click();
+
+ await expect.poll(() => order(page)).toEqual({ saved: true, savedBeforeRestart: true });
+ const saved = await page.evaluate(() => window.__FIBER_TEST__.lastSaved);
+ expect(JSON.stringify(saved)).toContain('/edited');
+});
+
+/** Where the last save landed relative to the restart, if both happened. */
+function order(page: import('@playwright/test').Page) {
+ return page.evaluate(() => {
+ const cmds = window.__FIBER_TEST__.calls.map((call) => call.cmd);
+ const restart = cmds.indexOf('plugin:process|restart');
+ const saved = cmds.lastIndexOf('save_section');
+ return { saved: saved !== -1, savedBeforeRestart: restart !== -1 && saved < restart };
+ });
+}