diff --git a/apps/desktop/e2e/document-crash.spec.ts b/apps/desktop/e2e/document-crash.spec.ts index de47a13ef..db83e38d4 100644 --- a/apps/desktop/e2e/document-crash.spec.ts +++ b/apps/desktop/e2e/document-crash.spec.ts @@ -1,6 +1,12 @@ 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"; +import { persistCurrentEditorRoute, waitForAuthoredFontLoaded } from "./fixtures/viewResume"; test.setTimeout(90_000); test.use({ scriptedDialogs: true }); @@ -19,6 +25,18 @@ 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(); + await persistCurrentEditorRoute(page); + + const reopenedPage = await crashRendererAndWaitForWindow(electronApp, page); + 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 }) => { const glyphName = "reactDocumentRecovery" as GlyphName; await page.evaluate((name) => { diff --git a/apps/desktop/e2e/document-lifecycle.spec.ts b/apps/desktop/e2e/document-lifecycle.spec.ts index f52271659..5b1ad9483 100644 --- a/apps/desktop/e2e/document-lifecycle.spec.ts +++ b/apps/desktop/e2e/document-lifecycle.spec.ts @@ -488,21 +488,48 @@ 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); + // 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\//); + 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"); + + 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(/#\/home$/); + 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"); + 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 8a28f35c4..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, recoveryTest as test } from "./fixtures/electronApp"; +import { expect, navigateToEditor, recoveryTest as test } from "./fixtures/electronApp"; +import { persistCurrentEditorRoute, waitForAuthoredFontLoaded } from "./fixtures/viewResume"; test.setTimeout(90_000); @@ -49,6 +50,22 @@ 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 }) => { + // 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 waitForAuthoredFontLoaded(recovered); + 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/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, + }); +} diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index f717e49b3..810470162 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[] = []; @@ -74,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; @@ -91,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; @@ -150,6 +156,8 @@ export class App { lifecycle: this.#lifecycle, activeWindow: () => this.#windows.activeWindow(), log: createShiftLogger("app.update"), + recordOpenSessionsForUpdateRestart: () => this.#recordOpenSessionsForUpdateRestart(), + clearOpenSessionsForUpdateRestart: () => this.#clearOpenSessionsForUpdateRestart(), }); } @@ -213,6 +221,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) { @@ -385,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); @@ -398,11 +413,72 @@ 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); + } + + 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; + + 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); + } + + #clearOpenSessionsForUpdateRestart(): void { + this.#viewResume?.clearOpenSessionsAtQuit(); + } + + 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 +597,22 @@ 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.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; + }); ipc.handle(ipcMain, "session.ready", (event) => { if (SLUG_ATLAS_PROFILING_ENABLED) { this.#log.info("[slug-atlas-profile]", { @@ -629,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); @@ -650,6 +744,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/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 new file mode 100644 index 000000000..1453a63db --- /dev/null +++ b/apps/desktop/src/main/viewResume/persistViewResume.test.ts @@ -0,0 +1,52 @@ +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("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") }; + 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..0b6d9e08c --- /dev/null +++ b/apps/desktop/src/main/viewResume/persistViewResume.ts @@ -0,0 +1,128 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { + SessionViewResume, + SessionViewResumeOpenSession, + ViewResumeFile, +} 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; + } + + 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 { + 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..d338b82be 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -19,6 +19,10 @@ const shiftHost: ShiftHost = { mode: invoke(ipcRenderer, "session.mode"), 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: { reopenDocument: invoke(ipcRenderer, "window.reopenDocument"), 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 6d02d7851..6af5e9856 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,28 +57,35 @@ 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..5e95e09a4 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); @@ -33,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(() => { @@ -49,6 +56,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..e39784a44 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,20 @@ 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 { viewResumeRestoreActive } from "@/lib/workspace/viewResumeRestoreActive"; 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 +38,57 @@ 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 [viewResumeConsumedEpoch, setViewResumeConsumedEpoch] = useState(0); + const notifyViewResumeConsumed = useCallback(() => { + setViewResumeConsumedEpoch((epoch) => epoch + 1); + }, []); + const value = useGlyphCatalogSource({ + pendingScrollRestore, + onScrollRestoreApplied, + captureRef, + viewResumeConsumedEpoch, + }); + + const flush = useCallback(async () => { + await captureRef.current?.flush(); + }, []); + + return ( + + {children} + + ); }; -const useGlyphCatalogSource = (): GlyphCatalogSource => { +type CaptureControls = { + flush: () => Promise; +}; + +type GlyphCatalogSourceOptions = { + pendingScrollRestore: number | null; + onScrollRestoreApplied: () => void; + viewResumeConsumedEpoch: number; + captureRef: MutableRefObject; +}; + +const useGlyphCatalogSource = ({ + pendingScrollRestore, + onScrollRestoreApplied, + captureRef, + viewResumeConsumedEpoch, +}: GlyphCatalogSourceOptions): GlyphCatalogSource => { const session = useFontSession(); const navigate = useNavigate(); const navigateRef = useRef(navigate); @@ -73,6 +133,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])), @@ -157,33 +220,42 @@ const useGlyphCatalogSource = (): GlyphCatalogSource => { ); 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) { @@ -191,11 +263,12 @@ const useGlyphCatalogSource = (): GlyphCatalogSource => { } } - void openRouteGlyph(); + void enforceRoute(); + return () => { active = false; }; - }, [availableGlyphs, catalog, routeLocation.pathname]); + }, [availableGlyphs, catalog, routeLocation.pathname, viewResumeConsumedEpoch]); useEffect(() => { const openedGlyphId = openedGlyphKeyRef.current; @@ -263,6 +336,102 @@ 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; + + 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, routeLocation.pathname, viewResumeConsumedEpoch], + ); + + useEffect(() => { + captureRef.current = { + flush: async () => persistResume(true), + }; + globalThis.__shiftViewResumeFlush = captureRef.current.flush; + + return () => { + captureRef.current = null; + delete globalThis.__shiftViewResumeFlush; + }; + }, [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 +476,10 @@ const useGlyphCatalogSource = (): GlyphCatalogSource => { setCategoryFilters([]); setSelectedLanguageId(languageId); }, + restoreCatalogView, + pendingCatalogScrollTop: pendingScrollRestore, + acknowledgeCatalogScrollApplied: onScrollRestoreApplied, + reportCatalogScrollTop: setReportedCatalogScrollTop, }; }; @@ -360,17 +533,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..3c38e726d --- /dev/null +++ b/apps/desktop/src/renderer/src/context/ViewResumeCaptureContext.ts @@ -0,0 +1,17 @@ +import { createContext, useContext } from "react"; + +export type ViewResumeCaptureControls = { + flush: () => Promise; + notifyConsumed: () => void; +}; + +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..25f5ad2ef --- /dev/null +++ b/apps/desktop/src/renderer/src/context/ViewResumeProvider.tsx @@ -0,0 +1,87 @@ +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 } from "@/workspace/WorkspaceContext"; +import { useGlyphCatalog } from "./GlyphCatalogContext"; +import { useViewResumeCapture } from "./ViewResumeCaptureContext"; +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 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.peekViewResume(); + if (!active) return; + if (!resume) { + restoredRef.current = true; + notifyConsumed(); + return; + } + + catalog.restoreCatalogView(resume.catalog); + onRestoredScrollTop(resume.catalog.scrollTop); + + if (resume.route && availableGlyphs.length === 0) return; + + const remapped = + resume.route && availableGlyphs.length > 0 + ? matchResumeGlyph(availableGlyphs, resume.route) + : null; + + if (remapped) { + const routeGlyphId = glyphIdFromPath(routeLocation.pathname); + if (routeGlyphId !== remapped) { + navigate(`/editor/${encodeURIComponent(remapped)}`, { replace: true }); + return; + } + + 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); + } + } + + void restore(); + + return () => { + active = false; + }; + }, [ + availableGlyphs, + catalog, + documentLoaded, + navigate, + notifyConsumed, + onRestoredScrollTop, + routeLocation.pathname, + ]); + + 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..da6ef86ce --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/workspace/resumeGlyph.test.ts @@ -0,0 +1,40 @@ +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..a1e839c51 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,10 @@ 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. */ update: { diff --git a/apps/desktop/src/shared/ipc/contract.ts b/apps/desktop/src/shared/ipc/contract.ts index ca1c233c1..a46441412 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,14 @@ 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 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; /** 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, + }, + }; +}