From d155c14ff30d0c59b92ea7fc7b76e0391c21769e Mon Sep 17 00:00:00 2001 From: teyepe Date: Fri, 2 Oct 2026 22:42:18 +0100 Subject: [PATCH 1/5] feat(desktop): restore last editing point across session interruptions Persist catalog and editor route state per session, transfer it on preview conversion, compose workspace load hashes on reopen/recovery, and reopen clean sessions after update restart when a save target exists. --- apps/desktop/e2e/document-crash.spec.ts | 29 +++- apps/desktop/e2e/document-lifecycle.spec.ts | 24 ++- apps/desktop/e2e/document-recovery.spec.ts | 15 +- apps/desktop/src/main/app/App.ts | 65 ++++++- apps/desktop/src/main/docs/DOCS.md | 5 +- .../src/main/document/DocumentSession.ts | 5 + .../main/viewResume/persistViewResume.test.ts | 41 +++++ .../src/main/viewResume/persistViewResume.ts | 123 +++++++++++++ apps/desktop/src/preload/docs/DOCS.md | 4 +- apps/desktop/src/preload/preload.ts | 2 + apps/desktop/src/renderer/src/app/Screens.tsx | 22 ++- .../home/GlyphCatalogBackendGate.tsx | 6 + .../src/components/home/GlyphGrid.tsx | 8 + .../components/home/SvgGlyphCatalogGrid.tsx | 17 +- .../src/context/GlyphCatalogProvider.tsx | 162 +++++++++++++++--- .../src/context/PreviewNoticeProvider.tsx | 5 +- .../src/context/ViewResumeCaptureContext.ts | 16 ++ .../src/context/ViewResumeProvider.tsx | 77 +++++++++ .../renderer/src/context/glyphCatalogRoute.ts | 15 ++ .../src/lib/workspace/resumeGlyph.test.ts | 38 ++++ .../renderer/src/lib/workspace/resumeGlyph.ts | 32 ++++ .../lib/workspace/viewResumeCapture.test.ts | 32 ++++ .../src/lib/workspace/viewResumeCapture.ts | 23 +++ .../src/lib/workspace/viewResumeFlags.ts | 12 ++ .../lib/workspace/viewResumeRestoreActive.ts | 7 + .../src/renderer/src/types/glyphCatalog.ts | 11 ++ apps/desktop/src/shared/host/ShiftHost.ts | 3 + apps/desktop/src/shared/ipc/contract.ts | 5 + apps/desktop/src/shared/viewResume.ts | 49 ++++++ 29 files changed, 813 insertions(+), 40 deletions(-) create mode 100644 apps/desktop/src/main/viewResume/persistViewResume.test.ts create mode 100644 apps/desktop/src/main/viewResume/persistViewResume.ts create mode 100644 apps/desktop/src/renderer/src/context/ViewResumeCaptureContext.ts create mode 100644 apps/desktop/src/renderer/src/context/ViewResumeProvider.tsx create mode 100644 apps/desktop/src/renderer/src/context/glyphCatalogRoute.ts create mode 100644 apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.ts create mode 100644 apps/desktop/src/renderer/src/lib/workspace/viewResumeCapture.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/workspace/viewResumeCapture.ts create mode 100644 apps/desktop/src/renderer/src/lib/workspace/viewResumeFlags.ts create mode 100644 apps/desktop/src/renderer/src/lib/workspace/viewResumeRestoreActive.ts create mode 100644 apps/desktop/src/shared/viewResume.ts diff --git a/apps/desktop/e2e/document-crash.spec.ts b/apps/desktop/e2e/document-crash.spec.ts index de47a13ef..b566106a4 100644 --- a/apps/desktop/e2e/document-crash.spec.ts +++ b/apps/desktop/e2e/document-crash.spec.ts @@ -1,6 +1,11 @@ import type { ElectronApplication, Page } from "@playwright/test"; import type { GlyphName } from "@shift/types"; -import { expect, workspaceTest as test, waitForWorkspaceReady } from "./fixtures/electronApp"; +import { + expect, + navigateToEditor, + workspaceTest as test, + waitForWorkspaceReady, +} from "./fixtures/electronApp"; test.setTimeout(90_000); test.use({ scriptedDialogs: true }); @@ -19,12 +24,25 @@ test("reopens a crashed document renderer with completed edits", async ({ electr await expect.poll(() => electronApp.windows().length).toBe(1); }); +test("reopens a crashed renderer on the last editor route", async ({ electronApp, page }) => { + await navigateToEditor(page, "41"); + await page.waitForURL(/#\/editor\//); + await expect(page.locator("#interactive-canvas")).toBeVisible(); + + const reopenedPage = await crashRendererAndWaitForWindow(electronApp, page); + await waitForAuthoredWorkspace(reopenedPage); + await expect(reopenedPage).toHaveURL(/#\/editor\//); + await expect(reopenedPage.locator("#interactive-canvas")).toBeVisible(); +}); + test("reopens after a document render failure", async ({ electronApp, page }) => { const glyphName = "reactDocumentRecovery" as GlyphName; await page.evaluate((name) => { window.shift?.editor.createGlyph(name); }, glyphName); await waitForGlyph(page, glyphName); + await navigateToEditor(page, "41"); + await page.waitForURL(/#\/editor\//); await page.evaluate(() => { window.location.hash = "/e2e-document-render-failure"; @@ -43,10 +61,17 @@ test("reopens after a document render failure", async ({ electronApp, page }) => const nextWindow = electronApp.waitForEvent("window"); await page.getByRole("button", { name: "Reopen document" }).click(); const reopenedPage = await nextWindow; - await waitForWorkspaceReady(reopenedPage); + await waitForAuthoredWorkspace(reopenedPage); await waitForGlyph(reopenedPage, glyphName); + await expect(reopenedPage).toHaveURL(/#\/editor\//); }); +async function waitForAuthoredWorkspace(page: Page): Promise { + await page.waitForFunction(() => window.shift?.font.loaded === true, undefined, { + timeout: 20_000, + }); +} + test("contains root route render failures", async ({ page }) => { await page.evaluate(() => { window.location.hash = "/e2e-root-render-failure"; diff --git a/apps/desktop/e2e/document-lifecycle.spec.ts b/apps/desktop/e2e/document-lifecycle.spec.ts index f52271659..eb986d069 100644 --- a/apps/desktop/e2e/document-lifecycle.spec.ts +++ b/apps/desktop/e2e/document-lifecycle.spec.ts @@ -488,21 +488,41 @@ for (const { format, sourcePath, sourceRoot } of [ } convertiblePreviewTest( - "Save As replaces a preview glyph route with the new workspace Home", + "Save As restores the editor route after preview conversion", async ({ electronApp, page, saveShiftPath, testRoot }) => { const workspacePage = await openSelectedPreview(page, electronApp); await clickFirstCatalogGlyph(workspacePage); await workspacePage.waitForURL(/#\/editor\//); await expect(workspacePage.locator("#interactive-canvas")).toBeVisible(); + const openedGlyphName = await workspacePage.evaluate(() => { + const glyphId = window.location.hash.match(/#\/editor\/([^/?]+)/)?.[1]; + if (!glyphId) return null; + const decoded = decodeURIComponent(glyphId); + return window.shift?.font.glyphRecords().find((glyph) => glyph.id === decoded)?.name ?? null; + }); + await runCommand(workspacePage, electronApp, "file.saveAs"); await waitForWorkspaceReady(workspacePage); - await expect(workspacePage).toHaveURL(/#\/home$/); + await expect(workspacePage).toHaveURL(/#\/editor\//); + await expect(workspacePage.locator("#interactive-canvas")).toBeVisible(); await expect .poll(() => workspacePage.evaluate(() => window.shiftSession?.mode)) .toBe("workspace"); expect(savedGlyphNames(saveShiftPath, testRoot)).toContain("A"); + if (openedGlyphName) { + await expect + .poll(() => + workspacePage.evaluate(() => { + const glyphId = window.location.hash.match(/#\/editor\/([^/?]+)/)?.[1]; + if (!glyphId) return null; + const decoded = decodeURIComponent(glyphId); + return window.shift?.font.glyphRecords().find((glyph) => glyph.id === decoded)?.name; + }), + ) + .toBe(openedGlyphName); + } }, ); diff --git a/apps/desktop/e2e/document-recovery.spec.ts b/apps/desktop/e2e/document-recovery.spec.ts index 8a28f35c4..f1a594ed5 100644 --- a/apps/desktop/e2e/document-recovery.spec.ts +++ b/apps/desktop/e2e/document-recovery.spec.ts @@ -1,6 +1,6 @@ import type { Page } from "@playwright/test"; import type { GlyphName } from "@shift/types"; -import { expect, recoveryTest as test } from "./fixtures/electronApp"; +import { expect, navigateToEditor, recoveryTest as test } from "./fixtures/electronApp"; test.setTimeout(90_000); @@ -49,6 +49,19 @@ test("recovers undo past the last save as a dirty document that saves explicitly expect(recoveryApp.canonicalGlyphNames()).not.toContain(glyphName); }); +test("relaunch recovery restores the last editor route", async ({ recoveryApp }) => { + await navigateToEditor(recoveryApp.page, "41"); + await recoveryApp.page.waitForURL(/#\/editor\//); + await expect(recoveryApp.page.locator("#interactive-canvas")).toBeVisible(); + + const recovered = await recoveryApp.crashAndRecover(); + await recovered.waitForFunction(() => window.shift?.font.loaded === true, undefined, { + timeout: 20_000, + }); + await expect(recovered).toHaveURL(/#\/editor\//); + await expect(recovered.locator("#interactive-canvas")).toBeVisible(); +}); + test("recovers edits made after a previous recovery", async ({ recoveryApp }) => { const firstGlyph = "recoveredFirst" as GlyphName; const secondGlyph = "recoveredSecond" as GlyphName; diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index f717e49b3..3825b81ae 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -32,6 +32,11 @@ import { isConvertiblePreviewPath } from "../../shared/workspace/previewConversi import { OPEN_FONT_EXTENSIONS } from "../../shared/openFontExtensions"; import { RecentDocuments } from "../recents/RecentDocuments"; import type { RecentDocumentVisit } from "../../shared/recents"; +import type { SessionViewResume } from "../../shared/viewResume"; +import { + ViewResumePersistence, + workspaceLoadHashFromResume, +} from "../viewResume/persistViewResume"; const SLUG_ATLAS_PROFILING_ENABLED = process.env.SHIFT_PROFILE_SLUG_ATLAS !== undefined && @@ -64,6 +69,7 @@ export class App { #workspaces: WorkspaceManager; #documentsRoot: string | null = null; #recents: RecentDocuments | null = null; + #viewResume: ViewResumePersistence | null = null; /** Launchers a font open is replacing; they stop receiving recents so no half-ready card flashes. */ #replacedLaunchers = new WeakSet(); #pendingOpenPaths: string[] = []; @@ -213,6 +219,11 @@ export class App { path.join(app.getPath("userData"), "recent-documents.json"), ); this.#recents.onChanged(() => this.#publishRecents()); + this.#viewResume = new ViewResumePersistence( + path.join(app.getPath("userData"), "view-resume.json"), + ); + + await this.#reopenSessionsAfterUpdateRestart(); const restoredSessions = await this.#workspaces.restoreRecoveries(); for (const session of restoredSessions) { @@ -253,6 +264,9 @@ export class App { this.#updater.start(); this.#log.info("finished when ready callback"); }); + app.on("before-quit", () => { + this.#recordOpenSessionsForUpdateRestart(); + }); app.on("will-quit", () => { this.#log.info("will quit: disposing app services"); for (const session of this.#workspaces.list()) { @@ -398,11 +412,49 @@ export class App { #crashedWindows(session: FontSessionHost | null, failedWindow: Window | null): Window[] { if (failedWindow) return [failedWindow]; - return session?.allWindows() ?? []; + return session ? [...session.allWindows()] : []; } #loadWorkspace(window: Window): void { - this.#loadRenderer(window, "/home"); + const session = this.#workspaces.getForBrowserWindow(window.window); + const resume = session && this.#viewResume ? this.#viewResume.get(session.workspaceId) : null; + const hash = workspaceLoadHashFromResume(resume); + this.#loadRenderer(window, hash); + } + + #recordOpenSessionsForUpdateRestart(): void { + if (!this.#viewResume) return; + + const openSessions = this.#workspaces + .list() + .filter((session) => session.mode === "workspace" && session.windows.size > 0) + .map((session) => ({ + sessionId: session.workspaceId, + documentPath: session.document?.cachedDocumentState()?.saveTarget ?? null, + })); + + this.#viewResume.setOpenSessionsAtQuit(openSessions); + } + + async #reopenSessionsAfterUpdateRestart(): Promise { + if (!this.#viewResume) return; + + const pending = this.#viewResume.takeOpenSessionsAtQuit(); + for (const entry of pending) { + if (!entry.documentPath) continue; + + try { + const session = await this.#workspaces.openPath(entry.documentPath); + const window = this.#createWindow(false, undefined, true); + this.#workspaces.attachWindow(session.workspaceId, window); + if (entry.sessionId !== session.workspaceId) { + this.#viewResume.transfer(entry.sessionId, session.workspaceId); + } + this.#loadWorkspace(window); + } catch (error) { + this.#log.warn("failed to reopen session after update restart", entry, error); + } + } } #loadRenderer(window: Window, hash: string): void { @@ -521,6 +573,14 @@ export class App { ipc.handle(ipcMain, "errors.reportRenderer", (_event, report) => { this.#log.warn("renderer error reported", report); }); + ipc.handle(ipcMain, "session.setViewResume", (event, resume: SessionViewResume) => { + const session = this.#fontSessionForSender(event.sender, "session.setViewResume"); + this.#viewResume?.set(session.workspaceId, resume); + }); + ipc.handle(ipcMain, "session.takeViewResume", (event) => { + const session = this.#fontSessionForSender(event.sender, "session.takeViewResume"); + return this.#viewResume?.consume(session.workspaceId) ?? null; + }); ipc.handle(ipcMain, "session.ready", (event) => { if (SLUG_ATLAS_PROFILING_ENABLED) { this.#log.info("[slug-atlas-profile]", { @@ -650,6 +710,7 @@ export class App { } if (preview.windows.size === 0) this.#workspaces.unregister(preview.workspaceId); + this.#viewResume?.transfer(preview.workspaceId, authored.workspaceId); this.#applicationMenu.updateCommandStates(); window.window.webContents.reload(); } catch (error) { diff --git a/apps/desktop/src/main/docs/DOCS.md b/apps/desktop/src/main/docs/DOCS.md index d364d6ad7..61adf5005 100644 --- a/apps/desktop/src/main/docs/DOCS.md +++ b/apps/desktop/src/main/docs/DOCS.md @@ -1,6 +1,6 @@ # Main - + Electron main process: app startup, windows, menus, document dialogs, and workspace session ownership. @@ -9,7 +9,8 @@ Electron main process: app startup, windows, menus, document dialogs, and worksp - **Architecture Invariant:** `WorkspaceManager` owns live font sessions. Windows attach to sessions; commands and IPC resolve the session from the focused window or sender. A desktop session's immutable mode is `"workspace"` or `"preview"`. - **Architecture Invariant:** Every font session owns one `WorkspaceProcess`. Workspace sessions additionally own one `DocumentClient` and one `DocumentSession`; preview sessions deliberately have no authored document, persistence, dirty state, save target, or export workflow. Main never reads or mutates font data directly. - **Architecture Invariant:** Every non-`.shift` font path opens as an immutable preview session. It uses the shared renderer sync lane and `/home` route, but never allocates a SQLite working document or authored Shift model. -- **Architecture Invariant:** Save or Save As may convert UFO, Designspace, Glyphs, and Glyphspackage previews into a new workspace session. The preview itself never changes mode: `workspace.createFromSource` fully imports and atomically publishes a separate canonical document, then main reattaches and reloads the active window. The renderer replaces a route whose glyph identity does not belong to the workspace session with Home. Cancel or failure leaves the preview and destination unchanged. TTF and OTF previews cannot convert. +- **Architecture Invariant:** Save or Save As may convert UFO, Designspace, Glyphs, and Glyphspackage previews into a new workspace session. The preview itself never changes mode: `workspace.createFromSource` fully imports and atomically publishes a separate canonical document, then main reattaches and reloads the active window. Main transfers the persisted `SessionViewResume` from the preview session id to the new workspace session id; the renderer remaps the last glyph route by stable name and unicode (falling back to Home when no glyph matches). Cancel or failure leaves the preview and destination unchanged. TTF and OTF previews cannot convert. +- **Architecture Invariant:** The renderer debounces catalog view state into `session.setViewResume`; main stores it in `view-resume.json` keyed by session id. Crash reopen, error reopen, relaunch recovery, and update restart compose the initial workspace hash from the stored route when the glyph id is still valid, and the renderer consumes the payload on `session.ready` to restore catalog filters, query, and scroll. Any restore failure falls back to today's Home landing and existing route guards. - **Architecture Invariant:** Dirty state and save targets come from the utility-owned workspace state. Main obtains native choices through `NativeDialogs`, but state reads, saves, and exports go through the renderer document lane so pending edits flush first. Production uses Electron dialogs; E2E injects deterministic choices at this outer boundary. - **Architecture Invariant:** Desktop dialog, update, crash, and settings-failure copy is keyed in `src/shared/messages/en.json` and formatted through `message()`. Native and recoverable failures never interpolate raw diagnostics; React error boundaries retain full diagnostics behind an explicit **Show details** disclosure and in renderer error reports. - **Architecture Invariant:** TTF export snapshots the workspace in the ordered sync lane, then releases that lane before font compilation so subsequent editing is not blocked by fontc. diff --git a/apps/desktop/src/main/document/DocumentSession.ts b/apps/desktop/src/main/document/DocumentSession.ts index a3ecd4ebb..b1fca0b5f 100644 --- a/apps/desktop/src/main/document/DocumentSession.ts +++ b/apps/desktop/src/main/document/DocumentSession.ts @@ -286,6 +286,11 @@ export class DocumentSession { this.#updateWindowTitle(); } + /** Returns the last accepted document state snapshot owned by main. */ + cachedDocumentState(): WorkspaceDocumentState | null { + return this.#state; + } + async #commitWorkspaceClose(discard: boolean, ownerReason: CloseReason | null): Promise { await Promise.resolve(); diff --git a/apps/desktop/src/main/viewResume/persistViewResume.test.ts b/apps/desktop/src/main/viewResume/persistViewResume.test.ts new file mode 100644 index 000000000..118c4132e --- /dev/null +++ b/apps/desktop/src/main/viewResume/persistViewResume.test.ts @@ -0,0 +1,41 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { asGlyphId } from "@shift/types"; +import { emptySessionViewResume } from "../../shared/viewResume"; +import { ViewResumePersistence, workspaceLoadHashFromResume } from "./persistViewResume"; + +describe("ViewResumePersistence", () => { + it("round-trips a session payload", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "shift-view-resume-")); + const filePath = path.join(root, "view-resume.json"); + const store = new ViewResumePersistence(filePath); + const resume = emptySessionViewResume(); + resume.catalog.query = "test"; + resume.route = { glyphName: "A", unicode: 65, glyphId: asGlyphId("glyph-a") }; + + store.set("session-1", resume); + expect(store.get("session-1")).toEqual(resume); + }); + + it("transfers resume between session ids", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "shift-view-resume-")); + const filePath = path.join(root, "view-resume.json"); + const store = new ViewResumePersistence(filePath); + const resume = emptySessionViewResume(); + resume.route = { glyphName: "A", unicode: 65, glyphId: null }; + + store.set("preview", resume); + store.transfer("preview", "workspace"); + + expect(store.get("preview")).toBeNull(); + expect(store.get("workspace")).toEqual(resume); + }); + + it("composes an editor hash when glyph id is known", () => { + const resume = emptySessionViewResume(); + resume.route = { glyphName: "A", unicode: 65, glyphId: asGlyphId("glyph-id") }; + expect(workspaceLoadHashFromResume(resume)).toBe("/editor/glyph-id"); + }); +}); diff --git a/apps/desktop/src/main/viewResume/persistViewResume.ts b/apps/desktop/src/main/viewResume/persistViewResume.ts new file mode 100644 index 000000000..51c0982a2 --- /dev/null +++ b/apps/desktop/src/main/viewResume/persistViewResume.ts @@ -0,0 +1,123 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { + SessionViewResume, + SessionViewResumeOpenSession, + ViewResumeFile, +} from "../../shared/viewResume"; +import { emptySessionViewResume } from "../../shared/viewResume"; + +/** + * Persists per-session view resume payloads under the app user data directory. + * + * @remarks + * Plain module (not a domain Store wrapper). Keys are font session ids. + */ +export class ViewResumePersistence { + readonly #filePath: string; + #file: ViewResumeFile; + + constructor(filePath: string) { + this.#filePath = filePath; + this.#file = readFile(filePath); + } + + get(sessionId: string): SessionViewResume | null { + return this.#file.sessions[sessionId] ?? null; + } + + set(sessionId: string, resume: SessionViewResume): void { + this.#file = { + ...this.#file, + sessions: { ...this.#file.sessions, [sessionId]: resume }, + }; + writeFile(this.#filePath, this.#file); + } + + transfer(fromSessionId: string, toSessionId: string): void { + const resume = this.#file.sessions[fromSessionId]; + if (!resume) return; + + const sessions = { ...this.#file.sessions, [toSessionId]: resume }; + delete sessions[fromSessionId]; + this.#file = { ...this.#file, sessions }; + writeFile(this.#filePath, this.#file); + } + + consume(sessionId: string): SessionViewResume | null { + const resume = this.#file.sessions[sessionId] ?? null; + if (!resume) return null; + + const sessions = { ...this.#file.sessions }; + delete sessions[sessionId]; + this.#file = { ...this.#file, sessions }; + writeFile(this.#filePath, this.#file); + return resume; + } + + setOpenSessionsAtQuit(sessions: readonly SessionViewResumeOpenSession[]): void { + this.#file = { ...this.#file, openSessionsAtQuit: [...sessions] }; + writeFile(this.#filePath, this.#file); + } + + takeOpenSessionsAtQuit(): SessionViewResumeOpenSession[] { + const openSessionsAtQuit = this.#file.openSessionsAtQuit; + if (openSessionsAtQuit.length === 0) return []; + + this.#file = { ...this.#file, openSessionsAtQuit: [] }; + writeFile(this.#filePath, this.#file); + return openSessionsAtQuit; + } +} + +export function workspaceLoadHashFromResume(resume: SessionViewResume | null): string { + const glyphId = resume?.route?.glyphId; + if (glyphId && glyphId.length > 0) { + return `/editor/${encodeURIComponent(glyphId)}`; + } + + return "/home"; +} + +function readFile(filePath: string): ViewResumeFile { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(filePath, "utf8")); + } catch { + return { sessions: {}, openSessionsAtQuit: [] }; + } + + if (typeof parsed !== "object" || parsed === null) { + return { sessions: {}, openSessionsAtQuit: [] }; + } + + const record = parsed as Record; + const sessions = + typeof record.sessions === "object" && + record.sessions !== null && + !Array.isArray(record.sessions) + ? (record.sessions as Record) + : {}; + const openSessionsAtQuit = Array.isArray(record.openSessionsAtQuit) + ? record.openSessionsAtQuit.filter(isOpenSession) + : []; + + return { sessions, openSessionsAtQuit }; +} + +function writeFile(filePath: string, file: ViewResumeFile): void { + const temporaryPath = `${filePath}.tmp`; + try { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(temporaryPath, JSON.stringify(file, null, 2)); + fs.renameSync(temporaryPath, filePath); + } catch (error) { + console.warn("failed to persist view resume", error); + } +} + +function isOpenSession(value: unknown): value is SessionViewResumeOpenSession { + if (typeof value !== "object" || value === null) return false; + const record = value as Record; + return typeof record.sessionId === "string"; +} diff --git a/apps/desktop/src/preload/docs/DOCS.md b/apps/desktop/src/preload/docs/DOCS.md index 8b9864bd7..fe6134b74 100644 --- a/apps/desktop/src/preload/docs/DOCS.md +++ b/apps/desktop/src/preload/docs/DOCS.md @@ -1,6 +1,6 @@ # Preload - + Electron preload script that exposes the typed Shift host API and relays session ports to the renderer. @@ -27,7 +27,7 @@ preload/ The preload runs once before the renderer loads: -1. Builds `ShiftHost` methods from typed `invoke` and `listen` IPC helpers, including native context-menu requests, update-window progress and actions, privacy-safe renderer error reports, and document-window reconstruction without clearing recovery state. +1. Builds `ShiftHost` methods from typed `invoke` and `listen` IPC helpers, including native context-menu requests, update-window progress and actions, privacy-safe renderer error reports, document-window reconstruction without clearing recovery state, and `session.setViewResume` / `session.takeViewResume` for persisted catalog and editor routes across session interruptions. 2. Exposes that object as `window.shiftHost` through `contextBridge`. 3. Relays session and document `MessagePort`s into the page. Because packaged `file://` pages have opaque origins, receivers authenticate these relays with `event.source === window` plus the expected message type rather than comparing origin strings. diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2fb111f27..bf3929bcf 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -19,6 +19,8 @@ const shiftHost: ShiftHost = { mode: invoke(ipcRenderer, "session.mode"), connect: invoke(ipcRenderer, "session.connect"), ready: invoke(ipcRenderer, "session.ready"), + setViewResume: invoke(ipcRenderer, "session.setViewResume"), + takeViewResume: invoke(ipcRenderer, "session.takeViewResume"), }, window: { reopenDocument: invoke(ipcRenderer, "window.reopenDocument"), diff --git a/apps/desktop/src/renderer/src/app/Screens.tsx b/apps/desktop/src/renderer/src/app/Screens.tsx index 6d02d7851..b69658120 100644 --- a/apps/desktop/src/renderer/src/app/Screens.tsx +++ b/apps/desktop/src/renderer/src/app/Screens.tsx @@ -1,4 +1,4 @@ -import { useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Navigate, Outlet, Route, Routes, useLocation } from "react-router"; import { Landing } from "@/views/Landing"; @@ -11,6 +11,7 @@ import { DebugProvider } from "@/context/DebugProvider"; import { SettingsNavigationProvider } from "@/context/SettingsNavigationProvider"; import { GlyphCatalogProvider } from "@/context/GlyphCatalogProvider"; import { PreviewNoticeProvider } from "@/context/PreviewNoticeProvider"; +import { ViewResumeProvider } from "@/context/ViewResumeProvider"; import { AboutScreen } from "@/views/AboutScreen"; import { FeedbackScreen } from "@/views/FeedbackScreen"; import { UpdateScreen } from "@/views/UpdateScreen"; @@ -56,16 +57,22 @@ export const Screens = () => { const FontSessionScreens = () => { const location = useLocation(); + const [pendingScrollRestore, setPendingScrollRestore] = useState(null); + const handleScrollRestoreApplied = useCallback(() => setPendingScrollRestore(null), []); // Preserve the resident catalog atlas across screen navigation. Route visibility // must not own the WebGPU device or trigger another complete atlas upload. const catalogActive = location.pathname === "/home"; return ( - - - - + + + + +
{
- - + + + ); }; diff --git a/apps/desktop/src/renderer/src/components/home/GlyphCatalogBackendGate.tsx b/apps/desktop/src/renderer/src/components/home/GlyphCatalogBackendGate.tsx index 6a13b76df..8ec52f65e 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphCatalogBackendGate.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphCatalogBackendGate.tsx @@ -21,6 +21,9 @@ export function GlyphCatalogBackendGate({ openGlyph, onFirstFrame, onUnavailable, + pendingCatalogScrollTop, + onCatalogScrollTop, + onCatalogScrollApplied, }: GlyphCatalogBackendGateProps) { const session = useFontSession(); const workspace = session.workspace; @@ -91,6 +94,9 @@ export function GlyphCatalogBackendGate({ onPendingGlyphName={handlePendingGlyphName} onFirstFrame={onFirstFrame} onUnavailable={onUnavailable} + pendingCatalogScrollTop={pendingCatalogScrollTop} + onCatalogScrollTop={onCatalogScrollTop} + onCatalogScrollApplied={onCatalogScrollApplied} /> ); } diff --git a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx index 7ce7a196e..c5c0dbc75 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx @@ -17,6 +17,10 @@ export const GlyphGrid = memo(function GlyphGrid() { glyphPreviews, canAuthor, openGlyph, + pendingCatalogScrollTop, + reportCatalogScrollTop, + acknowledgeCatalogScrollApplied, + query, } = useGlyphCatalog(); const [catalogReady, setCatalogReady] = useState(false); const workspaceReadyRef = useRef(false); @@ -49,6 +53,7 @@ export const GlyphGrid = memo(function GlyphGrid() { aria-label="Glyph catalog surface" data-filtered-glyph-count={filteredGlyphs.length} data-first-glyph-id={filteredGlyphs[0]?.id} + data-view-resume-query={query} className="relative h-full min-h-0 w-full overflow-hidden font-ui text-primary" > {filteredGlyphs.length === 0 ? (
diff --git a/apps/desktop/src/renderer/src/components/home/SvgGlyphCatalogGrid.tsx b/apps/desktop/src/renderer/src/components/home/SvgGlyphCatalogGrid.tsx index d7733ca0a..7b70d1603 100644 --- a/apps/desktop/src/renderer/src/components/home/SvgGlyphCatalogGrid.tsx +++ b/apps/desktop/src/renderer/src/components/home/SvgGlyphCatalogGrid.tsx @@ -21,6 +21,9 @@ export function SvgGlyphCatalogGrid({ onFirstFrame, onUnavailable, glyphActionLabel, + pendingCatalogScrollTop, + onCatalogScrollTop, + onCatalogScrollApplied, }: SvgGlyphCatalogGridProps) { const containerRef = useRef(null); const editingInputRef = useRef(null); @@ -44,6 +47,14 @@ export function SvgGlyphCatalogGrid({ onUnavailable, ); + useLayoutEffect(() => { + const container = containerRef.current; + if (!container || pendingCatalogScrollTop === null) return; + + container.scrollTop = pendingCatalogScrollTop; + onCatalogScrollApplied(); + }, [onCatalogScrollApplied, pendingCatalogScrollTop]); + useLayoutEffect(() => { const container = containerRef.current; if (!container) return undefined; @@ -97,7 +108,11 @@ export function SvgGlyphCatalogGrid({ data-preview-cache-bytes={cacheBytes} data-first-glyph-name={glyphs[0]?.displayName} className="absolute inset-0 overflow-x-hidden overflow-y-auto" - onScroll={(event) => setScrollTop(event.currentTarget.scrollTop)} + onScroll={(event) => { + const nextScrollTop = event.currentTarget.scrollTop; + setScrollTop(nextScrollTop); + onCatalogScrollTop(nextScrollTop); + }} >
{targetFrame.cells.map((cell) => { diff --git a/apps/desktop/src/renderer/src/context/GlyphCatalogProvider.tsx b/apps/desktop/src/renderer/src/context/GlyphCatalogProvider.tsx index a3de88aa2..c49540bb1 100644 --- a/apps/desktop/src/renderer/src/context/GlyphCatalogProvider.tsx +++ b/apps/desktop/src/renderer/src/context/GlyphCatalogProvider.tsx @@ -1,4 +1,12 @@ -import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type MutableRefObject, + type ReactNode, +} from "react"; import { useLocation, useNavigate } from "react-router"; import { DEFAULT_LANGUAGE_IDS, @@ -6,13 +14,19 @@ import { type GlyphCategoryCatalog, type LanguageCatalog, } from "@shift/glyph-info"; -import { asGlyphId, type GlyphId, type GlyphName } from "@shift/types"; +import type { GlyphName } from "@shift/types"; import { effect, signal, useSignalState } from "@shift/editor/signals"; +import type { SessionViewResume, SessionViewResumeCatalog } from "@shared/viewResume"; +import { emptySessionViewResume } from "@shared/viewResume"; +import { getShiftHost } from "@/host/shiftHost"; import { useFontSession } from "@/workspace/WorkspaceContext"; import { getGlyphInfo } from "@/workspace/glyphInfo"; import { useListSelection } from "@/hooks/useListSelection"; import { LatestRequest } from "@shift/editor"; +import { viewResumeCaptureDecision } from "@/lib/workspace/viewResumeCapture"; import { GlyphCatalogContext } from "./GlyphCatalogContext"; +import { ViewResumeCaptureContext } from "./ViewResumeCaptureContext"; +import { glyphIdFromPath } from "./glyphCatalogRoute"; import type { GlyphCatalogItem, GlyphCatalogSource, @@ -23,12 +37,50 @@ const NO_LANGUAGE_IDS = signal(null, { name: "glyphCatalog.previewLanguageIds", }); -export const GlyphCatalogProvider = ({ children }: { children: ReactNode }) => { - const value = useGlyphCatalogSource(); - return {children}; +type GlyphCatalogProviderProps = { + children: ReactNode; + pendingScrollRestore: number | null; + onScrollRestoreApplied: () => void; +}; + +export const GlyphCatalogProvider = ({ + children, + pendingScrollRestore, + onScrollRestoreApplied, +}: GlyphCatalogProviderProps) => { + const captureRef = useRef(null); + const value = useGlyphCatalogSource({ + pendingScrollRestore, + onScrollRestoreApplied, + captureRef, + }); + + const flush = useCallback(async () => { + await captureRef.current?.flush(); + }, []); + + return ( + + {children} + + ); +}; + +type CaptureControls = { + flush: () => Promise; }; -const useGlyphCatalogSource = (): GlyphCatalogSource => { +type GlyphCatalogSourceOptions = { + pendingScrollRestore: number | null; + onScrollRestoreApplied: () => void; + captureRef: MutableRefObject; +}; + +const useGlyphCatalogSource = ({ + pendingScrollRestore, + onScrollRestoreApplied, + captureRef, +}: GlyphCatalogSourceOptions): GlyphCatalogSource => { const session = useFontSession(); const navigate = useNavigate(); const navigateRef = useRef(navigate); @@ -73,6 +125,9 @@ const useGlyphCatalogSource = (): GlyphCatalogSource => { const [expandedCategories, setExpandedCategories] = useState>( () => new Set(), ); + const [reportedCatalogScrollTop, setReportedCatalogScrollTop] = useState(0); + const lastCapturedAtRef = useRef(null); + const latestResumeRef = useRef(emptySessionViewResume()); const availableUnicodes = useMemo( () => availableGlyphs.flatMap((glyph) => (glyph.unicode === null ? [] : [glyph.unicode])), @@ -263,6 +318,83 @@ const useGlyphCatalogSource = (): GlyphCatalogSource => { [workspace], ); + const restoreCatalogView = useCallback((catalogView: SessionViewResumeCatalog) => { + setQuery(catalogView.query); + setCategoryFilters(catalogView.categoryFilters as GlyphCategoryFilter[]); + setSelectedLanguageId(catalogView.selectedLanguageId); + }, []); + + const buildResume = useCallback((): SessionViewResume => { + const routeGlyphId = glyphIdFromPath(routeLocation.pathname); + const routeGlyph = routeGlyphId + ? availableGlyphs.find((glyph) => glyph.id === routeGlyphId) + : null; + + return { + route: routeGlyph + ? { + glyphName: routeGlyph.name, + unicode: routeGlyph.unicode, + glyphId: routeGlyphId, + } + : null, + catalog: { + query, + categoryFilters: categoryFilters.map(({ category, subCategoryKey }) => ({ + category, + subCategoryKey, + })), + selectedLanguageId, + scrollTop: reportedCatalogScrollTop, + }, + }; + }, [ + availableGlyphs, + categoryFilters, + query, + reportedCatalogScrollTop, + routeLocation.pathname, + selectedLanguageId, + ]); + + const persistResume = useCallback( + async (flush: boolean) => { + const decision = viewResumeCaptureDecision(lastCapturedAtRef.current, Date.now(), flush); + if (decision.kind === "skip") return; + + lastCapturedAtRef.current = Date.now(); + const resume = buildResume(); + latestResumeRef.current = resume; + await getShiftHost().session.setViewResume(resume); + }, + [buildResume], + ); + + useEffect(() => { + captureRef.current = { + flush: async () => persistResume(true), + }; + + return () => { + captureRef.current = null; + }; + }, [captureRef, persistResume]); + + useEffect(() => { + void persistResume(true); + }, [persistResume, routeLocation.pathname]); + + useEffect(() => { + void persistResume(false); + }, [ + availableGlyphs, + categoryFilters, + persistResume, + query, + reportedCatalogScrollTop, + selectedLanguageId, + ]); + return { availableGlyphs: [...availableGlyphs], filteredGlyphs, @@ -307,6 +439,10 @@ const useGlyphCatalogSource = (): GlyphCatalogSource => { setCategoryFilters([]); setSelectedLanguageId(languageId); }, + restoreCatalogView, + pendingCatalogScrollTop: pendingScrollRestore, + acknowledgeCatalogScrollApplied: onScrollRestoreApplied, + reportCatalogScrollTop: setReportedCatalogScrollTop, }; }; @@ -360,17 +496,3 @@ function glyphId(glyph: GlyphCatalogItem) { function sameCategoryFilter(left: GlyphCategoryFilter, right: GlyphCategoryFilter) { return left.category === right.category && left.subCategoryKey === right.subCategoryKey; } - -function glyphIdFromPath(pathname: string): GlyphId | null { - const prefix = "/editor/"; - if (!pathname.startsWith(prefix)) return null; - - let value: string; - try { - value = decodeURIComponent(pathname.slice(prefix.length)); - } catch { - return null; - } - - return value.length > 0 ? asGlyphId(value) : null; -} diff --git a/apps/desktop/src/renderer/src/context/PreviewNoticeProvider.tsx b/apps/desktop/src/renderer/src/context/PreviewNoticeProvider.tsx index fb3798a93..0076c3ab6 100644 --- a/apps/desktop/src/renderer/src/context/PreviewNoticeProvider.tsx +++ b/apps/desktop/src/renderer/src/context/PreviewNoticeProvider.tsx @@ -1,6 +1,7 @@ import { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from "react"; import { PreviewNoticeDialog } from "@/components/chrome/PreviewNoticeDialog"; import { getShiftHost } from "@/host/shiftHost"; +import { useViewResumeCapture } from "@/context/ViewResumeCaptureContext"; import { useFontSession } from "@/workspace/WorkspaceContext"; const PreviewNoticeContext = createContext<(() => void) | null>(null); @@ -16,6 +17,7 @@ export function usePreviewNotice(): () => void { export const PreviewNoticeProvider = ({ children }: { children: ReactNode }) => { const session = useFontSession(); + const { flush: flushViewResume } = useViewResumeCapture(); const [previewNoticeOpen, setPreviewNoticeOpen] = useState(false); const showPreviewNotice = useCallback(() => { @@ -28,11 +30,12 @@ export const PreviewNoticeProvider = ({ children }: { children: ReactNode }) => setPreviewNoticeOpen(false); try { + await flushViewResume(); await getShiftHost().commands.run("file.save"); } catch (error) { console.error("preview conversion failed", error); } - }, []); + }, [flushViewResume]); useEffect( () => session.editor.on("previewMutationAttempted", showPreviewNotice), diff --git a/apps/desktop/src/renderer/src/context/ViewResumeCaptureContext.ts b/apps/desktop/src/renderer/src/context/ViewResumeCaptureContext.ts new file mode 100644 index 000000000..94a10367c --- /dev/null +++ b/apps/desktop/src/renderer/src/context/ViewResumeCaptureContext.ts @@ -0,0 +1,16 @@ +import { createContext, useContext } from "react"; + +export type ViewResumeCaptureControls = { + flush: () => Promise; +}; + +export const ViewResumeCaptureContext = createContext(null); + +export function useViewResumeCapture(): ViewResumeCaptureControls { + const controls = useContext(ViewResumeCaptureContext); + if (!controls) { + throw new Error("useViewResumeCapture must be used within ViewResumeCaptureProvider"); + } + + return controls; +} diff --git a/apps/desktop/src/renderer/src/context/ViewResumeProvider.tsx b/apps/desktop/src/renderer/src/context/ViewResumeProvider.tsx new file mode 100644 index 000000000..4c4349c71 --- /dev/null +++ b/apps/desktop/src/renderer/src/context/ViewResumeProvider.tsx @@ -0,0 +1,77 @@ +import { useEffect, type ReactNode } from "react"; +import { useLocation, useNavigate } from "react-router"; +import { useSignalState } from "@shift/editor/signals"; +import { getShiftHost } from "@/host/shiftHost"; +import { matchResumeGlyph } from "@/lib/workspace/resumeGlyph"; +import { viewResumeRestoreActive } from "@/lib/workspace/viewResumeRestoreActive"; +import { useFont, useFontSession } from "@/workspace/WorkspaceContext"; +import { useGlyphCatalog } from "./GlyphCatalogContext"; +import { glyphIdFromPath } from "./glyphCatalogRoute"; + +type ViewResumeProviderProps = { + children: ReactNode; + onRestoredScrollTop: (scrollTop: number) => void; +}; + +/** Restores persisted catalog and editor routes after a session transition. */ +export function ViewResumeProvider({ children, onRestoredScrollTop }: ViewResumeProviderProps) { + const session = useFontSession(); + const font = useFont(); + const catalog = useGlyphCatalog(); + const navigate = useNavigate(); + const routeLocation = useLocation(); + const documentLoaded = useSignalState(font.loadedCell); + + useEffect(() => { + if (!documentLoaded) return; + if (!viewResumeRestoreActive()) return; + + let active = true; + + async function restore(): Promise { + try { + const resume = await getShiftHost().session.takeViewResume(); + if (!active || !resume) return; + + catalog.restoreCatalogView(resume.catalog); + onRestoredScrollTop(resume.catalog.scrollTop); + + const remapped = resume.route + ? matchResumeGlyph(catalog.availableGlyphs, resume.route) + : null; + + if (remapped) { + const glyph = catalog.availableGlyphs.find((item) => item.id === remapped); + if (glyph) { + await catalog.openGlyph(glyph); + navigate(`/editor/${encodeURIComponent(remapped)}`, { replace: true }); + return; + } + } + + const routeGlyphId = glyphIdFromPath(routeLocation.pathname); + if (routeGlyphId !== null) { + const glyph = catalog.availableGlyphs.find((item) => item.id === routeGlyphId); + if (glyph) await catalog.openGlyph(glyph); + } + } catch (error) { + console.error("view resume restore failed", error); + } + } + + void restore(); + + return () => { + active = false; + }; + }, [ + catalog, + documentLoaded, + navigate, + onRestoredScrollTop, + routeLocation.pathname, + session.mode, + ]); + + return children; +} diff --git a/apps/desktop/src/renderer/src/context/glyphCatalogRoute.ts b/apps/desktop/src/renderer/src/context/glyphCatalogRoute.ts new file mode 100644 index 000000000..e8daf84c1 --- /dev/null +++ b/apps/desktop/src/renderer/src/context/glyphCatalogRoute.ts @@ -0,0 +1,15 @@ +import { asGlyphId, type GlyphId } from "@shift/types"; + +export function glyphIdFromPath(pathname: string): GlyphId | null { + const prefix = "/editor/"; + if (!pathname.startsWith(prefix)) return null; + + let value: string; + try { + value = decodeURIComponent(pathname.slice(prefix.length)); + } catch { + return null; + } + + return value.length > 0 ? asGlyphId(value) : null; +} diff --git a/apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.test.ts b/apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.test.ts new file mode 100644 index 000000000..e9078a20f --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { asGlyphId, type GlyphName } from "@shift/types"; +import { matchResumeGlyph } from "./resumeGlyph"; + +describe("matchResumeGlyph", () => { + const glyphs = [ + { id: asGlyphId("g-a"), name: "A" as GlyphName, unicode: 65 }, + { id: asGlyphId("g-b"), name: "B" as GlyphName, unicode: 66 }, + ] as const; + + it("matches a unique glyph name", () => { + expect(matchResumeGlyph(glyphs, { glyphName: "A", unicode: 65, glyphId: null })).toBe( + asGlyphId("g-a"), + ); + }); + + it("falls back to unicode when the name is ambiguous", () => { + const ambiguous = [ + { id: asGlyphId("g-1"), name: "A" as GlyphName, unicode: 65 }, + { id: asGlyphId("g-2"), name: "A" as GlyphName, unicode: 66 }, + ]; + expect(matchResumeGlyph(ambiguous, { glyphName: "A", unicode: 66, glyphId: null })).toBe( + asGlyphId("g-2"), + ); + }); + + it("returns null for ambiguous matches", () => { + const ambiguous = [ + { id: asGlyphId("g-1"), name: "A" as GlyphName, unicode: null }, + { id: asGlyphId("g-2"), name: "A" as GlyphName, unicode: null }, + ]; + expect(matchResumeGlyph(ambiguous, { glyphName: "A", unicode: null, glyphId: null })).toBeNull(); + }); + + it("returns null for an empty catalog", () => { + expect(matchResumeGlyph([], { glyphName: "A", unicode: 65, glyphId: null })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.ts b/apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.ts new file mode 100644 index 000000000..ef697e5cd --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.ts @@ -0,0 +1,32 @@ +import type { GlyphId, GlyphName } from "@shift/types"; +import type { SessionViewResumeRoute } from "@shared/viewResume"; + +export type GlyphRecordForResume = { + id: GlyphId; + name: GlyphName; + unicode: number | null; +}; + +/** + * Maps a stable preview route identity onto a workspace glyph id. + * + * @returns null when no unique match exists (caller falls back to home). + */ +export function matchResumeGlyph( + glyphs: readonly GlyphRecordForResume[], + route: SessionViewResumeRoute, +): GlyphId | null { + if (glyphs.length === 0) return null; + + const byName = glyphs.filter((glyph) => glyph.name === route.glyphName); + if (byName.length === 1) return byName[0]!.id; + + if (route.unicode !== null) { + const byUnicode = glyphs.filter((glyph) => glyph.unicode === route.unicode); + if (byUnicode.length === 1) return byUnicode[0]!.id; + } + + if (byName.length > 1) return null; + + return null; +} diff --git a/apps/desktop/src/renderer/src/lib/workspace/viewResumeCapture.test.ts b/apps/desktop/src/renderer/src/lib/workspace/viewResumeCapture.test.ts new file mode 100644 index 000000000..5d2d77959 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/workspace/viewResumeCapture.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { viewResumeCaptureDecision } from "./viewResumeCapture"; + +describe("viewResumeCaptureDecision", () => { + it("captures on first change", () => { + expect(viewResumeCaptureDecision(null, 1_000, false)).toEqual({ + kind: "capture", + reason: "state-changed", + }); + }); + + it("skips within the debounce window", () => { + expect(viewResumeCaptureDecision(1_000, 1_200, false)).toEqual({ + kind: "skip", + reason: "within-debounce", + }); + }); + + it("captures after the debounce window", () => { + expect(viewResumeCaptureDecision(1_000, 1_600, false)).toEqual({ + kind: "capture", + reason: "state-changed", + }); + }); + + it("flush forces capture inside the debounce window", () => { + expect(viewResumeCaptureDecision(1_000, 1_100, true)).toEqual({ + kind: "capture", + reason: "flushed", + }); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/workspace/viewResumeCapture.ts b/apps/desktop/src/renderer/src/lib/workspace/viewResumeCapture.ts new file mode 100644 index 000000000..472656a6f --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/workspace/viewResumeCapture.ts @@ -0,0 +1,23 @@ +export const VIEW_RESUME_DEBOUNCE_MS = 500; + +export type ViewResumeCaptureDecision = + | { kind: "skip"; reason: "within-debounce" } + | { kind: "capture"; reason: "state-changed" | "flushed" }; + +/** + * Decides whether a view-resume snapshot should be persisted for the current timestamp. + */ +export function viewResumeCaptureDecision( + lastCapturedAtMs: number | null, + nowMs: number, + flush: boolean, +): ViewResumeCaptureDecision { + if (flush) return { kind: "capture", reason: "flushed" }; + if (lastCapturedAtMs === null) return { kind: "capture", reason: "state-changed" }; + + if (nowMs - lastCapturedAtMs < VIEW_RESUME_DEBOUNCE_MS) { + return { kind: "skip", reason: "within-debounce" }; + } + + return { kind: "capture", reason: "state-changed" }; +} diff --git a/apps/desktop/src/renderer/src/lib/workspace/viewResumeFlags.ts b/apps/desktop/src/renderer/src/lib/workspace/viewResumeFlags.ts new file mode 100644 index 000000000..1cd1db0fe --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/workspace/viewResumeFlags.ts @@ -0,0 +1,12 @@ +export type ViewResumeFlow = "conversion" | "crashReopen" | "relaunch" | "update"; + +const ENABLED_FLOWS: ReadonlySet = new Set([ + "conversion", + "crashReopen", + "relaunch", + "update", +]); + +export function isViewResumeRestoreEnabled(flow: ViewResumeFlow): boolean { + return ENABLED_FLOWS.has(flow); +} diff --git a/apps/desktop/src/renderer/src/lib/workspace/viewResumeRestoreActive.ts b/apps/desktop/src/renderer/src/lib/workspace/viewResumeRestoreActive.ts new file mode 100644 index 000000000..048e6f0f4 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/workspace/viewResumeRestoreActive.ts @@ -0,0 +1,7 @@ +import { isViewResumeRestoreEnabled, type ViewResumeFlow } from "./viewResumeFlags"; + +const RESTORE_FLOWS: ViewResumeFlow[] = ["conversion", "crashReopen", "relaunch", "update"]; + +export function viewResumeRestoreActive(): boolean { + return RESTORE_FLOWS.some((flow) => isViewResumeRestoreEnabled(flow)); +} diff --git a/apps/desktop/src/renderer/src/types/glyphCatalog.ts b/apps/desktop/src/renderer/src/types/glyphCatalog.ts index a2605c880..7662590a8 100644 --- a/apps/desktop/src/renderer/src/types/glyphCatalog.ts +++ b/apps/desktop/src/renderer/src/types/glyphCatalog.ts @@ -8,6 +8,7 @@ import type { GlyphPreview, SourceId, } from "@shift/types"; +import type { SessionViewResumeCatalog } from "@shared/viewResume"; import type { ListSelectionMode, RenderGlyph } from "@shift/editor/types"; import type { Dispatch, SetStateAction } from "react"; import type { ColorTheme } from "@/lib/themes"; @@ -84,6 +85,10 @@ export interface GlyphCatalogSource { canAuthor: boolean; openedGlyph: RenderGlyph | null; openGlyph: (glyph: GlyphCatalogItem) => Promise; + restoreCatalogView: (catalog: SessionViewResumeCatalog) => void; + pendingCatalogScrollTop: number | null; + acknowledgeCatalogScrollApplied: () => void; + reportCatalogScrollTop: (scrollTop: number) => void; } export interface GlyphCatalogLayoutMetrics { @@ -140,6 +145,9 @@ export interface GlyphNameInputProps { } export interface GlyphCatalogBackendGateProps { + pendingCatalogScrollTop: number | null; + onCatalogScrollTop: (scrollTop: number) => void; + onCatalogScrollApplied: () => void; readonly glyphs: readonly GlyphCatalogItem[]; readonly location: CatalogLocation; readonly metrics: CatalogMetrics; @@ -177,4 +185,7 @@ export interface SlugGlyphCatalogSurfaceProps extends GlyphCatalogViewProps { export interface SvgGlyphCatalogGridProps extends GlyphCatalogViewProps { readonly glyphPreviews: GlyphCatalogSource["glyphPreviews"]; readonly glyphActionLabel?: (glyph: GlyphCatalogItem) => string; + readonly pendingCatalogScrollTop: number | null; + readonly onCatalogScrollTop: (scrollTop: number) => void; + readonly onCatalogScrollApplied: () => void; } diff --git a/apps/desktop/src/shared/host/ShiftHost.ts b/apps/desktop/src/shared/host/ShiftHost.ts index 57001bbe9..1da272915 100644 --- a/apps/desktop/src/shared/host/ShiftHost.ts +++ b/apps/desktop/src/shared/host/ShiftHost.ts @@ -3,6 +3,7 @@ import type { UpdateProgress } from "../update/types"; import type { RendererErrorReport } from "../ipc/contract"; import type { FontSessionMode } from "../workspace/protocol"; import type { RecentDocument } from "../recents"; +import type { SessionViewResume } from "../viewResume"; /** * Renderer-facing API for Electron app-shell behavior. @@ -55,6 +56,8 @@ export interface ShiftHost { */ connect: () => Promise; ready: () => Promise; + setViewResume: (resume: SessionViewResume) => Promise; + takeViewResume: () => Promise; }; /** Controls and observes the main-owned application update flow. */ update: { diff --git a/apps/desktop/src/shared/ipc/contract.ts b/apps/desktop/src/shared/ipc/contract.ts index ca1c233c1..0c51b1795 100644 --- a/apps/desktop/src/shared/ipc/contract.ts +++ b/apps/desktop/src/shared/ipc/contract.ts @@ -6,6 +6,7 @@ import type { } from "../workspace/protocol"; import type { UpdateProgress } from "../update/types"; import type { RecentDocument } from "../recents"; +import type { SessionViewResume } from "../viewResume"; export type DocumentCallMap = { "document.state": { request: void; response: WorkspaceDocumentState | null }; @@ -57,6 +58,10 @@ export type RendererToMain = { */ "session.connect": () => void; "session.ready": () => void; + /** Persists the sender window's latest catalog/editor view state for session resume. */ + "session.setViewResume": (resume: SessionViewResume) => void; + /** Returns and clears the persisted resume payload for the sender's session. */ + "session.takeViewResume": () => SessionViewResume | null; "window.reopenDocument": () => void; /** Reports that the sender's first meaningful content is rendered, so main can show it. */ "window.ready": () => void; diff --git a/apps/desktop/src/shared/viewResume.ts b/apps/desktop/src/shared/viewResume.ts new file mode 100644 index 000000000..e2766afb9 --- /dev/null +++ b/apps/desktop/src/shared/viewResume.ts @@ -0,0 +1,49 @@ +import type { GlyphId, GlyphName } from "@shift/types"; + +/** Stable glyph identity for remapping across preview import (IDs are re-minted). */ +export type SessionViewResumeRoute = { + glyphName: GlyphName; + unicode: number | null; + /** When the workspace store is resumed, the prior GlyphId may still be valid. */ + glyphId: GlyphId | null; +}; + +export type SessionViewResumeCategoryFilter = { + category: string; + subCategoryKey: string | null; +}; + +export type SessionViewResumeCatalog = { + query: string; + categoryFilters: readonly SessionViewResumeCategoryFilter[]; + selectedLanguageId: string | null; + scrollTop: number; +}; + +/** Last renderer view state persisted for one font session. */ +export type SessionViewResume = { + route: SessionViewResumeRoute | null; + catalog: SessionViewResumeCatalog; +}; + +export type SessionViewResumeOpenSession = { + sessionId: string; + documentPath: string | null; +}; + +export type ViewResumeFile = { + sessions: Record; + openSessionsAtQuit: SessionViewResumeOpenSession[]; +}; + +export function emptySessionViewResume(): SessionViewResume { + return { + route: null, + catalog: { + query: "", + categoryFilters: [], + selectedLanguageId: null, + scrollTop: 0, + }, + }; +} From 6f483c9b0448e752ec6c4ec700c99efdd18ae754 Mon Sep 17 00:00:00 2001 From: teyepe Date: Sat, 3 Oct 2026 00:11:35 +0100 Subject: [PATCH 2/5] fix(desktop): keep view resume until the renderer has restored it The renderer used to take the saved view as soon as it asked for it, so a restore that had to wait for the glyph catalog lost the payload. Split the IPC into peek and consume so the renderer consumes only after restoring. Also stop recording open sessions on every quit, which reopened documents after a normal quit; record them only when restarting to install an update and clear them if the restart is cancelled. Skip the pre-reopen view flush for a crashed renderer, which cannot answer it and stalled crash recovery. --- apps/desktop/src/main/app/App.ts | 44 ++++++++++++++++--- apps/desktop/src/main/update/AppUpdater.ts | 8 ++++ apps/desktop/src/main/update/types.ts | 4 ++ .../main/viewResume/persistViewResume.test.ts | 11 +++++ .../src/main/viewResume/persistViewResume.ts | 9 +++- apps/desktop/src/preload/preload.ts | 2 + apps/desktop/src/shared/host/ShiftHost.ts | 2 + apps/desktop/src/shared/ipc/contract.ts | 4 ++ 8 files changed, 77 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index 3825b81ae..810470162 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -80,7 +80,7 @@ export class App { #applicationMenu = new ApplicationMenu( (id, browserWindow) => { const window = browserWindow - ? this.#windows.windowForBrowserWindow(browserWindow) + ? (this.#windows.windowForBrowserWindow(browserWindow) ?? undefined) : undefined; if (browserWindow && !window) return; @@ -97,7 +97,7 @@ export class App { }, (id, browserWindow) => { const window = browserWindow - ? this.#windows.windowForBrowserWindow(browserWindow) + ? (this.#windows.windowForBrowserWindow(browserWindow) ?? undefined) : undefined; if (browserWindow && !window) return false; @@ -156,6 +156,8 @@ export class App { lifecycle: this.#lifecycle, activeWindow: () => this.#windows.activeWindow(), log: createShiftLogger("app.update"), + recordOpenSessionsForUpdateRestart: () => this.#recordOpenSessionsForUpdateRestart(), + clearOpenSessionsForUpdateRestart: () => this.#clearOpenSessionsForUpdateRestart(), }); } @@ -264,9 +266,6 @@ export class App { this.#updater.start(); this.#log.info("finished when ready callback"); }); - app.on("before-quit", () => { - this.#recordOpenSessionsForUpdateRestart(); - }); app.on("will-quit", () => { this.#log.info("will quit: disposing app services"); for (const session of this.#workspaces.list()) { @@ -399,6 +398,8 @@ export class App { const session = this.#workspaces.getForBrowserWindow(owner.window); if (!session?.document) throw new Error("document reopen requires an authored workspace"); + await this.#flushViewResumeFromRenderer(owner); + const reopened = await this.#workspaces.reopenSession(session.workspaceId); const bounds = owner.window.isDestroyed() ? undefined : owner.window.getBounds(); const window = this.#createWindow(false, bounds); @@ -422,6 +423,25 @@ export class App { this.#loadRenderer(window, hash); } + async #flushViewResumeFromRenderer(window: Window): Promise { + const webContents = window.window.webContents; + if (window.window.isDestroyed() || webContents.isDestroyed() || webContents.isCrashed()) { + return; + } + + try { + await webContents.executeJavaScript( + `(async () => { + const flush = globalThis.__shiftViewResumeFlush; + if (typeof flush === "function") await flush(); + })()`, + true, + ); + } catch (error) { + this.#log.warn("failed to flush view resume from renderer", error); + } + } + #recordOpenSessionsForUpdateRestart(): void { if (!this.#viewResume) return; @@ -436,6 +456,10 @@ export class App { this.#viewResume.setOpenSessionsAtQuit(openSessions); } + #clearOpenSessionsForUpdateRestart(): void { + this.#viewResume?.clearOpenSessionsAtQuit(); + } + async #reopenSessionsAfterUpdateRestart(): Promise { if (!this.#viewResume) return; @@ -577,6 +601,14 @@ export class App { const session = this.#fontSessionForSender(event.sender, "session.setViewResume"); this.#viewResume?.set(session.workspaceId, resume); }); + ipc.handle(ipcMain, "session.peekViewResume", (event) => { + const session = this.#fontSessionForSender(event.sender, "session.peekViewResume"); + return this.#viewResume?.get(session.workspaceId) ?? null; + }); + ipc.handle(ipcMain, "session.consumeViewResume", (event) => { + const session = this.#fontSessionForSender(event.sender, "session.consumeViewResume"); + this.#viewResume?.consume(session.workspaceId); + }); ipc.handle(ipcMain, "session.takeViewResume", (event) => { const session = this.#fontSessionForSender(event.sender, "session.takeViewResume"); return this.#viewResume?.consume(session.workspaceId) ?? null; @@ -689,6 +721,8 @@ export class App { const sourcePath = preview.sourcePath; if (!sourcePath || !isConvertiblePreviewPath(sourcePath)) return; + await this.#flushViewResumeFromRenderer(window); + const parsedSourcePath = path.parse(sourcePath); const suggestedPath = path.join(parsedSourcePath.dir, `${parsedSourcePath.name}.shift`); const documentPath = await this.#nativeDialogs.saveShiftDocument(window, suggestedPath); diff --git a/apps/desktop/src/main/update/AppUpdater.ts b/apps/desktop/src/main/update/AppUpdater.ts index c76429135..bb15262dd 100644 --- a/apps/desktop/src/main/update/AppUpdater.ts +++ b/apps/desktop/src/main/update/AppUpdater.ts @@ -171,12 +171,20 @@ export class AppUpdater { async restartToUpdate(): Promise { if (this.#status.type !== "ready") return; + this.#options.recordOpenSessionsForUpdateRestart(); + try { if (!(await this.#options.lifecycle.confirmQuit("update"))) { this.#options.log.info("update restart blocked by document close"); + if (this.#options.clearOpenSessionsForUpdateRestart) { + this.#options.clearOpenSessionsForUpdateRestart(); + } return; } } catch (error) { + if (this.#options.clearOpenSessionsForUpdateRestart) { + this.#options.clearOpenSessionsForUpdateRestart(); + } this.#options.log.warn("update restart blocked by document close failure", error); await this.#showMessage({ type: "error", diff --git a/apps/desktop/src/main/update/types.ts b/apps/desktop/src/main/update/types.ts index 1a048de01..9704f8096 100644 --- a/apps/desktop/src/main/update/types.ts +++ b/apps/desktop/src/main/update/types.ts @@ -27,4 +27,8 @@ export type AppUpdaterOptions = { lifecycle: AppLifecycle; activeWindow: () => Window | null; log: ShiftLogger; + /** Called while workspace windows are still open, before update restart closes documents. */ + recordOpenSessionsForUpdateRestart: () => void; + /** Clears a pending update-restart reopen list when the user vetoes restart. */ + clearOpenSessionsForUpdateRestart?: () => void; }; diff --git a/apps/desktop/src/main/viewResume/persistViewResume.test.ts b/apps/desktop/src/main/viewResume/persistViewResume.test.ts index 118c4132e..1453a63db 100644 --- a/apps/desktop/src/main/viewResume/persistViewResume.test.ts +++ b/apps/desktop/src/main/viewResume/persistViewResume.test.ts @@ -33,6 +33,17 @@ describe("ViewResumePersistence", () => { expect(store.get("workspace")).toEqual(resume); }); + it("clears pending update-restart sessions", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "shift-view-resume-")); + const filePath = path.join(root, "view-resume.json"); + const store = new ViewResumePersistence(filePath); + + store.setOpenSessionsAtQuit([{ sessionId: "s-1", documentPath: "/tmp/doc.shift" }]); + store.clearOpenSessionsAtQuit(); + + expect(store.takeOpenSessionsAtQuit()).toEqual([]); + }); + it("composes an editor hash when glyph id is known", () => { const resume = emptySessionViewResume(); resume.route = { glyphName: "A", unicode: 65, glyphId: asGlyphId("glyph-id") }; diff --git a/apps/desktop/src/main/viewResume/persistViewResume.ts b/apps/desktop/src/main/viewResume/persistViewResume.ts index 51c0982a2..0b6d9e08c 100644 --- a/apps/desktop/src/main/viewResume/persistViewResume.ts +++ b/apps/desktop/src/main/viewResume/persistViewResume.ts @@ -5,8 +5,6 @@ import type { SessionViewResumeOpenSession, ViewResumeFile, } from "../../shared/viewResume"; -import { emptySessionViewResume } from "../../shared/viewResume"; - /** * Persists per-session view resume payloads under the app user data directory. * @@ -68,6 +66,13 @@ export class ViewResumePersistence { writeFile(this.#filePath, this.#file); return openSessionsAtQuit; } + + clearOpenSessionsAtQuit(): void { + if (this.#file.openSessionsAtQuit.length === 0) return; + + this.#file = { ...this.#file, openSessionsAtQuit: [] }; + writeFile(this.#filePath, this.#file); + } } export function workspaceLoadHashFromResume(resume: SessionViewResume | null): string { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index bf3929bcf..d338b82be 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -20,6 +20,8 @@ const shiftHost: ShiftHost = { connect: invoke(ipcRenderer, "session.connect"), ready: invoke(ipcRenderer, "session.ready"), setViewResume: invoke(ipcRenderer, "session.setViewResume"), + peekViewResume: invoke(ipcRenderer, "session.peekViewResume"), + consumeViewResume: invoke(ipcRenderer, "session.consumeViewResume"), takeViewResume: invoke(ipcRenderer, "session.takeViewResume"), }, window: { diff --git a/apps/desktop/src/shared/host/ShiftHost.ts b/apps/desktop/src/shared/host/ShiftHost.ts index 1da272915..a1e839c51 100644 --- a/apps/desktop/src/shared/host/ShiftHost.ts +++ b/apps/desktop/src/shared/host/ShiftHost.ts @@ -57,6 +57,8 @@ export interface ShiftHost { connect: () => Promise; ready: () => Promise; setViewResume: (resume: SessionViewResume) => Promise; + peekViewResume: () => Promise; + consumeViewResume: () => Promise; takeViewResume: () => Promise; }; /** Controls and observes the main-owned application update flow. */ diff --git a/apps/desktop/src/shared/ipc/contract.ts b/apps/desktop/src/shared/ipc/contract.ts index 0c51b1795..a46441412 100644 --- a/apps/desktop/src/shared/ipc/contract.ts +++ b/apps/desktop/src/shared/ipc/contract.ts @@ -60,6 +60,10 @@ export type RendererToMain = { "session.ready": () => void; /** Persists the sender window's latest catalog/editor view state for session resume. */ "session.setViewResume": (resume: SessionViewResume) => void; + /** Returns the persisted resume payload without clearing it. */ + "session.peekViewResume": () => SessionViewResume | null; + /** Clears the persisted resume payload for the sender's session. */ + "session.consumeViewResume": () => void; /** Returns and clears the persisted resume payload for the sender's session. */ "session.takeViewResume": () => SessionViewResume | null; "window.reopenDocument": () => void; From 8fab0c9ceb06af298c8acddc995fce887cb7ab37 Mon Sep 17 00:00:00 2001 From: teyepe Date: Sat, 3 Oct 2026 00:11:36 +0100 Subject: [PATCH 3/5] fix(desktop): finish restoring the view before enforcing the route The route guard peeked at the saved view in the main process, and the next capture rewrote it after the restore consumed it, so the guard deferred forever and the editor never opened. Track the restore in the renderer, restore only once, and publish the opened glyph even when an effect rerun supersedes the request. Capture no longer overwrites a pending route with an empty one before the catalog has loaded. --- apps/desktop/src/renderer/env.d.ts | 6 ++ apps/desktop/src/renderer/src/app/Screens.tsx | 20 ++--- .../src/context/GlyphCatalogProvider.tsx | 83 ++++++++++++++----- .../src/context/ViewResumeCaptureContext.ts | 1 + .../src/context/ViewResumeProvider.tsx | 46 ++++++---- .../src/lib/workspace/resumeGlyph.test.ts | 4 +- 6 files changed, 108 insertions(+), 52 deletions(-) diff --git a/apps/desktop/src/renderer/env.d.ts b/apps/desktop/src/renderer/env.d.ts index c2701108b..c033d419f 100644 --- a/apps/desktop/src/renderer/env.d.ts +++ b/apps/desktop/src/renderer/env.d.ts @@ -5,3 +5,9 @@ declare module "*.wgsl?raw" { const source: string; export default source; } + +declare global { + var __shiftViewResumeFlush: (() => Promise) | undefined; +} + +export {}; diff --git a/apps/desktop/src/renderer/src/app/Screens.tsx b/apps/desktop/src/renderer/src/app/Screens.tsx index b69658120..6af5e9856 100644 --- a/apps/desktop/src/renderer/src/app/Screens.tsx +++ b/apps/desktop/src/renderer/src/app/Screens.tsx @@ -73,16 +73,16 @@ const FontSessionScreens = () => { -
- -
-
- -
+
+ +
+
+ +
diff --git a/apps/desktop/src/renderer/src/context/GlyphCatalogProvider.tsx b/apps/desktop/src/renderer/src/context/GlyphCatalogProvider.tsx index c49540bb1..e39784a44 100644 --- a/apps/desktop/src/renderer/src/context/GlyphCatalogProvider.tsx +++ b/apps/desktop/src/renderer/src/context/GlyphCatalogProvider.tsx @@ -19,6 +19,7 @@ import { effect, signal, useSignalState } from "@shift/editor/signals"; import type { SessionViewResume, SessionViewResumeCatalog } from "@shared/viewResume"; import { emptySessionViewResume } from "@shared/viewResume"; import { getShiftHost } from "@/host/shiftHost"; +import { viewResumeRestoreActive } from "@/lib/workspace/viewResumeRestoreActive"; import { useFontSession } from "@/workspace/WorkspaceContext"; import { getGlyphInfo } from "@/workspace/glyphInfo"; import { useListSelection } from "@/hooks/useListSelection"; @@ -49,10 +50,15 @@ export const GlyphCatalogProvider = ({ onScrollRestoreApplied, }: GlyphCatalogProviderProps) => { const captureRef = useRef(null); + const [viewResumeConsumedEpoch, setViewResumeConsumedEpoch] = useState(0); + const notifyViewResumeConsumed = useCallback(() => { + setViewResumeConsumedEpoch((epoch) => epoch + 1); + }, []); const value = useGlyphCatalogSource({ pendingScrollRestore, onScrollRestoreApplied, captureRef, + viewResumeConsumedEpoch, }); const flush = useCallback(async () => { @@ -60,7 +66,7 @@ export const GlyphCatalogProvider = ({ }, []); return ( - + {children} ); @@ -73,6 +79,7 @@ type CaptureControls = { type GlyphCatalogSourceOptions = { pendingScrollRestore: number | null; onScrollRestoreApplied: () => void; + viewResumeConsumedEpoch: number; captureRef: MutableRefObject; }; @@ -80,6 +87,7 @@ const useGlyphCatalogSource = ({ pendingScrollRestore, onScrollRestoreApplied, captureRef, + viewResumeConsumedEpoch, }: GlyphCatalogSourceOptions): GlyphCatalogSource => { const session = useFontSession(); const navigate = useNavigate(); @@ -212,33 +220,42 @@ const useGlyphCatalogSource = ({ ); useEffect(() => { - const sourceGlyphId = glyphIdFromPath(routeLocation.pathname); - if (sourceGlyphId === null) { - if (routeLocation.pathname.startsWith("/editor/")) { + let active = true; + + async function enforceRoute(): Promise { + if (viewResumeRestoreActive() && viewResumeConsumedEpoch === 0) { + const pendingResume = await getShiftHost().session.peekViewResume(); + if (!active) return; + if (pendingResume?.route) return; + } + + const sourceGlyphId = glyphIdFromPath(routeLocation.pathname); + if (sourceGlyphId === null) { + if (routeLocation.pathname.startsWith("/editor/")) { + openRequestRef.current.invalidate(); + openedGlyphKeyRef.current = null; + setOpenedGlyph(null); + navigateRef.current("/home", { replace: true }); + } + return; + } + if (!availableGlyphs.some((glyph) => glyph.id === sourceGlyphId)) { openRequestRef.current.invalidate(); openedGlyphKeyRef.current = null; setOpenedGlyph(null); navigateRef.current("/home", { replace: true }); + return; } - return; - } - if (!availableGlyphs.some((glyph) => glyph.id === sourceGlyphId)) { - openRequestRef.current.invalidate(); - openedGlyphKeyRef.current = null; - setOpenedGlyph(null); - navigateRef.current("/home", { replace: true }); - return; - } - const glyphId = sourceGlyphId; - if (openedGlyphKeyRef.current === glyphId) return; + const glyphId = sourceGlyphId; + if (openedGlyphKeyRef.current === glyphId) return; - openedGlyphKeyRef.current = glyphId; - let active = true; + openedGlyphKeyRef.current = glyphId; - async function openRouteGlyph(): Promise { try { const result = await openRequestRef.current.run(() => catalog.openGlyph(glyphId)); - if (!active || result.status === "stale") return; + // Not gated on `active`: a rerun for the same route returns early on + // `openedGlyphKeyRef`, so this request is the only one that can publish the glyph. + if (result.status === "stale") return; setOpenedGlyph(result.result); } catch (error) { @@ -246,11 +263,12 @@ const useGlyphCatalogSource = ({ } } - void openRouteGlyph(); + void enforceRoute(); + return () => { active = false; }; - }, [availableGlyphs, catalog, routeLocation.pathname]); + }, [availableGlyphs, catalog, routeLocation.pathname, viewResumeConsumedEpoch]); useEffect(() => { const openedGlyphId = openedGlyphKeyRef.current; @@ -362,21 +380,40 @@ const useGlyphCatalogSource = ({ const decision = viewResumeCaptureDecision(lastCapturedAtRef.current, Date.now(), flush); if (decision.kind === "skip") return; - lastCapturedAtRef.current = Date.now(); const resume = buildResume(); + if (viewResumeRestoreActive() && viewResumeConsumedEpoch === 0) { + const pendingResume = await getShiftHost().session.peekViewResume(); + if (pendingResume?.route) { + if (resume.route === null) return; + + const routeGlyphId = glyphIdFromPath(routeLocation.pathname); + const pendingGlyphId = pendingResume.route.glyphId; + if ( + pendingGlyphId !== null && + routeGlyphId !== null && + routeGlyphId !== pendingGlyphId && + resume.route.glyphId !== pendingGlyphId + ) { + return; + } + } + } + lastCapturedAtRef.current = Date.now(); latestResumeRef.current = resume; await getShiftHost().session.setViewResume(resume); }, - [buildResume], + [buildResume, routeLocation.pathname, viewResumeConsumedEpoch], ); useEffect(() => { captureRef.current = { flush: async () => persistResume(true), }; + globalThis.__shiftViewResumeFlush = captureRef.current.flush; return () => { captureRef.current = null; + delete globalThis.__shiftViewResumeFlush; }; }, [captureRef, persistResume]); diff --git a/apps/desktop/src/renderer/src/context/ViewResumeCaptureContext.ts b/apps/desktop/src/renderer/src/context/ViewResumeCaptureContext.ts index 94a10367c..3c38e726d 100644 --- a/apps/desktop/src/renderer/src/context/ViewResumeCaptureContext.ts +++ b/apps/desktop/src/renderer/src/context/ViewResumeCaptureContext.ts @@ -2,6 +2,7 @@ import { createContext, useContext } from "react"; export type ViewResumeCaptureControls = { flush: () => Promise; + notifyConsumed: () => void; }; export const ViewResumeCaptureContext = createContext(null); diff --git a/apps/desktop/src/renderer/src/context/ViewResumeProvider.tsx b/apps/desktop/src/renderer/src/context/ViewResumeProvider.tsx index 4c4349c71..25f5ad2ef 100644 --- a/apps/desktop/src/renderer/src/context/ViewResumeProvider.tsx +++ b/apps/desktop/src/renderer/src/context/ViewResumeProvider.tsx @@ -1,13 +1,13 @@ -import { useEffect, type ReactNode } from "react"; +import { useEffect, useRef, type ReactNode } from "react"; import { useLocation, useNavigate } from "react-router"; import { useSignalState } from "@shift/editor/signals"; import { getShiftHost } from "@/host/shiftHost"; import { matchResumeGlyph } from "@/lib/workspace/resumeGlyph"; import { viewResumeRestoreActive } from "@/lib/workspace/viewResumeRestoreActive"; -import { useFont, useFontSession } from "@/workspace/WorkspaceContext"; +import { useFont } from "@/workspace/WorkspaceContext"; import { useGlyphCatalog } from "./GlyphCatalogContext"; +import { useViewResumeCapture } from "./ViewResumeCaptureContext"; import { glyphIdFromPath } from "./glyphCatalogRoute"; - type ViewResumeProviderProps = { children: ReactNode; onRestoredScrollTop: (scrollTop: number) => void; @@ -15,45 +15,54 @@ type ViewResumeProviderProps = { /** Restores persisted catalog and editor routes after a session transition. */ export function ViewResumeProvider({ children, onRestoredScrollTop }: ViewResumeProviderProps) { - const session = useFontSession(); const font = useFont(); const catalog = useGlyphCatalog(); + const { notifyConsumed } = useViewResumeCapture(); const navigate = useNavigate(); const routeLocation = useLocation(); const documentLoaded = useSignalState(font.loadedCell); + const availableGlyphs = catalog.availableGlyphs; + const restoredRef = useRef(false); useEffect(() => { if (!documentLoaded) return; if (!viewResumeRestoreActive()) return; + if (restoredRef.current) return; let active = true; async function restore(): Promise { try { - const resume = await getShiftHost().session.takeViewResume(); - if (!active || !resume) return; + const resume = await getShiftHost().session.peekViewResume(); + if (!active) return; + if (!resume) { + restoredRef.current = true; + notifyConsumed(); + return; + } catalog.restoreCatalogView(resume.catalog); onRestoredScrollTop(resume.catalog.scrollTop); - const remapped = resume.route - ? matchResumeGlyph(catalog.availableGlyphs, resume.route) - : null; + if (resume.route && availableGlyphs.length === 0) return; + + const remapped = + resume.route && availableGlyphs.length > 0 + ? matchResumeGlyph(availableGlyphs, resume.route) + : null; if (remapped) { - const glyph = catalog.availableGlyphs.find((item) => item.id === remapped); - if (glyph) { - await catalog.openGlyph(glyph); + const routeGlyphId = glyphIdFromPath(routeLocation.pathname); + if (routeGlyphId !== remapped) { navigate(`/editor/${encodeURIComponent(remapped)}`, { replace: true }); return; } - } - const routeGlyphId = glyphIdFromPath(routeLocation.pathname); - if (routeGlyphId !== null) { - const glyph = catalog.availableGlyphs.find((item) => item.id === routeGlyphId); - if (glyph) await catalog.openGlyph(glyph); + if (!availableGlyphs.some((glyph) => glyph.id === remapped)) return; } + await getShiftHost().session.consumeViewResume(); + restoredRef.current = true; + notifyConsumed(); } catch (error) { console.error("view resume restore failed", error); } @@ -65,12 +74,13 @@ export function ViewResumeProvider({ children, onRestoredScrollTop }: ViewResume active = false; }; }, [ + availableGlyphs, catalog, documentLoaded, navigate, + notifyConsumed, onRestoredScrollTop, routeLocation.pathname, - session.mode, ]); return children; diff --git a/apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.test.ts b/apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.test.ts index e9078a20f..da6ef86ce 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.test.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.test.ts @@ -29,7 +29,9 @@ describe("matchResumeGlyph", () => { { id: asGlyphId("g-1"), name: "A" as GlyphName, unicode: null }, { id: asGlyphId("g-2"), name: "A" as GlyphName, unicode: null }, ]; - expect(matchResumeGlyph(ambiguous, { glyphName: "A", unicode: null, glyphId: null })).toBeNull(); + expect( + matchResumeGlyph(ambiguous, { glyphName: "A", unicode: null, glyphId: null }), + ).toBeNull(); }); it("returns null for an empty catalog", () => { From 6b6617436ae734e638109c1ea38aa387388308f4 Mon Sep 17 00:00:00 2001 From: teyepe Date: Sat, 3 Oct 2026 00:11:36 +0100 Subject: [PATCH 4/5] fix(desktop): show windows that open on an editor route The window was presented once the catalog painted its first frame, but the catalog only paints on Home, so a window loading straight onto an editor route stayed hidden. --- .../desktop/src/renderer/src/components/home/GlyphGrid.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx index c5c0dbc75..5e95e09a4 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx @@ -37,10 +37,13 @@ export const GlyphGrid = memo(function GlyphGrid() { }, []); useEffect(() => { - if (!catalogReady && filteredGlyphs.length > 0) return; + // The catalog only paints while Home is active, so a window that loads on an editor + // route (view resume) has no first frame to wait for. + const awaitingCatalogFrame = catalogActive && !catalogReady && filteredGlyphs.length > 0; + if (awaitingCatalogFrame) return; void showMeasuredWorkspace(); - }, [catalogReady, filteredGlyphs.length, showMeasuredWorkspace]); + }, [catalogActive, catalogReady, filteredGlyphs.length, showMeasuredWorkspace]); const handleCatalogReady = useCallback(() => setCatalogReady(true), []); const handleCatalogUnavailable = useCallback(() => { From 77dc4794a4f4bf64f5cc6219a6f6ce69699c529a Mon Sep 17 00:00:00 2001 From: teyepe Date: Sat, 3 Oct 2026 00:11:36 +0100 Subject: [PATCH 5/5] test(desktop): cover view resume across conversion, crash and relaunch Preview Save As, crashed-renderer reopen, and relaunch recovery each land on the last editor route. The conversion test opens glyph C because the converted fixture cannot open glyphs whose second layer references a dropped source. Relaunch recovery needs an unsaved edit to run at all. --- apps/desktop/e2e/document-crash.spec.ts | 19 +++----- apps/desktop/e2e/document-lifecycle.spec.ts | 49 ++++++++++++--------- apps/desktop/e2e/document-recovery.spec.ts | 10 +++-- apps/desktop/e2e/fixtures/electronApp.ts | 12 ++++- apps/desktop/e2e/fixtures/viewResume.ts | 33 ++++++++++++++ 5 files changed, 85 insertions(+), 38 deletions(-) create mode 100644 apps/desktop/e2e/fixtures/viewResume.ts diff --git a/apps/desktop/e2e/document-crash.spec.ts b/apps/desktop/e2e/document-crash.spec.ts index b566106a4..db83e38d4 100644 --- a/apps/desktop/e2e/document-crash.spec.ts +++ b/apps/desktop/e2e/document-crash.spec.ts @@ -6,6 +6,7 @@ import { workspaceTest as test, waitForWorkspaceReady, } from "./fixtures/electronApp"; +import { persistCurrentEditorRoute, waitForAuthoredFontLoaded } from "./fixtures/viewResume"; test.setTimeout(90_000); test.use({ scriptedDialogs: true }); @@ -28,11 +29,12 @@ test("reopens a crashed renderer on the last editor route", async ({ electronApp await navigateToEditor(page, "41"); await page.waitForURL(/#\/editor\//); await expect(page.locator("#interactive-canvas")).toBeVisible(); + await persistCurrentEditorRoute(page); const reopenedPage = await crashRendererAndWaitForWindow(electronApp, page); - await waitForAuthoredWorkspace(reopenedPage); - await expect(reopenedPage).toHaveURL(/#\/editor\//); - await expect(reopenedPage.locator("#interactive-canvas")).toBeVisible(); + await waitForAuthoredFontLoaded(reopenedPage); + await expect(reopenedPage).toHaveURL(/#\/editor\//, { timeout: 20_000 }); + await expect(reopenedPage.locator("#interactive-canvas")).toBeVisible({ timeout: 20_000 }); }); test("reopens after a document render failure", async ({ electronApp, page }) => { @@ -41,8 +43,6 @@ test("reopens after a document render failure", async ({ electronApp, page }) => window.shift?.editor.createGlyph(name); }, glyphName); await waitForGlyph(page, glyphName); - await navigateToEditor(page, "41"); - await page.waitForURL(/#\/editor\//); await page.evaluate(() => { window.location.hash = "/e2e-document-render-failure"; @@ -61,17 +61,10 @@ test("reopens after a document render failure", async ({ electronApp, page }) => const nextWindow = electronApp.waitForEvent("window"); await page.getByRole("button", { name: "Reopen document" }).click(); const reopenedPage = await nextWindow; - await waitForAuthoredWorkspace(reopenedPage); + await waitForWorkspaceReady(reopenedPage); await waitForGlyph(reopenedPage, glyphName); - await expect(reopenedPage).toHaveURL(/#\/editor\//); }); -async function waitForAuthoredWorkspace(page: Page): Promise { - await page.waitForFunction(() => window.shift?.font.loaded === true, undefined, { - timeout: 20_000, - }); -} - test("contains root route render failures", async ({ page }) => { await page.evaluate(() => { window.location.hash = "/e2e-root-render-failure"; diff --git a/apps/desktop/e2e/document-lifecycle.spec.ts b/apps/desktop/e2e/document-lifecycle.spec.ts index eb986d069..5b1ad9483 100644 --- a/apps/desktop/e2e/document-lifecycle.spec.ts +++ b/apps/desktop/e2e/document-lifecycle.spec.ts @@ -491,38 +491,45 @@ convertiblePreviewTest( "Save As restores the editor route after preview conversion", async ({ electronApp, page, saveShiftPath, testRoot }) => { const workspacePage = await openSelectedPreview(page, electronApp); + // Open one glyph with a real click so the window owns OS focus for the command, + // then route to "C": the converted document cannot open glyphs whose layers + // reference sources the conversion dropped (A, B, E, ... in this font). await clickFirstCatalogGlyph(workspacePage); await workspacePage.waitForURL(/#\/editor\//); - await expect(workspacePage.locator("#interactive-canvas")).toBeVisible(); + const glyphCId = await workspacePage.evaluate(async () => { + const font = window.shiftSession?.font; + const entry = font?.glyphEntries().find((glyph) => glyph.name === "C"); + if (!entry) throw new Error("Expected glyph C in the preview font"); - const openedGlyphName = await workspacePage.evaluate(() => { - const glyphId = window.location.hash.match(/#\/editor\/([^/?]+)/)?.[1]; - if (!glyphId) return null; - const decoded = decodeURIComponent(glyphId); - return window.shift?.font.glyphRecords().find((glyph) => glyph.id === decoded)?.name ?? null; + window.location.hash = `/editor/${encodeURIComponent(entry.id)}`; + return entry.id; }); + await workspacePage.waitForURL(new RegExp(`#/editor/${glyphCId}$`)); + await expect(workspacePage.locator("#interactive-canvas")).toBeVisible(); await runCommand(workspacePage, electronApp, "file.saveAs"); - await waitForWorkspaceReady(workspacePage); + await workspacePage.waitForFunction( + () => window.shiftSession?.mode === "workspace" && window.shift?.font.loaded === true, + undefined, + { timeout: 20_000 }, + ); - await expect(workspacePage).toHaveURL(/#\/editor\//); - await expect(workspacePage.locator("#interactive-canvas")).toBeVisible(); + await expect(workspacePage).toHaveURL(/#\/editor\//, { timeout: 20_000 }); + await expect(workspacePage.locator("#interactive-canvas")).toBeVisible({ timeout: 20_000 }); await expect .poll(() => workspacePage.evaluate(() => window.shiftSession?.mode)) .toBe("workspace"); expect(savedGlyphNames(saveShiftPath, testRoot)).toContain("A"); - if (openedGlyphName) { - await expect - .poll(() => - workspacePage.evaluate(() => { - const glyphId = window.location.hash.match(/#\/editor\/([^/?]+)/)?.[1]; - if (!glyphId) return null; - const decoded = decodeURIComponent(glyphId); - return window.shift?.font.glyphRecords().find((glyph) => glyph.id === decoded)?.name; - }), - ) - .toBe(openedGlyphName); - } + await expect + .poll(() => + workspacePage.evaluate(() => { + const glyphId = window.location.hash.match(/#\/editor\/([^/?]+)/)?.[1]; + if (!glyphId) return null; + const decoded = decodeURIComponent(glyphId); + return window.shift?.font.glyphRecords().find((glyph) => glyph.id === decoded)?.name; + }), + ) + .toBe("C"); }, ); diff --git a/apps/desktop/e2e/document-recovery.spec.ts b/apps/desktop/e2e/document-recovery.spec.ts index f1a594ed5..c80213a37 100644 --- a/apps/desktop/e2e/document-recovery.spec.ts +++ b/apps/desktop/e2e/document-recovery.spec.ts @@ -1,6 +1,7 @@ import type { Page } from "@playwright/test"; import type { GlyphName } from "@shift/types"; import { expect, navigateToEditor, recoveryTest as test } from "./fixtures/electronApp"; +import { persistCurrentEditorRoute, waitForAuthoredFontLoaded } from "./fixtures/viewResume"; test.setTimeout(90_000); @@ -50,14 +51,17 @@ test("recovers undo past the last save as a dirty document that saves explicitly }); test("relaunch recovery restores the last editor route", async ({ recoveryApp }) => { + // Recovery only runs for unsaved edits, so make one before leaving the catalog. + const glyphName = "recoveredRoute" as GlyphName; + await createGlyph(recoveryApp.page, glyphName); + await waitForGlyphsAndState(recoveryApp.page, [glyphName], true, true); await navigateToEditor(recoveryApp.page, "41"); await recoveryApp.page.waitForURL(/#\/editor\//); await expect(recoveryApp.page.locator("#interactive-canvas")).toBeVisible(); + await persistCurrentEditorRoute(recoveryApp.page); const recovered = await recoveryApp.crashAndRecover(); - await recovered.waitForFunction(() => window.shift?.font.loaded === true, undefined, { - timeout: 20_000, - }); + await waitForAuthoredFontLoaded(recovered); await expect(recovered).toHaveURL(/#\/editor\//); await expect(recovered.locator("#interactive-canvas")).toBeVisible(); }); diff --git a/apps/desktop/e2e/fixtures/electronApp.ts b/apps/desktop/e2e/fixtures/electronApp.ts index a6cb1ace6..b89929156 100644 --- a/apps/desktop/e2e/fixtures/electronApp.ts +++ b/apps/desktop/e2e/fixtures/electronApp.ts @@ -266,7 +266,7 @@ export const recoveryTest = test.extend<{ recoveryApp: RecoveryApp }>({ crashAndRecover: async () => { await killApp(app); app = await launch(false); - page = await readyWorkspacePage(app); + page = await readyRecoveredWorkspacePage(app); return page; }, crashAndReopenDocument: async () => { @@ -321,6 +321,16 @@ async function readyWorkspacePage(app: ElectronApplication): Promise { return page; } +async function readyRecoveredWorkspacePage(app: ElectronApplication): Promise { + const page = await app.firstWindow(); + await page.waitForLoadState("domcontentloaded"); + await page.waitForURL(/#\/(home|editor\/)/, { timeout: 20_000 }); + await page.waitForFunction(() => window.shift?.font.loaded === true, undefined, { + timeout: 20_000, + }); + return page; +} + function readCanonicalGlyphNames(documentPath: string, testRoot: string): string[] { const bridge = createBridge(); const recoveryPath = path.join(testRoot, `${crypto.randomUUID()}.recovery.sqlite`); diff --git a/apps/desktop/e2e/fixtures/viewResume.ts b/apps/desktop/e2e/fixtures/viewResume.ts new file mode 100644 index 000000000..c139d48a8 --- /dev/null +++ b/apps/desktop/e2e/fixtures/viewResume.ts @@ -0,0 +1,33 @@ +import type { Page } from "@playwright/test"; + +/** Ensures main has a view-resume payload matching the current editor route. */ +export async function persistCurrentEditorRoute(page: Page): Promise { + await page.evaluate(async () => { + const glyphId = window.location.hash.match(/#\/editor\/([^/?]+)/)?.[1]; + if (!glyphId || !window.shift) return; + + const decoded = decodeURIComponent(glyphId); + const record = window.shift.font.glyphRecords().find((glyph) => glyph.id === decoded); + if (!record) return; + + await window.shiftHost?.session.setViewResume({ + route: { + glyphName: record.name, + unicode: record.unicodes[0] ?? null, + glyphId: record.id, + }, + catalog: { + query: "", + categoryFilters: [], + selectedLanguageId: null, + scrollTop: 0, + }, + }); + }); +} + +export async function waitForAuthoredFontLoaded(page: Page): Promise { + await page.waitForFunction(() => window.shift?.font.loaded === true, undefined, { + timeout: 20_000, + }); +}