From 2ca998918a2577b414e9b4bd778b975e4f9ac29e Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Thu, 25 Jun 2026 08:59:10 +0100 Subject: [PATCH 1/4] Create workspace sessions per font window --- apps/desktop/src/main/app/App.ts | 225 ++++++++++++++---- apps/desktop/src/main/app/AppLifecycle.ts | 30 ++- apps/desktop/src/main/commands/Command.ts | 2 + apps/desktop/src/main/commands/Commands.ts | 4 +- .../src/main/document/DocumentClient.ts | 15 +- .../src/main/document/DocumentSession.ts | 129 ++-------- .../src/main/document/openFontDialog.ts | 41 ++++ .../desktop/src/main/windows/WindowManager.ts | 35 +++ .../src/main/workspace/WorkspaceManager.ts | 124 ++++++++++ .../src/main/workspace/WorkspaceProcess.ts | 24 ++ .../src/main/workspace/WorkspaceSession.ts | 117 +++++++++ apps/desktop/src/preload/preload.ts | 2 + apps/desktop/src/renderer/src/app/Screens.tsx | 55 +++-- .../src/renderer/src/lib/model/Font.test.ts | 8 +- .../renderer/src/lib/model/variation.test.ts | 2 +- .../renderer/src/lib/text/layout/testUtils.ts | 2 +- .../lib/workspace/WorkspaceEditQueue.test.ts | 2 +- .../src/lib/workspace/WorkspaceEditQueue.ts | 12 - .../src/lib/workspace/WorkspaceSession.ts | 17 +- .../renderer/src/perf/workspaceApply.bench.ts | 2 +- .../src/renderer/src/store/appStore.ts | 32 ++- .../src/renderer/src/testing/TestEditor.ts | 2 +- .../renderer/src/testing/workspaceStack.ts | 15 +- .../src/renderer/src/views/Landing.tsx | 8 +- apps/desktop/src/shared/host/ShiftHost.ts | 16 +- apps/desktop/src/shared/ipc/contract.ts | 6 +- apps/desktop/src/shared/workspace/protocol.ts | 18 +- .../utility/workspace/WorkspaceHost.test.ts | 115 +++++++-- .../src/utility/workspace/WorkspaceHost.ts | 16 +- 29 files changed, 793 insertions(+), 283 deletions(-) create mode 100644 apps/desktop/src/main/document/openFontDialog.ts create mode 100644 apps/desktop/src/main/windows/WindowManager.ts create mode 100644 apps/desktop/src/main/workspace/WorkspaceManager.ts create mode 100644 apps/desktop/src/main/workspace/WorkspaceSession.ts diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index ccfaaa55..cf92559d 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -1,4 +1,4 @@ -import { app, ipcMain, MessageChannelMain } from "electron"; +import { app, BrowserWindow, ipcMain, MessageChannelMain, type WebContents } from "electron"; import started from "electron-squirrel-startup"; import path from "node:path"; import { Window } from "../windows/Window"; @@ -10,11 +10,16 @@ import { registerCommands } from "../commands/Commands"; import { ApplicationMenu } from "../menu/ApplicationMenu"; import { createShiftLogger, type ShiftLogger } from "../logging"; import { WorkspaceProcess } from "../workspace/WorkspaceProcess"; -import { DocumentSession } from "../document/DocumentSession"; import { DocumentClient } from "../document/DocumentClient"; import { AppLifecycle } from "./AppLifecycle"; +import { WindowManager } from "../windows/WindowManager"; +import { WorkspaceManager } from "../workspace/WorkspaceManager"; +import { WorkspaceSession } from "../workspace/WorkspaceSession"; +import type { WorkspaceDocumentState } from "../../shared/workspace/protocol"; +import { showOpenFontDialog } from "../document/openFontDialog"; const APP_NAME = "Shift"; +type RendererRoute = "launcher" | "workspace"; /** * Owns Electron app startup and the first main-process service graph. @@ -28,8 +33,10 @@ export class App { readonly #log: ShiftLogger; readonly #lifecycle: AppLifecycle; - #workingWindow: Window | null = null; #commands = new CommandRegistry(); + #windows = new WindowManager(); + #workspaces = new WorkspaceManager(); + #documentsRoot: string | null = null; #appIcon = new AppIcon(); #applicationMenu = new ApplicationMenu(this.#appIcon.path(), (id) => { @@ -40,18 +47,12 @@ export class App { }); }); - #workspace = new WorkspaceProcess(); - #documentClient = new DocumentClient(); - #document = new DocumentSession({ - document: this.#documentClient, - activeWindow: () => this.#workingWindow, - applicationName: () => this.applicationName, - }); - constructor(log: ShiftLogger = createShiftLogger("app")) { this.#log = log; this.#lifecycle = new AppLifecycle({ - document: this.#document, + documentForWindow: (window) => + this.#workspaces.getForBrowserWindow(window.window)?.document ?? null, + documents: () => this.#workspaces.list().map((session) => session.document), log: this.#log, }); } @@ -89,49 +90,60 @@ export class App { void app.whenReady().then(() => { this.#log.info("running when ready callback"); - const documentsRoot = path.join(app.getPath("userData"), "working-documents"); - this.#workspace.start(documentsRoot); - this.#workspace.onDocumentChanged((state) => this.#document.acceptState(state)); + this.#documentsRoot = path.join(app.getPath("userData"), "working-documents"); this.#appIcon.install(); this.#applicationMenu.install(); - this.#workingWindow = new Window({ - preloadPath: path.join(__dirname, "preload.js"), - }); - this.#lifecycle.registerWindow(this.#workingWindow, { - onClosed: () => { - this.#log.info("working window closed"); - this.#workingWindow = null; - this.#documentClient.dispose(); - }, - }); - - this.#loadRenderer(); + const window = this.#createWindow(); + this.#loadRenderer(window, "launcher"); this.#log.info("finished when ready callback"); }); app.on("will-quit", () => { this.#log.info("will quit: disposing app services"); - this.#documentClient.dispose(); - this.#workspace.stop(); + for (const session of this.#workspaces.list()) { + this.#workspaces.unregister(session.workspaceId); + } }); } - #loadRenderer() { - if (!this.#workingWindow) return; + #createWindow(): Window { + const window = new Window({ + preloadPath: path.join(__dirname, "preload.js"), + }); + this.#windows.add(window); + + this.#lifecycle.registerWindow(window, { + onClosed: () => { + this.#log.info("working window closed"); + const session = this.#workspaces.getForBrowserWindow(window.window); + this.#workspaces.detachWindow(window); + if (session?.windows.size === 0) { + this.#workspaces.unregister(session.workspaceId); + } + this.#windows.remove(window); + }, + }); + return window; + } + + #loadRenderer(window: Window, route: RendererRoute) { const source = getRendererSource(); + const hash = route === "workspace" ? "/home" : "/launcher"; if (source.type === "url") { // in dev load the renderer from vite at MAIN_WINDOW_VITE_DEV_SERVER_URL - this.#log.info("loading dev server url", { url: source.source }); - this.#workingWindow.window.loadURL(source.source); + const url = new URL(source.source); + url.hash = hash; + this.#log.info("loading dev server url", { url: url.toString() }); + window.window.loadURL(url.toString()); return; } // otherwise this is the build, load the built file directly this.#log.info("loading build file at", { path: source.source }); - this.#workingWindow.window.loadFile(source.source); + window.window.loadFile(source.source, { hash }); } #registerCommands(): void { @@ -142,21 +154,29 @@ export class App { ipc.handle(ipcMain, "commands.run", (_event, id) => { return this.#commands.run(id, this.#commandContext()); }); + ipc.handle(ipcMain, "workspace.create", async (event) => { + await this.#createWorkspaceForSender(event.sender); + }); + ipc.handle(ipcMain, "workspace.open", async (event) => { + await this.#openWorkspaceForSender(event.sender); + }); ipc.handle(ipcMain, "document.connect", (event) => { this.#log.info("document connect requested"); + const session = this.#workspaceForSender(event.sender, "document.connect"); const { port1, port2 } = new MessageChannelMain(); - this.#documentClient.connect(port1); + session.documentClient.connect(port1); event.sender.postMessage("document.port", null, [port2]); this.#log.info("document port sent to renderer"); }); ipc.handle(ipcMain, "workspace.connect", async (event) => { this.#log.info("workspace connect requested"); + const session = this.#workspaceForSender(event.sender, "workspace.connect"); const { port1, port2 } = new MessageChannelMain(); try { - await this.#workspace.whenReady(); - await this.#workspace.connectSyncLane(port1); + await session.workspaceProcess.whenReady(); + await session.workspaceProcess.connectSyncLane(port1); } catch (error) { this.#log.error("workspace connect failed", error); port1.close(); @@ -172,14 +192,135 @@ export class App { #commandContext(): CommandContext { return { document: { - create: () => this.#document.create(), - open: () => this.#document.open(), - save: () => this.#document.save(), - saveAs: () => this.#document.saveAs(), + create: async () => { + const window = this.#windows.activeWindow(); + if (!window) return; + + await this.#createWorkspaceFromWindow(window); + }, + open: async () => { + const window = this.#windows.activeWindow(); + if (!window) return; + + await this.#openWorkspaceFromWindow(window); + }, + hasWorkspace: () => this.#activeWorkspaceSession() !== null, + save: async () => { + await this.#requireActiveWorkspaceSession("file.save").document.save(); + }, + saveAs: async () => { + await this.#requireActiveWorkspaceSession("file.saveAs").document.saveAs(); + }, }, windows: { - active: () => this.#workingWindow, + active: () => this.#windows.activeWindow(), }, }; } + + async #createWorkspaceForSender(sender: WebContents): Promise { + await this.#createWorkspaceFromWindow(this.#requireWindowForWebContents(sender)); + } + + async #openWorkspaceForSender(sender: WebContents): Promise { + await this.#openWorkspaceFromWindow(this.#requireWindowForWebContents(sender)); + } + + async #createWorkspaceFromWindow(opener: Window): Promise { + const { session, state } = await this.#createWorkspaceSession((workspaceProcess) => + workspaceProcess.createWorkspace(), + ); + this.#openWorkspaceWindow(opener, session, state); + } + + async #openWorkspaceFromWindow(opener: Window): Promise { + const openPath = await showOpenFontDialog(opener); + if (!openPath) return; + + const { session, state } = await this.#createWorkspaceSession((workspaceProcess) => + workspaceProcess.openWorkspace(openPath), + ); + this.#openWorkspaceWindow(opener, session, state); + } + + async #createWorkspaceSession( + load: (workspaceProcess: WorkspaceProcess) => Promise, + ): Promise<{ session: WorkspaceSession; state: WorkspaceDocumentState }> { + const workspaceProcess = new WorkspaceProcess(); + workspaceProcess.start(this.#requireDocumentsRoot()); + + try { + await workspaceProcess.whenReady(); + const state = await load(workspaceProcess); + const existing = this.#workspaces.get(state.documentId); + if (existing) { + workspaceProcess.stop(); + existing.document.acceptState(state); + return { session: existing, state }; + } + + const session = new WorkspaceSession({ + workspaceId: state.documentId, + workspaceProcess, + documentClient: new DocumentClient(), + applicationName: () => this.applicationName, + }); + + session.document.acceptState(state); + this.#workspaces.register(session); + return { session, state }; + } catch (error) { + workspaceProcess.stop(); + throw error; + } + } + + #openWorkspaceWindow( + opener: Window, + session: WorkspaceSession, + state: WorkspaceDocumentState, + ): void { + const closeOpener = this.#workspaces.getForBrowserWindow(opener.window) === null; + const workspaceWindow = this.#createWindow(); + this.#workspaces.attachWindow(session.workspaceId, workspaceWindow); + session.document.acceptState(state); + this.#loadRenderer(workspaceWindow, "workspace"); + if (closeOpener) opener.close(); + } + + #workspaceForSender(sender: WebContents, operation: string): WorkspaceSession { + const window = this.#requireWindowForWebContents(sender); + const session = this.#workspaces.getForBrowserWindow(window.window); + if (!session) { + throw new Error(`${operation} requires a workspace-bound window`); + } + + return session; + } + + #activeWorkspaceSession(): WorkspaceSession | null { + const window = this.#windows.activeWindow(); + return window ? this.#workspaces.getForBrowserWindow(window.window) : null; + } + + #requireActiveWorkspaceSession(operation: string): WorkspaceSession { + const session = this.#activeWorkspaceSession(); + if (!session) throw new Error(`${operation} requires an active workspace window`); + return session; + } + + #requireDocumentsRoot(): string { + if (!this.#documentsRoot) throw new Error("documents root is not ready"); + return this.#documentsRoot; + } + + #requireWindowForWebContents(webContents: WebContents): Window { + const browserWindow = BrowserWindow.fromWebContents(webContents); + const window = browserWindow ? this.#windows.windowForBrowserWindow(browserWindow) : null; + if (!window) { + throw new Error("workspace request came from an unknown window"); + } + + return window; + } } diff --git a/apps/desktop/src/main/app/AppLifecycle.ts b/apps/desktop/src/main/app/AppLifecycle.ts index 18968fb3..9dcd1423 100644 --- a/apps/desktop/src/main/app/AppLifecycle.ts +++ b/apps/desktop/src/main/app/AppLifecycle.ts @@ -9,7 +9,8 @@ export type CloseConfirmation = { }; export type AppLifecycleOptions = { - document: CloseConfirmation; + documentForWindow: (window: Window) => CloseConfirmation | null; + documents: () => readonly CloseConfirmation[]; log: ShiftLogger; }; @@ -25,10 +26,11 @@ type QuitState = "idle" | "confirming" | "confirmed"; * @remarks * Electron exposes window close and app quit as separate event paths. This * coordinator keeps their re-entrant state in one place and exposes a narrow - * `registerWindow` surface to the app bootstrap. + * `registerWindow` surface to app startup. */ export class AppLifecycle { - readonly #document: CloseConfirmation; + readonly #documentForWindow: (window: Window) => CloseConfirmation | null; + readonly #documents: () => readonly CloseConfirmation[]; readonly #log: ShiftLogger; #quitState: QuitState = "idle"; @@ -36,7 +38,8 @@ export class AppLifecycle { #pendingWindowCloses = new Set(); constructor(options: AppLifecycleOptions) { - this.#document = options.document; + this.#documentForWindow = options.documentForWindow; + this.#documents = options.documents; this.#log = options.log; } @@ -71,7 +74,8 @@ export class AppLifecycle { return; } - if (!this.#document.shouldConfirmClose()) { + const document = this.#documentForWindow(window); + if (!document?.shouldConfirmClose()) { this.#log.debug("window close guard skipped", { windowId }); return; } @@ -84,7 +88,7 @@ export class AppLifecycle { this.#pendingWindowCloses.add(windowId); this.#log.info("window close guard started", { windowId }); - void this.#document + void document .confirmClose("window") .then((confirmed) => { if (!confirmed) { @@ -111,7 +115,8 @@ export class AppLifecycle { return; } - if (!this.#document.shouldConfirmClose()) { + const documents = this.#documents().filter((document) => document.shouldConfirmClose()); + if (documents.length === 0) { this.#log.info("quit guard skipped"); return; } @@ -124,8 +129,7 @@ export class AppLifecycle { this.#quitState = "confirming"; this.#log.info("quit guard started"); - void this.#document - .confirmClose("quit") + void this.#confirmQuit(documents) .then((confirmed) => { if (!confirmed) { this.#quitState = "idle"; @@ -142,4 +146,12 @@ export class AppLifecycle { this.#log.error("quit guard failed", error); }); } + + async #confirmQuit(documents: readonly CloseConfirmation[]): Promise { + for (const document of documents) { + if (!(await document.confirmClose("quit"))) return false; + } + + return true; + } } diff --git a/apps/desktop/src/main/commands/Command.ts b/apps/desktop/src/main/commands/Command.ts index 54af1577..505810ac 100644 --- a/apps/desktop/src/main/commands/Command.ts +++ b/apps/desktop/src/main/commands/Command.ts @@ -91,6 +91,8 @@ export type CommandContext = { /** Creates a new untitled workspace through main's document workflow. */ create: () => Promise; open: () => Promise; + /** Returns whether the active window is attached to a workspace. */ + hasWorkspace: () => boolean; /** Saves through main's native document workflow. */ save: () => Promise; /** Runs main's native Save As workflow. */ diff --git a/apps/desktop/src/main/commands/Commands.ts b/apps/desktop/src/main/commands/Commands.ts index d2131366..7a79dec5 100644 --- a/apps/desktop/src/main/commands/Commands.ts +++ b/apps/desktop/src/main/commands/Commands.ts @@ -79,14 +79,14 @@ const fileCommands: Command[] = [ id: "file.save", label: "Save", accelerator: "CmdOrCtrl+S", - enabled: (ctx) => ctx.windows.active() !== null, + enabled: (ctx) => ctx.document.hasWorkspace(), run: (ctx) => ctx.document.save(), }, { id: "file.saveAs", label: "Save As...", accelerator: "CmdOrCtrl+Shift+S", - enabled: (ctx) => ctx.windows.active() !== null, + enabled: (ctx) => ctx.document.hasWorkspace(), run: (ctx) => ctx.document.saveAs(), }, ]; diff --git a/apps/desktop/src/main/document/DocumentClient.ts b/apps/desktop/src/main/document/DocumentClient.ts index 075459b6..cff21584 100644 --- a/apps/desktop/src/main/document/DocumentClient.ts +++ b/apps/desktop/src/main/document/DocumentClient.ts @@ -7,18 +7,15 @@ import { createShiftLogger, type ShiftLogger } from "../logging"; export interface Document { readonly connected: boolean; state(): Promise; - create(): Promise; save(path: string | null): Promise; - open(path: string): Promise; } /** * Main-process client for document operations served by the renderer. * * @remarks - * The renderer owns the committed edit lane, so main routes document state, - * create, save, and open requests through this client instead of reading or - * mutating the utility process directly. + * The renderer owns the committed edit lane, so main routes document state + * reads and saves through this client to flush pending edits first. */ export class DocumentClient implements Document { readonly #log: ShiftLogger; @@ -49,18 +46,10 @@ export class DocumentClient implements Document { return this.#call("document.state", undefined); } - create(): Promise { - return this.#call("document.create", undefined); - } - save(path: string | null): Promise { return this.#call("document.save", { path }); } - open(path: string): Promise { - return this.#call("document.open", { path }); - } - /** Disconnects the active renderer document lane. */ dispose(): void { if (!this.#channel) { diff --git a/apps/desktop/src/main/document/DocumentSession.ts b/apps/desktop/src/main/document/DocumentSession.ts index b832da8f..2f2a9bc2 100644 --- a/apps/desktop/src/main/document/DocumentSession.ts +++ b/apps/desktop/src/main/document/DocumentSession.ts @@ -1,9 +1,4 @@ -import { - dialog, - type MessageBoxOptions, - type OpenDialogOptions, - type SaveDialogOptions, -} from "electron"; +import { dialog, type MessageBoxOptions, type SaveDialogOptions } from "electron"; import path from "node:path"; import { errorToMessage } from "../../shared/errors"; import type { WorkspaceDocumentState } from "../../shared/workspace/protocol"; @@ -11,24 +6,15 @@ import type { Window } from "../windows/Window"; import type { Document } from "./DocumentClient"; import { createShiftLogger, type ShiftLogger } from "../logging"; -const OPEN_FONT_EXTENSIONS = [ - "shift", - "ttf", - "otf", - "glyphs", - "glyphspackage", - "ufo", - "designspace", -]; - export type DocumentSessionOptions = { document: Document; - activeWindow: () => Window | null; + dialogWindow: () => Window | null; + windows: () => readonly Window[]; applicationName: () => string; log?: ShiftLogger; }; -export type CloseReason = "window" | "quit" | "replace-document"; +export type CloseReason = "window" | "quit"; type DirtyDocumentChoice = "save" | "discard" | "cancel"; /** @@ -36,13 +22,13 @@ type DirtyDocumentChoice = "save" | "discard" | "cancel"; * * @remarks * Main owns the shell chrome and native dialogs. Document state reads and - * writes that affect replacement or save decisions go through the renderer's - * committed-op lane so pending edits cannot be bypassed by main-process state - * reads. + * writes that affect save and close decisions go through the renderer's + * committed-op lane so pending edits cannot be bypassed by main-process reads. */ export class DocumentSession { readonly #document: Document; - readonly #activeWindow: () => Window | null; + readonly #dialogWindow: () => Window | null; + readonly #windows: () => readonly Window[]; readonly #applicationName: () => string; readonly #log: ShiftLogger; @@ -50,41 +36,12 @@ export class DocumentSession { constructor(options: DocumentSessionOptions) { this.#document = options.document; - this.#activeWindow = options.activeWindow; + this.#dialogWindow = options.dialogWindow; + this.#windows = options.windows; this.#applicationName = options.applicationName; this.#log = options.log ?? createShiftLogger("document.session"); } - /** Creates an untitled workspace through the renderer edit lane. */ - async create(): Promise { - this.#log.info("new document requested"); - if (!(await this.confirmClose("replace-document"))) { - this.#log.info("new document canceled by close guard"); - return; - } - - await this.#document.create(); - this.#log.info("new document created"); - } - - /** Runs Open from main with a native open dialog. */ - async open(): Promise { - this.#log.info("open document requested"); - const openPath = await this.#showOpenDialog(); - if (!openPath) { - this.#log.info("open document canceled before file selection"); - return; - } - - if (!(await this.confirmClose("replace-document"))) { - this.#log.info("open document canceled by close guard", { path: openPath }); - return; - } - - await this.#requestOpen(openPath); - this.#log.info("open document completed", { path: openPath }); - } - /** Returns whether a close transition needs document confirmation. */ shouldConfirmClose(): boolean { const shouldConfirm = this.#document.connected || this.#state?.dirty === true; @@ -97,9 +54,9 @@ export class DocumentSession { } /** - * Confirms whether the current document may be closed or replaced. + * Confirms whether the current document may be closed. * - * @param reason - Native transition that would discard or replace the document. + * @param reason - Native transition that would discard the document. * @returns `true` when the transition may continue. * @throws {Error} when the renderer cannot provide a settled document state. */ @@ -121,7 +78,7 @@ export class DocumentSession { return true; } - const choice = await this.#showDirtyDocumentDialog(state, reason); + const choice = await this.#showDirtyDocumentDialog(state); this.#log.info("dirty document dialog completed", { reason, choice, @@ -254,11 +211,6 @@ export class DocumentSession { return state; } - async #requestOpen(openPath: string): Promise { - this.#log.info("document open sent to renderer", { path: openPath }); - await this.#document.open(openPath); - } - async #showSaveDialog(state: WorkspaceDocumentState): Promise { const options: SaveDialogOptions = { title: "Save Shift Document", @@ -267,7 +219,7 @@ export class DocumentSession { properties: ["createDirectory", "showOverwriteConfirmation"], }; - const window = this.#activeWindow(); + const window = this.#dialogWindow(); const result = window ? await dialog.showSaveDialog(window.window, options) : await dialog.showSaveDialog(options); @@ -275,10 +227,7 @@ export class DocumentSession { return result.canceled ? null : (result.filePath ?? null); } - async #showDirtyDocumentDialog( - state: WorkspaceDocumentState, - reason: CloseReason, - ): Promise { + async #showDirtyDocumentDialog(state: WorkspaceDocumentState): Promise { const name = state.saveTarget ? path.basename(state.saveTarget) : "Untitled"; const options: MessageBoxOptions = { type: "warning", @@ -287,11 +236,11 @@ export class DocumentSession { cancelId: 2, noLink: true, title: this.#applicationName(), - message: this.#dirtyDocumentMessage(reason, name), + message: this.#dirtyDocumentMessage(name), detail: "Your changes will be lost if you don't save them.", }; - const window = this.#activeWindow(); + const window = this.#dialogWindow(); const result = window ? await dialog.showMessageBox(window.window, options) : await dialog.showMessageBox(options); @@ -311,7 +260,7 @@ export class DocumentSession { detail: errorToMessage(error), }; - const window = this.#activeWindow(); + const window = this.#dialogWindow(); if (window) { await dialog.showMessageBox(window.window, options); return; @@ -320,50 +269,24 @@ export class DocumentSession { await dialog.showMessageBox(options); } - #dirtyDocumentMessage(reason: CloseReason, name: string): string { - if (reason === "replace-document") { - return `Save changes to ${name} before replacing it?`; - } - + #dirtyDocumentMessage(name: string): string { return `Save changes to ${name} before closing?`; } - async #showOpenDialog(): Promise { - const options: OpenDialogOptions = { - title: "Open Font", - filters: [ - { name: "Supported Fonts", extensions: OPEN_FONT_EXTENSIONS }, - { name: "Shift Source Package", extensions: ["shift"] }, - { name: "TrueType/OpenType", extensions: ["ttf", "otf"] }, - { name: "Glyphs", extensions: ["glyphs", "glyphspackage"] }, - { name: "UFO/Designspace", extensions: ["ufo", "designspace"] }, - ], - properties: process.platform === "darwin" ? ["openFile", "openDirectory"] : ["openFile"], - }; - - const window = this.#activeWindow(); - const result = window - ? await dialog.showOpenDialog(window.window, options) - : await dialog.showOpenDialog(options); - - if (result.canceled) return null; - if (result.filePaths.length !== 1) return null; - - return result.filePaths[0]; - } - #updateWindowTitle(): void { - const window = this.#activeWindow(); - if (!window) return; - const state = this.#state; + const windows = this.#windows(); + if (windows.length === 0) return; + if (!state) { - window.setTitle(this.#applicationName()); + for (const window of windows) window.setTitle(this.#applicationName()); return; } const name = state.saveTarget ? path.basename(state.saveTarget) : "Untitled"; const dirty = state.dirty ? " *" : ""; - window.setTitle(`${name}${dirty} - ${this.#applicationName()}`); + for (const window of windows) { + window.setTitle(`${name}${dirty} - ${this.#applicationName()}`); + } } } diff --git a/apps/desktop/src/main/document/openFontDialog.ts b/apps/desktop/src/main/document/openFontDialog.ts new file mode 100644 index 00000000..35a50ea7 --- /dev/null +++ b/apps/desktop/src/main/document/openFontDialog.ts @@ -0,0 +1,41 @@ +import { dialog, type OpenDialogOptions } from "electron"; +import type { Window } from "../windows/Window"; + +const OPEN_FONT_EXTENSIONS = [ + "shift", + "ttf", + "otf", + "glyphs", + "glyphspackage", + "ufo", + "designspace", +]; + +/** + * Shows the native Open dialog for font sources. + * + * @param window - Native window that owns the dialog, or null for an app-modal dialog. + * @returns null when the user cancels or no single path is selected. + */ +export async function showOpenFontDialog(window: Window | null): Promise { + const options: OpenDialogOptions = { + title: "Open Font", + filters: [ + { name: "Supported Fonts", extensions: OPEN_FONT_EXTENSIONS }, + { name: "Shift Source Package", extensions: ["shift"] }, + { name: "TrueType/OpenType", extensions: ["ttf", "otf"] }, + { name: "Glyphs", extensions: ["glyphs", "glyphspackage"] }, + { name: "UFO/Designspace", extensions: ["ufo", "designspace"] }, + ], + properties: process.platform === "darwin" ? ["openFile", "openDirectory"] : ["openFile"], + }; + + const result = window + ? await dialog.showOpenDialog(window.window, options) + : await dialog.showOpenDialog(options); + + if (result.canceled) return null; + if (result.filePaths.length !== 1) return null; + + return result.filePaths[0]; +} diff --git a/apps/desktop/src/main/windows/WindowManager.ts b/apps/desktop/src/main/windows/WindowManager.ts new file mode 100644 index 00000000..4345732f --- /dev/null +++ b/apps/desktop/src/main/windows/WindowManager.ts @@ -0,0 +1,35 @@ +import { BrowserWindow } from "electron"; +import type { Window } from "./Window"; + +/** + * Tracks native windows independently from the workspaces they display. + * + * @remarks + * Windows are renderer containers, not document authority. Workspace ownership + * lives in `WorkspaceManager`; this manager should only answer window lifecycle + * and lookup questions. + */ +export class WindowManager { + readonly #windows = new Map(); + + add(window: Window): void { + this.#windows.set(window.window.id, window); + } + + remove(window: Window): void { + this.#windows.delete(window.window.id); + } + + activeWindow(): Window | null { + const focused = BrowserWindow.getFocusedWindow(); + return focused ? this.windowForBrowserWindow(focused) : null; + } + + windowForBrowserWindow(window: BrowserWindow): Window | null { + return this.#windows.get(window.id) ?? null; + } + + allWindows(): readonly Window[] { + return [...this.#windows.values()]; + } +} diff --git a/apps/desktop/src/main/workspace/WorkspaceManager.ts b/apps/desktop/src/main/workspace/WorkspaceManager.ts new file mode 100644 index 00000000..62f76ec2 --- /dev/null +++ b/apps/desktop/src/main/workspace/WorkspaceManager.ts @@ -0,0 +1,124 @@ +import { BrowserWindow, type WebContents } from "electron"; +import type { Window } from "../windows/Window"; +import { type WorkspaceId, WorkspaceSession } from "./WorkspaceSession"; + +/** + * Tracks live font workspace sessions by workspace identity. + * + * @remarks + * Commands and IPC handlers resolve a workspace from the focused or sending + * window before acting on document state. + */ +export class WorkspaceManager { + readonly #sessionsById = new Map(); + readonly #sessionIdByWindowId = new Map(); + + /** + * Returns the live workspace session for an id. + * + * @param workspaceId - Stable identity minted for a loaded workspace session. + * @returns null when no live session is registered for the id. + */ + get(workspaceId: WorkspaceId): WorkspaceSession | null { + return this.#sessionsById.get(workspaceId) ?? null; + } + + /** + * Registers one live workspace session. + * + * @param session - Workspace session that is not already registered. + * @throws {Error} when another session already uses the same workspace id. + */ + register(session: WorkspaceSession): void { + if (this.#sessionsById.has(session.workspaceId)) { + throw new Error(`Workspace session already registered: ${session.workspaceId}`); + } + + this.#sessionsById.set(session.workspaceId, session); + } + + /** + * Removes a workspace session and all of its window associations. + * + * @param workspaceId - Stable identity for the session to remove. + */ + unregister(workspaceId: WorkspaceId): void { + const session = this.#sessionsById.get(workspaceId); + if (!session) return; + + for (const window of session.windows) { + this.#sessionIdByWindowId.delete(window.window.id); + } + this.#sessionsById.delete(workspaceId); + session.dispose(); + } + + /** + * Attaches a native window to a registered workspace session. + * + * @param workspaceId - Session that should own the window. + * @param window - Native window wrapper to associate with the session. + * @throws {Error} when the session is missing or the window belongs to another session. + */ + attachWindow(workspaceId: WorkspaceId, window: Window): void { + const session = this.#requireWorkspace(workspaceId); + const currentWorkspaceId = this.#sessionIdByWindowId.get(window.window.id); + + if (currentWorkspaceId && currentWorkspaceId !== workspaceId) { + throw new Error(`Window is already attached to workspace: ${currentWorkspaceId}`); + } + + session.attachWindow(window); + this.#sessionIdByWindowId.set(window.window.id, workspaceId); + } + + /** + * Detaches a native window from whichever workspace owns it. + * + * @param window - Native window wrapper to remove from the session registry. + */ + detachWindow(window: Window): void { + const workspaceId = this.#sessionIdByWindowId.get(window.window.id); + if (!workspaceId) return; + + this.#sessionsById.get(workspaceId)?.detachWindow(window); + this.#sessionIdByWindowId.delete(window.window.id); + } + + /** + * Resolves the workspace session attached to a native browser window. + * + * @param window - BrowserWindow that may be attached to a workspace session. + * @returns null when the window is unbound or unknown. + */ + getForBrowserWindow(window: BrowserWindow): WorkspaceSession | null { + const workspaceId = this.#sessionIdByWindowId.get(window.id); + return workspaceId ? this.get(workspaceId) : null; + } + + /** + * Resolves the workspace session attached to a renderer webContents. + * + * @param webContents - Renderer sender from an Electron IPC event. + * @returns null when the sender does not belong to a bound workspace window. + */ + getForWebContents(webContents: WebContents): WorkspaceSession | null { + const window = BrowserWindow.fromWebContents(webContents); + return window ? this.getForBrowserWindow(window) : null; + } + + /** + * Returns the live workspace sessions. + * + * @returns a fresh array; mutating it does not change the registry. + */ + list(): readonly WorkspaceSession[] { + return [...this.#sessionsById.values()]; + } + + #requireWorkspace(workspaceId: WorkspaceId): WorkspaceSession { + const session = this.#sessionsById.get(workspaceId); + if (!session) throw new Error(`Workspace session is not registered: ${workspaceId}`); + return session; + } +} diff --git a/apps/desktop/src/main/workspace/WorkspaceProcess.ts b/apps/desktop/src/main/workspace/WorkspaceProcess.ts index ae602cfa..fb5244a9 100644 --- a/apps/desktop/src/main/workspace/WorkspaceProcess.ts +++ b/apps/desktop/src/main/workspace/WorkspaceProcess.ts @@ -55,12 +55,15 @@ export class WorkspaceProcess { /** Stops the utility process; in-flight shell-lane calls reject. */ stop(): void { + const proc = this.#process; if (this.#unlistenDocumentChanged) this.#unlistenDocumentChanged(); if (this.#channel) this.#channel.dispose(); this.#process = null; this.#channel = null; this.#ready = null; this.#unlistenDocumentChanged = null; + this.#documentListeners.clear(); + proc?.kill(); } /** @@ -82,6 +85,27 @@ export class WorkspaceProcess { return this.#channel.call("workspace.connect", undefined, [port]); } + /** + * Creates an untitled workspace through the shell lane. + * + * @returns utility-owned document state for the created workspace. + * @throws {Error} when the utility process is not running or rejects the call. + */ + createWorkspace(): Promise { + return this.#requireChannel().call("workspace.create", undefined); + } + + /** + * Opens a workspace from a source path through the shell lane. + * + * @param path - User-selected source path to open. + * @returns utility-owned document state for the opened workspace. + * @throws {Error} when the utility process is not running or rejects the call. + */ + openWorkspace(path: string): Promise { + return this.#requireChannel().call("workspace.open", { path }); + } + /** * Subscribes to document lifecycle snapshots emitted by the utility process. * diff --git a/apps/desktop/src/main/workspace/WorkspaceSession.ts b/apps/desktop/src/main/workspace/WorkspaceSession.ts new file mode 100644 index 00000000..ff5e68ed --- /dev/null +++ b/apps/desktop/src/main/workspace/WorkspaceSession.ts @@ -0,0 +1,117 @@ +import { BrowserWindow } from "electron"; +import { DocumentClient } from "../document/DocumentClient"; +import { DocumentSession } from "../document/DocumentSession"; +import type { Window } from "../windows/Window"; +import { WorkspaceProcess } from "./WorkspaceProcess"; + +/** + * Identifies one live loaded font workspace. + * + * @remarks + * Renderer windows attach to this identity and keep it for their lifetime. + */ +export type WorkspaceId = string; + +export interface WorkspaceSessionOptions { + readonly workspaceId: WorkspaceId; + readonly workspaceProcess: WorkspaceProcess; + readonly documentClient: DocumentClient; + readonly applicationName: () => string; +} + +/** + * Groups the main-process services and windows for one font workspace. + * + * @remarks + * The workspace owns persistence, dirty state, undo state, and sync-lane + * access. Windows are views attached to this session. + */ +export class WorkspaceSession { + /** Stable identity for this live workspace session. */ + readonly workspaceId: WorkspaceId; + + /** Utility process that owns this workspace's font data and sync lanes. */ + readonly workspaceProcess: WorkspaceProcess; + + /** Renderer-mediated document lane for save/state requests that must flush edits. */ + readonly documentClient: DocumentClient; + + /** Main-owned document workflow for this workspace. */ + readonly document: DocumentSession; + + /** Renderer windows currently attached to this workspace session. */ + readonly windows = new Set(); + + readonly #unlistenDocumentChanged: () => void; + + /** + * Creates a session around the services for one loaded font workspace. + * + * @param options - Services and identity that remain stable for this session lifetime. + */ + constructor(options: WorkspaceSessionOptions) { + this.workspaceId = options.workspaceId; + this.workspaceProcess = options.workspaceProcess; + this.documentClient = options.documentClient; + this.document = new DocumentSession({ + document: this.documentClient, + dialogWindow: () => this.activeWindow(), + windows: () => this.allWindows(), + applicationName: options.applicationName, + }); + this.#unlistenDocumentChanged = this.workspaceProcess.onDocumentChanged((state) => { + this.document.acceptState(state); + }); + } + + /** + * Attaches a renderer window to this workspace session. + * + * @param window - Native window wrapper that should display this workspace. + */ + attachWindow(window: Window): void { + this.windows.add(window); + } + + /** + * Detaches a renderer window from this workspace session. + * + * @param window - Native window wrapper that no longer displays this workspace. + */ + detachWindow(window: Window): void { + this.windows.delete(window); + } + + /** + * Returns a window suitable for workspace-owned dialogs. + * + * @returns the focused session window, a remaining attached window, or null. + */ + activeWindow(): Window | null { + const focused = BrowserWindow.getFocusedWindow(); + if (focused) { + for (const window of this.windows) { + if (window.window.id === focused.id) return window; + } + } + + return this.windows.values().next().value ?? null; + } + + /** + * Returns the renderer windows attached to this session. + * + * @returns a fresh array; mutating it does not change the session. + */ + allWindows(): readonly Window[] { + return [...this.windows]; + } + + /** Disposes process and renderer-facing document resources for this workspace. */ + dispose(): void { + this.#unlistenDocumentChanged(); + this.documentClient.dispose(); + this.workspaceProcess.stop(); + this.windows.clear(); + } +} diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index cfbb838b..a90dd94b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -14,6 +14,8 @@ const shiftHost: ShiftHost = { connect: invoke(ipcRenderer, "document.connect"), }, workspace: { + create: invoke(ipcRenderer, "workspace.create"), + open: invoke(ipcRenderer, "workspace.open"), connect: invoke(ipcRenderer, "workspace.connect"), }, ui: { diff --git a/apps/desktop/src/renderer/src/app/Screens.tsx b/apps/desktop/src/renderer/src/app/Screens.tsx index d694cc07..890c2bd9 100644 --- a/apps/desktop/src/renderer/src/app/Screens.tsx +++ b/apps/desktop/src/renderer/src/app/Screens.tsx @@ -1,27 +1,46 @@ -import { useEffect } from "react"; -import { Navigate, Route, Routes } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { Navigate, Outlet, Route, Routes } from "react-router-dom"; import { Landing } from "@/views/Landing"; import { Home } from "@/views/Home"; import { Editor } from "@/views/Editor"; -import { getEditor, getFont } from "@/store/appStore"; +import { connectWorkspaceRuntime, getEditor, getFont } from "@/store/appStore"; import { getShiftHost } from "@/host/shiftHost"; import { useSignalState } from "@/lib/signals/useSignal"; /** - * The entire top-level screen structure, in one place. + * Routes launcher and workspace windows to their screen trees. * * @remarks - * Whether a document is loaded — not the URL — decides the launcher vs. the - * workspace, so it reads top-to-bottom as a guard. URL routing only addresses - * what's *inside* the workspace (which glyph). Loading a document is what flips - * the screen, regardless of trigger (New, Open, future recovery); views never - * navigate to make it happen. + * Main chooses the initial route when it creates a window. Launcher routes do + * not connect to a workspace; workspace routes connect through the sender + * window and fail if main has not attached that window to a session. */ export const Screens = () => { + return ( + + } /> + }> + } /> + } /> + + } /> + + ); +}; + +const WorkspaceScreens = () => { const font = getFont(); const editor = getEditor(); const documentLoaded = useSignalState(font.$loaded); + const [connectionError, setConnectionError] = useState(null); + + useEffect(() => { + void connectWorkspaceRuntime().catch((error) => { + console.error("workspace runtime failed", error); + setConnectionError(error); + }); + }, []); // Side effect of a document loading: give the editor room. useEffect(() => { @@ -34,13 +53,15 @@ export const Screens = () => { .catch((error) => console.error("maximise on document load failed", error)); }, [documentLoaded, editor, font]); - if (!documentLoaded) return ; + if (connectionError) { + return ( +
+ Workspace failed to load. +
+ ); + } - return ( - - } /> - } /> - } /> - - ); + if (!documentLoaded) return null; + + return ; }; diff --git a/apps/desktop/src/renderer/src/lib/model/Font.test.ts b/apps/desktop/src/renderer/src/lib/model/Font.test.ts index e62a6489..c8f86ae1 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.test.ts @@ -106,7 +106,7 @@ describe("Font projects the workspace snapshot", () => { describe("font-level intents make the font variable", () => { it("createAxis and createSource project axes and sources without creating glyph layers", async () => { const stack = createWorkspaceStack(); - await stack.client.create(); + await stack.createWorkspace(); const glyphId = mintGlyphId(); await stack.client.apply([ { @@ -154,7 +154,7 @@ describe("font-level intents make the font variable", () => { it("createGlyphLayer projects sparse glyph-layer membership", async () => { const stack = createWorkspaceStack(); - await stack.client.create(); + await stack.createWorkspace(); const glyphId = mintGlyphId(); await stack.client.apply([ { @@ -178,7 +178,7 @@ describe("font-level intents make the font variable", () => { it("createGlyph authors a default layer for fresh glyphs", async () => { const stack = createWorkspaceStack(); - await stack.client.create(); + await stack.createWorkspace(); const record = stack.font.createGlyph("A" as GlyphName); const source = stack.font.defaultSource; @@ -198,7 +198,7 @@ describe("font-level intents make the font variable", () => { it("exact sources without glyph layers have no live layer and do not render default geometry", async () => { const stack = createWorkspaceStack(); - await stack.client.create(); + await stack.createWorkspace(); const glyphId = mintGlyphId(); const defaultLayerId = mintLayerId(); await stack.client.apply([ diff --git a/apps/desktop/src/renderer/src/lib/model/variation.test.ts b/apps/desktop/src/renderer/src/lib/model/variation.test.ts index 2196f338..9796540e 100644 --- a/apps/desktop/src/renderer/src/lib/model/variation.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/variation.test.ts @@ -59,7 +59,7 @@ async function variableFont(): Promise<{ bold: Source; }> { const stack = createWorkspaceStack(); - await stack.client.create(); + await stack.createWorkspace(); const glyphId = mintGlyphId(); const regularLayerId = mintLayerId(); diff --git a/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts index 0cfc233b..90c0f321 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts @@ -22,7 +22,7 @@ const GLYPHS: ReadonlyArray = [ export async function layoutTestFont(): Promise { const stack = createWorkspaceStack(); - await stack.client.create(); + await stack.createWorkspace(); for (const [name, unicode, advance] of GLYPHS) { const glyphId = mintGlyphId(); diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.test.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.test.ts index c9109d0b..0491f750 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.test.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.test.ts @@ -17,7 +17,7 @@ describe("WorkspaceEditQueue issues save on the committed-op lane", () => { beforeEach(async () => { stack = createWorkspaceStack(); - await stack.client.create(); + await stack.createWorkspace(); }); it("flushes queued edits before the save so the write includes them", async () => { diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.ts index 85c9895a..d8f28ed8 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.ts @@ -115,18 +115,6 @@ export class WorkspaceEditQueue { }); } - /** Creates an untitled workspace behind every queued and in-flight edit. */ - create(): Promise { - return this.#withFlush(() => this.#workspace.create()); - } - - /** Opens a workspace behind every queued and in-flight edit. */ - open(path: string): Promise { - return this.#withFlush(async () => { - await this.#workspace.open(path); - }); - } - /** Reads document state behind every queued and in-flight edit. */ state(): Promise { return this.#withFlush(() => this.#workspace.documentState()); diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceSession.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceSession.ts index 0389e25d..41fdbc9e 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceSession.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceSession.ts @@ -41,7 +41,7 @@ export class WorkspaceSession { } /** - * Memoized connection to the workspace process; safe to call repeatedly. + * Connects this renderer runtime to its bound workspace. * * @remarks * A failed attempt clears the memo so the next call retries instead of @@ -59,13 +59,6 @@ export class WorkspaceSession { return this.#connected; } - /** Creates an untitled workspace; `workspaceCell` becomes the returned state. */ - async create(): Promise { - await this.connected(); - - this.workspaceCell.set(await this.#require().call("workspace.create", undefined)); - } - /** * Applies an intent set; the response is pure replace-grade state. * @@ -100,14 +93,6 @@ export class WorkspaceSession { return applied === null ? null : this.#fold(applied); } - async open(path: string): Promise { - await this.connected(); - - const snapshot = await this.#require().call("workspace.open", { path }); - this.workspaceCell.set(snapshot); - return snapshot; - } - /** Reads utility-owned document state through the renderer sync lane. */ async documentState(): Promise { await this.connected(); diff --git a/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts b/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts index e2a47dd1..ba3b5530 100644 --- a/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts +++ b/apps/desktop/src/renderer/src/perf/workspaceApply.bench.ts @@ -10,7 +10,7 @@ import { createWorkspaceStack } from "@/testing/workspaceStack"; * is one frame; WorkspaceHost.test.ts pins p99 < 50ms as the hard guard. */ const stack = createWorkspaceStack(); -await stack.client.create(); +await stack.createWorkspace(); const glyphId = mintGlyphId(); const layerId = mintLayerId(); diff --git a/apps/desktop/src/renderer/src/store/appStore.ts b/apps/desktop/src/renderer/src/store/appStore.ts index 2e2bb2ea..3f2a92ae 100644 --- a/apps/desktop/src/renderer/src/store/appStore.ts +++ b/apps/desktop/src/renderer/src/store/appStore.ts @@ -24,14 +24,34 @@ registerBuiltInTools(editor); // Set select tool as ready on startup editor.setActiveTool("select"); -void workspace.connected(); - const host = getShiftHost(); let documentRequests: ChannelServer | null = null; +let workspaceRuntimeLoad: Promise | null = null; + +export function connectWorkspaceRuntime(): Promise { + if (!workspaceRuntimeLoad) { + const attempt = connectWorkspaceRuntimeOnce().catch((error) => { + if (workspaceRuntimeLoad === attempt) { + workspaceRuntimeLoad = null; + } + throw error; + }); + workspaceRuntimeLoad = attempt; + } + + return workspaceRuntimeLoad; +} -void serveDocumentRequests().catch((error) => { - console.error("document request lane failed", error); -}); +async function connectWorkspaceRuntimeOnce(): Promise { + await workspace.connected(); + + const snapshot = workspace.workspaceCell.peek(); + if (!snapshot) { + throw new Error("workspace connected without a snapshot"); + } + + await serveDocumentRequests(); +} async function serveDocumentRequests(): Promise { const port = nextDocumentPort(); @@ -48,9 +68,7 @@ async function serveDocumentRequests(): Promise { domPortTransport(await port.received), { "document.state": () => editQueue.state(), - "document.create": () => editQueue.create(), "document.save": ({ path }) => editQueue.save(path), - "document.open": ({ path }) => editQueue.open(path), }, ); } diff --git a/apps/desktop/src/renderer/src/testing/TestEditor.ts b/apps/desktop/src/renderer/src/testing/TestEditor.ts index 4e93c61e..7b51f0ae 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.ts @@ -54,7 +54,7 @@ export class TestEditor extends Editor { * the production pipe end to end (intents → NAPI → SQLite → echo → fold). */ async startSession(name = "A", unicode: number | null = 65): Promise { - await this.#stack.client.create(); + await this.#stack.createWorkspace(); const glyph = await this.#createAndOpenGlyph(name, unicode); const record = this.font.recordForName(glyph.handle.name); diff --git a/apps/desktop/src/renderer/src/testing/workspaceStack.ts b/apps/desktop/src/renderer/src/testing/workspaceStack.ts index a5c85bad..a9c3de05 100644 --- a/apps/desktop/src/renderer/src/testing/workspaceStack.ts +++ b/apps/desktop/src/renderer/src/testing/workspaceStack.ts @@ -13,6 +13,7 @@ export type WorkspaceStack = { client: WorkspaceSession; editQueue: WorkspaceEditQueue; font: Font; + createWorkspace(): Promise; }; /** @@ -42,5 +43,17 @@ export function createWorkspaceStack(): WorkspaceStack { const editQueue = new WorkspaceEditQueue(client); const font = new Font(client.workspaceCell, editQueue); - return { client, editQueue, font }; + return { + client, + editQueue, + font, + async createWorkspace(): Promise { + await shell.call("workspace.create", undefined); + await client.connected(); + + if (!client.workspaceCell.peek()) { + throw new Error("workspace stack connected without a snapshot"); + } + }, + }; } diff --git a/apps/desktop/src/renderer/src/views/Landing.tsx b/apps/desktop/src/renderer/src/views/Landing.tsx index 55504bb5..0c74a69c 100644 --- a/apps/desktop/src/renderer/src/views/Landing.tsx +++ b/apps/desktop/src/renderer/src/views/Landing.tsx @@ -8,15 +8,13 @@ export const Landing = () => { const host = getShiftHost(); const handleNewFont = () => { - void host.commands - .run("file.new") + void host.workspace + .create() .catch((error) => console.error("creating a new font failed", error)); }; const handleOpenFont = () => { - void host.commands - .run("file.open") - .catch((error) => console.error("opening a font failed", error)); + void host.workspace.open().catch((error) => console.error("opening a font failed", error)); }; return ( diff --git a/apps/desktop/src/shared/host/ShiftHost.ts b/apps/desktop/src/shared/host/ShiftHost.ts index a43c35e3..71c639a2 100644 --- a/apps/desktop/src/shared/host/ShiftHost.ts +++ b/apps/desktop/src/shared/host/ShiftHost.ts @@ -22,22 +22,32 @@ export interface ShiftHost { /** Connects the renderer to main-owned document requests. */ document: { /** - * Asks main to transfer a document request lane. + * Asks main to transfer a document request lane for the sender's workspace. * * @remarks * The renderer half arrives via the `document.port` postMessage relay; - * install that listener before calling. + * install that listener before calling. Main rejects the request when the + * sender window is not bound to a workspace. */ connect: () => Promise; }; /** Connects the renderer to the workspace utility process. */ workspace: { + /** + * Requests a new untitled workspace in a new bound renderer window. + */ + create: () => Promise; + /** + * Requests that main show an open dialog and open the result in a new bound window. + */ + open: () => Promise; /** * Asks main to transfer a fresh sync-lane port to the workspace process. * * @remarks * The lane's renderer half arrives via the `workspace.port` postMessage - * relay; install that listener before calling. + * relay; install that listener before calling. Main resolves the workspace + * from the sender window. */ connect: () => Promise; }; diff --git a/apps/desktop/src/shared/ipc/contract.ts b/apps/desktop/src/shared/ipc/contract.ts index 52260de8..aa7726c9 100644 --- a/apps/desktop/src/shared/ipc/contract.ts +++ b/apps/desktop/src/shared/ipc/contract.ts @@ -3,9 +3,7 @@ import type { WorkspaceDocumentState } from "../workspace/protocol"; export type DocumentCallMap = { "document.state": { request: void; response: WorkspaceDocumentState | null }; - "document.create": { request: void; response: void }; "document.save": { request: { path: string | null }; response: WorkspaceDocumentState }; - "document.open": { request: { path: string }; response: void }; }; export type DocumentEventMap = Record; @@ -19,6 +17,10 @@ export type DocumentEventMap = Record; */ export type RendererToMain = { "commands.run": (id: CommandId) => void; + /** Creates an untitled workspace in a new bound window. */ + "workspace.create": () => void; + /** Opens a workspace through main's native open dialog in a new bound window. */ + "workspace.open": () => void; /** * Asks main to transfer a document request lane to the renderer. The port * arrives separately on the `document.port` postMessage channel because ports diff --git a/apps/desktop/src/shared/workspace/protocol.ts b/apps/desktop/src/shared/workspace/protocol.ts index 7db7c4d2..30a94e48 100644 --- a/apps/desktop/src/shared/workspace/protocol.ts +++ b/apps/desktop/src/shared/workspace/protocol.ts @@ -46,12 +46,15 @@ export type WorkspaceDocumentState = { * * @remarks * `workspace.connect` carries the renderer's sync-lane port as a transferred - * port, not as payload. Save is NOT here: it rides the sync lane as a committed - * operation so FIFO orders it behind edits (see `workspace.save`). Main reads - * `document.state` to decide Save vs Save As and learns save outcomes from the - * `document.changed` event. + * port, not as payload. Create/open return document lifecycle state only; font + * records stay on the sync lane. Save is NOT here: it rides the sync lane as a + * committed operation so FIFO orders it behind edits (see `workspace.save`). + * Main reads `document.state` to decide Save vs Save As and learns save + * outcomes from the `document.changed` event. */ export type ShellCallMap = { + "workspace.create": { request: void; response: WorkspaceDocumentState }; + "workspace.open": { request: { path: string }; response: WorkspaceDocumentState }; "workspace.connect": { request: void; response: void }; "document.state": { request: void; response: WorkspaceDocumentState | null }; }; @@ -66,12 +69,10 @@ export type ShellEventMap = { * * @remarks * Convention: **every sync-lane response is the renderer's next state** — - * states, not acks. `workspace.create` takes void because the utility mints - * the documentId and allocates the store path; the renderer never sees a - * filesystem path. + * states, not acks. Create/open are main-owned shell-lane operations; the + * renderer catches up by reading `workspace.snapshot`. */ export type SyncCallMap = { - "workspace.create": { request: void; response: WorkspaceSnapshot }; "workspace.snapshot": { request: void; response: WorkspaceSnapshot | null }; "document.state": { request: void; response: WorkspaceDocumentState | null }; /** @@ -97,7 +98,6 @@ export type SyncCallMap = { * needs a path. Rides the edit lane so the utility serializes it behind every * committed edit — no cross-lane watermark required. */ - "workspace.open": { request: { path: string }; response: WorkspaceSnapshot }; "workspace.save": { request: void; response: WorkspaceDocumentState }; /** Saves to `path` (main's Save As dialog choice) and adopts it as target. */ "workspace.saveAs": { request: { path: string }; response: WorkspaceDocumentState }; diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts index d8437eb7..63725247 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.test.ts @@ -23,6 +23,7 @@ import type { SyncCallMap, SyncEventMap, WorkspaceDocumentState, + WorkspaceSnapshot, } from "../../shared/workspace/protocol"; import { WorkspaceHost } from "./WorkspaceHost"; @@ -102,6 +103,29 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { return (await sync.call("workspace.redo", undefined)).applied; } + async function createWorkspace( + sync: SyncChannel, + targetShell: ShellChannel = shell, + ): Promise { + const state = await targetShell.call("workspace.create", undefined); + const snapshot = await sync.call("workspace.snapshot", undefined); + if (!snapshot) throw new Error("workspace.create did not create a snapshot"); + expect(snapshot.documentId).toBe(state.documentId); + return snapshot; + } + + async function openWorkspace( + sync: SyncChannel, + targetShell: ShellChannel, + sourcePath: string, + ): Promise { + const state = await targetShell.call("workspace.open", { path: sourcePath }); + const snapshot = await sync.call("workspace.snapshot", undefined); + if (!snapshot) throw new Error("workspace.open did not create a snapshot"); + expect(snapshot.documentId).toBe(state.documentId); + return snapshot; + } + beforeEach(() => { tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "shift-workspace-host-")); const lane = new MessageChannel(); @@ -131,7 +155,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { // An authored draft must never die with the process: the data-loss // class the durability ADRs were written against. const sync = await connectSyncLane(); - const { documentId } = await sync.call("workspace.create", undefined); + const { documentId } = await createWorkspace(sync); const storePath = path.join(tmpRoot, "drafts", documentId, "document.sqlite"); expect(fs.existsSync(storePath)).toBe(true); @@ -157,10 +181,27 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { await expect(shell.call("document.state", undefined)).resolves.toBeNull(); }); + it("creates an untitled workspace from the shell lane", async () => { + const state = await shell.call("workspace.create", undefined); + + expect(state).toMatchObject({ + sourceKind: "untitled", + saveTarget: null, + dirty: false, + needsSaveAs: true, + }); + + const sync = await connectSyncLane(); + await expect(sync.call("workspace.snapshot", undefined)).resolves.toMatchObject({ + documentId: state.documentId, + glyphs: [], + }); + }); + it("creates an untitled workspace and returns it as the next state", async () => { const sync = await connectSyncLane(); - const snapshot = await sync.call("workspace.create", undefined); + const snapshot = await createWorkspace(sync); expect(snapshot.documentId).toMatch(/^[0-9a-f]{8}-[0-9a-f-]{27}$/); expect(snapshot.glyphs).toEqual([]); @@ -172,7 +213,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("writes the sqlite store under the documents root", async () => { const sync = await connectSyncLane(); - const { documentId } = await sync.call("workspace.create", undefined); + const { documentId } = await createWorkspace(sync); const storePath = path.join(tmpRoot, "drafts", documentId, "document.sqlite"); expect(fs.existsSync(storePath)).toBe(true); @@ -180,14 +221,14 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("workspace.snapshot returns the created workspace", async () => { const sync = await connectSyncLane(); - const created = await sync.call("workspace.create", undefined); + const created = await createWorkspace(sync); await expect(sync.call("workspace.snapshot", undefined)).resolves.toEqual(created); }); it("opens a package before any workspace exists", async () => { const source = await connectSyncLane(); - await source.call("workspace.create", undefined); + await createWorkspace(source); await source.call("workspace.apply", { intents: [createGlyphA()], label: "Add Glyph" }); const savePath = path.join(tmpRoot, "OpenMe.shift"); await source.call("workspace.saveAs", { path: savePath }); @@ -198,7 +239,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { startHost(nodePortTransport(lane.port2)); const unopened = await connectSyncLane(unopenedShell); - const opened = await unopened.call("workspace.open", { path: savePath }); + const opened = await openWorkspace(unopened, unopenedShell, savePath); expect(opened.documentId).toMatch(/^[0-9a-f]{8}-[0-9a-f-]{27}$/); expect(opened.sources.length).toBeGreaterThan(0); @@ -211,9 +252,37 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { }); }); + it("opens a package from the shell lane", async () => { + const source = await connectSyncLane(); + await createWorkspace(source); + await source.call("workspace.apply", { intents: [createGlyphA()], label: "Add Glyph" }); + const savePath = path.join(tmpRoot, "ShellOpenMe.shift"); + await source.call("workspace.saveAs", { path: savePath }); + + const lane = new MessageChannel(); + const unopenedShell: ShellChannel = new Channel(nodePortTransport(lane.port1)); + channels.push(unopenedShell); + startHost(nodePortTransport(lane.port2)); + + const state = await unopenedShell.call("workspace.open", { path: savePath }); + + expect(state).toMatchObject({ + sourceKind: "package", + saveTarget: savePath, + dirty: false, + needsSaveAs: false, + }); + + const unopened = await connectSyncLane(unopenedShell); + await expect(unopened.call("workspace.snapshot", undefined)).resolves.toMatchObject({ + documentId: state.documentId, + glyphs: [expect.objectContaining({ name: "A" })], + }); + }); + it("opening a package resumes a retained dirty working store", async () => { const source = await connectSyncLane(); - const created = await source.call("workspace.create", undefined); + const created = await createWorkspace(source); await source.call("workspace.apply", { intents: [createGlyphA()], label: "Add Glyph" }); const savePath = path.join(tmpRoot, "RecoverMe.shift"); await source.call("workspace.saveAs", { path: savePath }); @@ -237,7 +306,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { startHost(nodePortTransport(lane.port2)); const restarted = await connectSyncLane(restartedShell); - const opened = await restarted.call("workspace.open", { path: savePath }); + const opened = await openWorkspace(restarted, restartedShell, savePath); expect(opened.documentId).toBe(created.documentId); expect(opened.glyphs.map((glyph) => glyph.name)).toEqual(["A", "B"]); @@ -257,7 +326,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { }); const sync = await connectSyncLane(); - const created = await sync.call("workspace.create", undefined); + const created = await createWorkspace(sync); await shell.call("document.state", undefined); expect(latestState).toMatchObject({ documentId: created.documentId, @@ -286,7 +355,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("a reconnected sync lane still serves the open workspace", async () => { const first = await connectSyncLane(); - const created = await first.call("workspace.create", undefined); + const created = await createWorkspace(first); const second = await connectSyncLane(); @@ -295,7 +364,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("apply createGlyph echoes identity records without layers", async () => { const sync = await connectSyncLane(); - await sync.call("workspace.create", undefined); + await createWorkspace(sync); const applied = await applyWorkspace(sync, { intents: [createGlyphA()], @@ -313,7 +382,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("apply createGlyphLayer echoes sparse membership and a structural layer", async () => { const sync = await connectSyncLane(); - const snapshot = await sync.call("workspace.create", undefined); + const snapshot = await createWorkspace(sync); const { layerId, intents } = createGlyphALayer(snapshot.sources[0].id); const applied = await applyWorkspace(sync, { @@ -331,7 +400,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("workspace.save reports NeedsSaveAs for untitled workspaces", async () => { const sync = await connectSyncLane(); - await sync.call("workspace.create", undefined); + await createWorkspace(sync); await applyWorkspace(sync, { intents: [createGlyphA()], label: "Add Glyph", @@ -349,7 +418,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("workspace.saveAs writes the package and clears dirty for later saves", async () => { const sync = await connectSyncLane(); - await sync.call("workspace.create", undefined); + await createWorkspace(sync); await applyWorkspace(sync, { intents: [createGlyphA()], label: "Add Glyph", @@ -391,7 +460,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("serializes a save behind an un-awaited apply on the same lane", async () => { const sync = await connectSyncLane(); - await sync.call("workspace.create", undefined); + await createWorkspace(sync); const savePath = path.join(tmpRoot, "Ordered.shift"); // Issue the apply and the save back-to-back without awaiting the apply. The @@ -409,7 +478,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("undo and redo createGlyph update glyph records", async () => { const sync = await connectSyncLane(); - await sync.call("workspace.create", undefined); + await createWorkspace(sync); const created = await applyWorkspace(sync, { intents: [createGlyphA()], @@ -436,7 +505,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("apply setXAdvance echoes values without structure or records", async () => { const sync = await connectSyncLane(); - const snapshot = await sync.call("workspace.create", undefined); + const snapshot = await createWorkspace(sync); const { layerId, intents } = createGlyphALayer(snapshot.sources[0].id); const created = await applyWorkspace(sync, { intents, @@ -455,7 +524,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("apply rejects unknown intent kinds with a channel error", async () => { const sync = await connectSyncLane(); - await sync.call("workspace.create", undefined); + await createWorkspace(sync); await expect(applyWorkspace(sync, { intents: [{ kind: "explodeFont" }] })).rejects.toThrow( "explodeFont", @@ -464,7 +533,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("pen intents apply atomically with client-minted ids through the channel", async () => { const sync = await connectSyncLane(); - const snapshot = await sync.call("workspace.create", undefined); + const snapshot = await createWorkspace(sync); const { layerId, intents } = createGlyphALayer(snapshot.sources[0].id); const created = await applyWorkspace(sync, { intents, @@ -505,7 +574,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("undo and redo replay ledger entries through the channel", async () => { const sync = await connectSyncLane(); - const snapshot = await sync.call("workspace.create", undefined); + const snapshot = await createWorkspace(sync); const { layerId, intents } = createGlyphALayer(snapshot.sources[0].id); const created = await applyWorkspace(sync, { intents, @@ -538,7 +607,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("undo on an empty ledger answers null", async () => { const sync = await connectSyncLane(); - await sync.call("workspace.create", undefined); + await createWorkspace(sync); await expect(undoWorkspace(sync)).resolves.toBeNull(); await expect(redoWorkspace(sync)).resolves.toBeNull(); @@ -546,7 +615,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("workspace.layer pulls replace-grade state by stable layer id", async () => { const sync = await connectSyncLane(); - const snapshot = await sync.call("workspace.create", undefined); + const snapshot = await createWorkspace(sync); const { layerId, intents } = createGlyphALayer(snapshot.sources[0].id); const created = await applyWorkspace(sync, { intents, @@ -565,7 +634,7 @@ describe("WorkspaceHost serves the workspace over transferred ports", () => { it("CS0 skeleton: measures the apply round trip through the full stack", async () => { const sync = await connectSyncLane(); - const snapshot = await sync.call("workspace.create", undefined); + const snapshot = await createWorkspace(sync); const { layerId, intents } = createGlyphALayer(snapshot.sources[0].id); const created = await applyWorkspace(sync, { intents, diff --git a/apps/desktop/src/utility/workspace/WorkspaceHost.ts b/apps/desktop/src/utility/workspace/WorkspaceHost.ts index 9699dcb2..236d53a7 100644 --- a/apps/desktop/src/utility/workspace/WorkspaceHost.ts +++ b/apps/desktop/src/utility/workspace/WorkspaceHost.ts @@ -55,6 +55,8 @@ export class WorkspaceHost { /** Serves the shell lane and announces readiness. Drafts are retained. */ start(): void { this.#shell = serveChannel(this.#shellTransport, { + "workspace.create": () => this.#serialize(() => this.#create()), + "workspace.open": ({ path }) => this.#serialize(() => this.#open(path)), "workspace.connect": (_payload, context) => { this.#connectSyncLane(context.ports); }, @@ -72,7 +74,6 @@ export class WorkspaceHost { this.#sync?.dispose(); this.#sync = serveChannel(this.#syncTransport(port), { - "workspace.create": () => this.#serialize(() => this.#create()), "workspace.snapshot": () => this.#serialize(() => this.#documentId === null ? null : this.#snapshot(this.#documentId), @@ -97,23 +98,20 @@ export class WorkspaceHost { }), // Save rides the edit lane: the same #serialize queue orders it behind // every committed apply/undo/redo, so it never writes stale state. - "workspace.open": ({ path }) => this.#serialize(() => this.#open(path)), "workspace.save": () => this.#serialize(() => this.#save()), "workspace.saveAs": ({ path }) => this.#serialize(() => this.#saveAs(path)), "workspace.layer": ({ layerId }) => this.#serialize(() => this.#bridge.getLayer(layerId)), }); } - #create(): WorkspaceSnapshot { + #create(): WorkspaceDocumentState { const draft = this.#documents.createDraft(); this.#bridge.createUntitledWorkspace(draft.storePath); this.#bridge.setDocumentId(draft.documentId); this.#documentId = draft.documentId; - const snapshot = this.#snapshot(draft.documentId); - this.#emitDocumentChanged(); - return snapshot; + return this.#emitDocumentChanged(); } #snapshot(documentId: string): WorkspaceSnapshot { @@ -127,7 +125,7 @@ export class WorkspaceHost { }; } - #open(path: string): WorkspaceSnapshot { + #open(path: string): WorkspaceDocumentState { const recovery = this.#bridge.findRecoverableWorkspace(path, this.#documents.listDrafts()); const document = recovery ?? this.#documents.createDraft(); @@ -139,10 +137,8 @@ export class WorkspaceHost { this.#bridge.setDocumentId(document.documentId); this.#documentId = document.documentId; - const snapshot = this.#snapshot(document.documentId); - this.#emitDocumentChanged(); - return snapshot; + return this.#emitDocumentChanged(); } #save(): WorkspaceDocumentState { From d2b30fae6c95e25bb835541c12a56254394b1427 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Fri, 26 Jun 2026 08:12:27 +0100 Subject: [PATCH 2/4] Address workspace launcher review --- apps/desktop/src/main/app/App.ts | 96 +++++-------------- .../src/main/workspace/WorkspaceManager.ts | 76 ++++++++++++++- apps/desktop/src/preload/preload.ts | 2 - .../src/renderer/src/store/appStore.ts | 25 ++--- .../src/renderer/src/views/Landing.tsx | 18 ++-- apps/desktop/src/shared/host/ShiftHost.ts | 8 -- apps/desktop/src/shared/ipc/contract.ts | 4 - 7 files changed, 123 insertions(+), 106 deletions(-) diff --git a/apps/desktop/src/main/app/App.ts b/apps/desktop/src/main/app/App.ts index cf92559d..024d3cea 100644 --- a/apps/desktop/src/main/app/App.ts +++ b/apps/desktop/src/main/app/App.ts @@ -9,17 +9,13 @@ import { CommandRegistry, type CommandContext } from "../commands/Command"; import { registerCommands } from "../commands/Commands"; import { ApplicationMenu } from "../menu/ApplicationMenu"; import { createShiftLogger, type ShiftLogger } from "../logging"; -import { WorkspaceProcess } from "../workspace/WorkspaceProcess"; -import { DocumentClient } from "../document/DocumentClient"; import { AppLifecycle } from "./AppLifecycle"; import { WindowManager } from "../windows/WindowManager"; import { WorkspaceManager } from "../workspace/WorkspaceManager"; import { WorkspaceSession } from "../workspace/WorkspaceSession"; -import type { WorkspaceDocumentState } from "../../shared/workspace/protocol"; import { showOpenFontDialog } from "../document/openFontDialog"; const APP_NAME = "Shift"; -type RendererRoute = "launcher" | "workspace"; /** * Owns Electron app startup and the first main-process service graph. @@ -35,7 +31,10 @@ export class App { #commands = new CommandRegistry(); #windows = new WindowManager(); - #workspaces = new WorkspaceManager(); + #workspaces = new WorkspaceManager({ + documentsRoot: () => this.#requireDocumentsRoot(), + applicationName: () => this.applicationName, + }); #documentsRoot: string | null = null; #appIcon = new AppIcon(); @@ -95,8 +94,7 @@ export class App { this.#appIcon.install(); this.#applicationMenu.install(); - const window = this.#createWindow(); - this.#loadRenderer(window, "launcher"); + this.#openLauncher(); this.#log.info("finished when ready callback"); }); @@ -129,9 +127,22 @@ export class App { return window; } - #loadRenderer(window: Window, route: RendererRoute) { + #openLauncher(): Window { + const window = this.#createWindow(); + this.#loadLauncher(window); + return window; + } + + #loadLauncher(window: Window): void { + this.#loadRenderer(window, "/launcher"); + } + + #loadWorkspace(window: Window): void { + this.#loadRenderer(window, "/home"); + } + + #loadRenderer(window: Window, hash: string): void { const source = getRendererSource(); - const hash = route === "workspace" ? "/home" : "/launcher"; if (source.type === "url") { // in dev load the renderer from vite at MAIN_WINDOW_VITE_DEV_SERVER_URL const url = new URL(source.source); @@ -154,12 +165,6 @@ export class App { ipc.handle(ipcMain, "commands.run", (_event, id) => { return this.#commands.run(id, this.#commandContext()); }); - ipc.handle(ipcMain, "workspace.create", async (event) => { - await this.#createWorkspaceForSender(event.sender); - }); - ipc.handle(ipcMain, "workspace.open", async (event) => { - await this.#openWorkspaceForSender(event.sender); - }); ipc.handle(ipcMain, "document.connect", (event) => { this.#log.info("document connect requested"); const session = this.#workspaceForSender(event.sender, "document.connect"); @@ -218,73 +223,24 @@ export class App { }; } - async #createWorkspaceForSender(sender: WebContents): Promise { - await this.#createWorkspaceFromWindow(this.#requireWindowForWebContents(sender)); - } - - async #openWorkspaceForSender(sender: WebContents): Promise { - await this.#openWorkspaceFromWindow(this.#requireWindowForWebContents(sender)); - } - async #createWorkspaceFromWindow(opener: Window): Promise { - const { session, state } = await this.#createWorkspaceSession((workspaceProcess) => - workspaceProcess.createWorkspace(), - ); - this.#openWorkspaceWindow(opener, session, state); + const session = await this.#workspaces.createUntitled(); + this.#openWorkspaceWindow(opener, session); } async #openWorkspaceFromWindow(opener: Window): Promise { const openPath = await showOpenFontDialog(opener); if (!openPath) return; - const { session, state } = await this.#createWorkspaceSession((workspaceProcess) => - workspaceProcess.openWorkspace(openPath), - ); - this.#openWorkspaceWindow(opener, session, state); - } - - async #createWorkspaceSession( - load: (workspaceProcess: WorkspaceProcess) => Promise, - ): Promise<{ session: WorkspaceSession; state: WorkspaceDocumentState }> { - const workspaceProcess = new WorkspaceProcess(); - workspaceProcess.start(this.#requireDocumentsRoot()); - - try { - await workspaceProcess.whenReady(); - const state = await load(workspaceProcess); - const existing = this.#workspaces.get(state.documentId); - if (existing) { - workspaceProcess.stop(); - existing.document.acceptState(state); - return { session: existing, state }; - } - - const session = new WorkspaceSession({ - workspaceId: state.documentId, - workspaceProcess, - documentClient: new DocumentClient(), - applicationName: () => this.applicationName, - }); - - session.document.acceptState(state); - this.#workspaces.register(session); - return { session, state }; - } catch (error) { - workspaceProcess.stop(); - throw error; - } + const session = await this.#workspaces.openPath(openPath); + this.#openWorkspaceWindow(opener, session); } - #openWorkspaceWindow( - opener: Window, - session: WorkspaceSession, - state: WorkspaceDocumentState, - ): void { + #openWorkspaceWindow(opener: Window, session: WorkspaceSession): void { const closeOpener = this.#workspaces.getForBrowserWindow(opener.window) === null; const workspaceWindow = this.#createWindow(); this.#workspaces.attachWindow(session.workspaceId, workspaceWindow); - session.document.acceptState(state); - this.#loadRenderer(workspaceWindow, "workspace"); + this.#loadWorkspace(workspaceWindow); if (closeOpener) opener.close(); } diff --git a/apps/desktop/src/main/workspace/WorkspaceManager.ts b/apps/desktop/src/main/workspace/WorkspaceManager.ts index 62f76ec2..f0d502af 100644 --- a/apps/desktop/src/main/workspace/WorkspaceManager.ts +++ b/apps/desktop/src/main/workspace/WorkspaceManager.ts @@ -1,18 +1,60 @@ import { BrowserWindow, type WebContents } from "electron"; +import { DocumentClient } from "../document/DocumentClient"; import type { Window } from "../windows/Window"; +import type { WorkspaceDocumentState } from "../../shared/workspace/protocol"; +import { WorkspaceProcess } from "./WorkspaceProcess"; import { type WorkspaceId, WorkspaceSession } from "./WorkspaceSession"; +/** Provides app-owned values required when a workspace session is created. */ +export interface WorkspaceManagerOptions { + readonly documentsRoot: () => string; + readonly applicationName: () => string; +} + /** * Tracks live font workspace sessions by workspace identity. * * @remarks * Commands and IPC handlers resolve a workspace from the focused or sending - * window before acting on document state. + * window before acting on document state. The manager also creates sessions + * so each loaded font gets its own process and renderer-mediated document + * lane. */ export class WorkspaceManager { + readonly #documentsRoot: () => string; + readonly #applicationName: () => string; readonly #sessionsById = new Map(); readonly #sessionIdByWindowId = new Map(); + /** + * Creates a manager for live font workspace sessions. + * + * @param options - Callbacks that provide app-level values when a session is created. + */ + constructor(options: WorkspaceManagerOptions) { + this.#documentsRoot = options.documentsRoot; + this.#applicationName = options.applicationName; + } + + /** + * Creates an untitled workspace session. + * + * @returns the live session that owns the new workspace. + */ + async createUntitled(): Promise { + return this.#createSession((workspaceProcess) => workspaceProcess.createWorkspace()); + } + + /** + * Opens a font source path in a workspace session. + * + * @param sourcePath - User-selected font source path. + * @returns a live session for the opened source; existing sessions are reused by workspace id. + */ + async openPath(sourcePath: string): Promise { + return this.#createSession((workspaceProcess) => workspaceProcess.openWorkspace(sourcePath)); + } + /** * Returns the live workspace session for an id. * @@ -121,4 +163,36 @@ export class WorkspaceManager { if (!session) throw new Error(`Workspace session is not registered: ${workspaceId}`); return session; } + + async #createSession( + load: (workspaceProcess: WorkspaceProcess) => Promise, + ): Promise { + const workspaceProcess = new WorkspaceProcess(); + workspaceProcess.start(this.#documentsRoot()); + + try { + await workspaceProcess.whenReady(); + const state = await load(workspaceProcess); + const existing = this.get(state.documentId); + if (existing) { + workspaceProcess.stop(); + existing.document.acceptState(state); + return existing; + } + + const session = new WorkspaceSession({ + workspaceId: state.documentId, + workspaceProcess, + documentClient: new DocumentClient(), + applicationName: this.#applicationName, + }); + + session.document.acceptState(state); + this.register(session); + return session; + } catch (error) { + workspaceProcess.stop(); + throw error; + } + } } diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index a90dd94b..cfbb838b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -14,8 +14,6 @@ const shiftHost: ShiftHost = { connect: invoke(ipcRenderer, "document.connect"), }, workspace: { - create: invoke(ipcRenderer, "workspace.create"), - open: invoke(ipcRenderer, "workspace.open"), connect: invoke(ipcRenderer, "workspace.connect"), }, ui: { diff --git a/apps/desktop/src/renderer/src/store/appStore.ts b/apps/desktop/src/renderer/src/store/appStore.ts index 3f2a92ae..87cc5db8 100644 --- a/apps/desktop/src/renderer/src/store/appStore.ts +++ b/apps/desktop/src/renderer/src/store/appStore.ts @@ -30,29 +30,24 @@ let workspaceRuntimeLoad: Promise | null = null; export function connectWorkspaceRuntime(): Promise { if (!workspaceRuntimeLoad) { - const attempt = connectWorkspaceRuntimeOnce().catch((error) => { - if (workspaceRuntimeLoad === attempt) { - workspaceRuntimeLoad = null; + workspaceRuntimeLoad = (async () => { + await workspace.connected(); + + const snapshot = workspace.workspaceCell.peek(); + if (!snapshot) { + throw new Error("workspace connected without a snapshot"); } + + await serveDocumentRequests(); + })().catch((error) => { + workspaceRuntimeLoad = null; throw error; }); - workspaceRuntimeLoad = attempt; } return workspaceRuntimeLoad; } -async function connectWorkspaceRuntimeOnce(): Promise { - await workspace.connected(); - - const snapshot = workspace.workspaceCell.peek(); - if (!snapshot) { - throw new Error("workspace connected without a snapshot"); - } - - await serveDocumentRequests(); -} - async function serveDocumentRequests(): Promise { const port = nextDocumentPort(); diff --git a/apps/desktop/src/renderer/src/views/Landing.tsx b/apps/desktop/src/renderer/src/views/Landing.tsx index 0c74a69c..42d83d02 100644 --- a/apps/desktop/src/renderer/src/views/Landing.tsx +++ b/apps/desktop/src/renderer/src/views/Landing.tsx @@ -7,14 +7,20 @@ import { getShiftHost } from "@/host/shiftHost"; export const Landing = () => { const host = getShiftHost(); - const handleNewFont = () => { - void host.workspace - .create() - .catch((error) => console.error("creating a new font failed", error)); + const handleNewFont = async () => { + try { + await host.commands.run("file.new"); + } catch (error) { + console.error("new font failed", error); + } }; - const handleOpenFont = () => { - void host.workspace.open().catch((error) => console.error("opening a font failed", error)); + const handleOpenFont = async () => { + try { + await host.commands.run("file.open"); + } catch (error) { + console.error("opening a font failed", error); + } }; return ( diff --git a/apps/desktop/src/shared/host/ShiftHost.ts b/apps/desktop/src/shared/host/ShiftHost.ts index 71c639a2..a18fea29 100644 --- a/apps/desktop/src/shared/host/ShiftHost.ts +++ b/apps/desktop/src/shared/host/ShiftHost.ts @@ -33,14 +33,6 @@ export interface ShiftHost { }; /** Connects the renderer to the workspace utility process. */ workspace: { - /** - * Requests a new untitled workspace in a new bound renderer window. - */ - create: () => Promise; - /** - * Requests that main show an open dialog and open the result in a new bound window. - */ - open: () => Promise; /** * Asks main to transfer a fresh sync-lane port to the workspace process. * diff --git a/apps/desktop/src/shared/ipc/contract.ts b/apps/desktop/src/shared/ipc/contract.ts index aa7726c9..154239dc 100644 --- a/apps/desktop/src/shared/ipc/contract.ts +++ b/apps/desktop/src/shared/ipc/contract.ts @@ -17,10 +17,6 @@ export type DocumentEventMap = Record; */ export type RendererToMain = { "commands.run": (id: CommandId) => void; - /** Creates an untitled workspace in a new bound window. */ - "workspace.create": () => void; - /** Opens a workspace through main's native open dialog in a new bound window. */ - "workspace.open": () => void; /** * Asks main to transfer a document request lane to the renderer. The port * arrives separately on the `document.port` postMessage channel because ports From bd258d6a8a66aac3fd47274ce7359528efeeade3 Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Fri, 26 Jun 2026 08:18:23 +0100 Subject: [PATCH 3/4] Clarify async runtime style --- AGENTS.md | 176 ++++++++++++++++++ Claude.md | 172 +---------------- .../src/renderer/src/store/appStore.ts | 30 +-- 3 files changed, 195 insertions(+), 183 deletions(-) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..f010a900 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,176 @@ +# Shift Editor - Development Guide + +## General Guidelines + +- Prefer switch statements over long if-else chains when branching on the same value. +- Prefer early returns over nested if-else blocks. Return early for guard clauses to keep the main logic at the top indentation level. In React components, guard on null data at the top (`if (!glyph) return null;`) instead of scattering `glyph?.foo ?? fallback` throughout the JSX. +- Prefer `async`/`await` with `try`/`catch` for asynchronous control flow. +- Avoid `.then(...)` / `.catch(...)` chains in application code when the same flow can be written clearly with `await`. +- Avoid `void promise.catch(...)` in React event handlers and normal command handlers. Use an `async` function with `try`/`catch` instead. +- Use `void promise.catch(...)` only for genuinely fire-and-forget boundaries that cannot be awaited by the caller, such as Electron menu callbacks. Keep those cases local and log or surface the failure. +- Avoid async IIFEs assigned into state, for example `state = (async () => { ... })().catch(...)`. Extract a named helper so the shared-state/memoization code and the async work are readable separately. + +## Naming + +- **Domain types are plain nouns.** `Glyph`, `Contour`, `Point`, `Anchor` — not `Glyph`, `GlyphInfo`, `GlyphState`, `GlyphRenderData`. If you need a modifier, it should describe the _kind_ of thing (`EditableGlyph`, `RenderContour`), not append generic suffixes. +- **Avoid `-Data`, `-Info`, `-State` suffixes** on types unless it genuinely represents transient mutable state (e.g. `TextRunRenderState` for a signal value consumed by a render pass). If the type represents a domain concept, name it after the concept. + +## Roadmap + +When completing a feature, check ROADMAP.md and check any box if we have completed it in the new feature. Always add tests to verify behaviour. + +## Testing + +Tests use `TestEditor` from `@/testing/TestEditor` (real Editor + real NAPI). Assert on state, not mock calls. See `/writing-tests` skill for canonical rules, templates, banned patterns, and the fake-test checklist — trigger it any time you add, rewrite, or review a `.test.ts` file. + +## Frontend + +### Base UI Components + +All UI components must wrap [Base UI](https://base-ui.com/react/components) primitives: + +- ALWAYS check if a Base UI component exists before creating a custom implementation +- Wrapper components live in `packages/ui/src/components/{componentName}/` +- Import Base UI as `import { Component as BaseComponent } from "@base-ui-components/react/component"` +- Export a wrapped version that applies project styling and extends the Base UI props +- Use the same name as Base UI (e.g., `Separator`, `Input`, `Tooltip`) + +Example wrapper structure: + +```tsx +import { Separator as BaseSeparator } from "@base-ui-components/react/separator"; + +export const Separator = React.forwardRef( + ({ className, ...props }, ref) => ( + + ), +); +``` + +## Package Manager + +This project uses **pnpm** (v9.0.0) as its package manager. + +## Available Commands + +### Development + +- `pnpm dev` - Start the Electron app in development mode +- `pnpm dev:app` - Start with watch mode + +### Code Quality + +- `pnpm format` - Format code with Prettier +- `pnpm format:check` - Check formatting without modifying files +- `pnpm lint` - Lint code with Oxlint (auto-fix) +- `pnpm lint:check` - Lint code without auto-fix +- `pnpm typecheck` - Type check with tsgo +- `cargo fmt` - Format Rust code (run after any Rust changes) +- `cargo clippy` - Lint Rust code (run after any Rust changes) + +### Testing + +- `pnpm test` - Run tests once +- `pnpm test:watch` - Run tests in watch mode + +### Building + +- `pnpm build:native` - Build Rust native modules +- `pnpm build:native:debug` - Build native modules in debug mode +- `pnpm package` - Package the application +- `pnpm make` - Build and create distribution + +### Glyph Info + +- `pnpm generate:glyph-info` - Generate glyph data, decomposition, charsets, and FTS5 search index +- `pnpm glyph-info:repl` - Start interactive REPL with GlyphInfo pre-loaded + +### Maintenance + +- `pnpm clean` - Clean build artifacts and node_modules +- `pnpm check-deps` - Check for unused dependencies + +## Project Structure + +- `apps/desktop/src/` - Electron app (main, preload, renderer, shared) +- `crates/` - Rust workspace (shift-font, shift-backends, shift-bridge, shift-store) +- `packages/` - TypeScript packages (types, geo, font, ui) + +## Code Organization Rules + +### Package vs App Code + +- Geometry utilities (Vec2, Curve, Polygon) → import from `@shift/geo` +- Glyph-domain geometry (contour traversal, segment parsing, tight/x bounds) → import from `@shift/font` +- Core types (Point2D, Rect2D, PointId, ContourId) → import from `@shift/types` +- NEVER duplicate package code in app layer +- If you need functionality from a package, import it; don't copy it +- Do not synthesize fake point IDs for geometry-only operations +- Canonical glyph geometry APIs: `parseContourSegments`, `deriveGlyphTightBounds`, `deriveGlyphXBounds` + +### Import Conventions + +- `@shift/*` for imports from packages (external-facing shared code) +- `@/*` for app-wide imports (from renderer/src root) +- Relative imports (`./`, `../`) only within the same module directory +- Never mix import styles for the same module +- **Never use inline type imports** such as `import("@shift/types").PointId`, `import("@/types/hitResult").ContourEndpointHit`, or `import("../core").ToolEvent`. Always use top-level imports: `import type { PointId } from "@shift/types"`, `import type { ContourEndpointHit } from "@/types/hitResult"`, or `import type { ToolEvent } from "../core"`. + +### Type Definitions + +- Domain types belong in `/types/{domain}.ts`, not in implementation files +- NEVER define types (interfaces, type aliases, enums) directly in classes or service files +- Types should be imported from dedicated type files +- Re-export types from their domain's index.ts for public API +- NEVER re-declare types that exist in `@shift/types` (generated from Rust). Import from `@shift/types`; for derived views (e.g. readonly, nested) use the domain pattern in `packages/types/src/domain.ts` + +### Generated and domain types + +- **Generated types** (from Rust via ts-rs) live ONLY in `packages/types/src/generated/`. Run `cargo test --package shift-core` to regenerate. They are the single source of truth for shapes and field names (e.g. `familyName`, `versionMajor`, not `family` or `version`). +- **Domain types** (e.g. `Point`, `Contour`, `Glyph`) live in `packages/types/src/domain.ts`. They MUST derive from generated types (e.g. `Readonly`, `Omit` + composition). See `domain.ts`: same field names, no re-declaration of structure. +- **App layer**: NEVER re-declare types that exist in `@shift/types`. Import `FontMetadata`, `FontMetrics`, snapshot types, etc. from `@shift/types`. If you need a narrowed or immutable view, define it in `packages/types` (e.g. domain.ts) as a type derived from the generated type, not as a new interface in the app. +- Bridge and native layer are typed with `@shift/types`; engine and UI use those types and the same field names (e.g. `familyName` in the UI, not `family`). + +### File Size Guidelines + +- Single classes should not exceed 500 lines +- If a file grows beyond 300 lines, evaluate splitting by responsibility +- Prefer composition over monolithic classes + +## Architectural Constraints + +- **NEVER create Manager, Store, or Cache wrapper classes.** NativeBridge is the single interface to Rust. Do not wrap it in FooManager, FooStore, or FooCache. If you need derived data, compute it at the call site — NAPI calls are ~50μs. +- **NEVER create CONTEXT.md files.** These are agent-generated dumps that go stale. Use `docs/architecture/` for architecture docs. + +## Anti-Slop Rules + +Rules enforced by `scripts/oxlint/shift-plugin.mjs` are omitted here — the linter catches them. The rules below are conventions not covered by lint: + +- **Use Point2D in function signatures.** Never create `(x, y)` / `(Point2D)` overloads with `typeof` resolution code. +- **Blank lines between logical blocks.** Separate guard clauses, branches, and return statements with blank lines. +- **Do not add methods to Editor without justification.** Editor.ts is a facade with 150+ delegation methods. Ask: does it add logic? Can it be a pure function? Does it belong on NativeBridge? + +## GlyphDraft — Immer-style two-tier mutations + +`editor.createDraft()` returns a `GlyphDraft` for drag operations (translate, rotate, resize, bend). The draft separates JS preview (every frame) from Rust persistence (once at end): + +- **`setPositions(updates)`** — calls `glyph.apply()` directly. JS-only, no NAPI, no Rust. Fires internal Glyph signals which trigger render effects. +- **`finish(label)`** — syncs final state to Rust via `restoreSnapshot` once, records undo. +- **`discard()`** — restores JS model from base snapshot. Rust was never modified. + +**NEVER call `bridge.setNodePositions()` inside the draft hot path.** That sends N individual NAPI struct marshals to Rust per frame. For glyphs with thousands of points this causes ~450ms frames + GC pressure. The draft exists specifically to avoid this. + +**Render effects track `glyph.contours` and `glyph.anchors`**, not `$glyph`. The `$glyph` signal on NativeBridge is for glyph identity (loaded/unloaded). Glyph data changes propagate through the Glyph model's internal signals. `#patchPositions` fires `#contours` with a new array reference so glyph-level effects see the change. + +## Documentation Routing + +Before creating new documentation or exploring unfamiliar subsystems, consult `docs/architecture/index.md`. It maps every repo area to its canonical `DOCS.md` and lists API boundaries. Use `/docs` to update module documentation following the standard format. + +Run `python3 scripts/context-drift-check.py` to validate doc freshness and link integrity. + +## Architecture References + +- **Documentation routing & API boundaries:** Read `docs/architecture/index.md` +- **Signal patterns & Editor conventions:** Read `lib/editor/Editor.ts` header comments +- **Tool structure & behavior system:** Read `lib/tools/core/BaseTool.ts` +- **Command organization:** Read `lib/commands/core/Command.ts` diff --git a/Claude.md b/Claude.md index a62adb65..a89606d6 100644 --- a/Claude.md +++ b/Claude.md @@ -1,171 +1,3 @@ -# Shift Editor - Development Guide +# Claude Guidance -## General Guidelines - -- Prefer switch statements over long if-else chains when branching on the same value. -- Prefer early returns over nested if-else blocks. Return early for guard clauses to keep the main logic at the top indentation level. In React components, guard on null data at the top (`if (!glyph) return null;`) instead of scattering `glyph?.foo ?? fallback` throughout the JSX. - -## Naming - -- **Domain types are plain nouns.** `Glyph`, `Contour`, `Point`, `Anchor` — not `Glyph`, `GlyphInfo`, `GlyphState`, `GlyphRenderData`. If you need a modifier, it should describe the _kind_ of thing (`EditableGlyph`, `RenderContour`), not append generic suffixes. -- **Avoid `-Data`, `-Info`, `-State` suffixes** on types unless it genuinely represents transient mutable state (e.g. `TextRunRenderState` for a signal value consumed by a render pass). If the type represents a domain concept, name it after the concept. - -## Roadmap - -When completing a feature, check ROADMAP.md and check any box if we have completed it in the new feature. Always add tests to verify behaviour. - -## Testing - -Tests use `TestEditor` from `@/testing/TestEditor` (real Editor + real NAPI). Assert on state, not mock calls. See `/writing-tests` skill for canonical rules, templates, banned patterns, and the fake-test checklist — trigger it any time you add, rewrite, or review a `.test.ts` file. - -## Frontend - -### Base UI Components - -All UI components must wrap [Base UI](https://base-ui.com/react/components) primitives: - -- ALWAYS check if a Base UI component exists before creating a custom implementation -- Wrapper components live in `packages/ui/src/components/{componentName}/` -- Import Base UI as `import { Component as BaseComponent } from "@base-ui-components/react/component"` -- Export a wrapped version that applies project styling and extends the Base UI props -- Use the same name as Base UI (e.g., `Separator`, `Input`, `Tooltip`) - -Example wrapper structure: - -```tsx -import { Separator as BaseSeparator } from "@base-ui-components/react/separator"; - -export const Separator = React.forwardRef( - ({ className, ...props }, ref) => ( - - ), -); -``` - -## Package Manager - -This project uses **pnpm** (v9.0.0) as its package manager. - -## Available Commands - -### Development - -- `pnpm dev` - Start the Electron app in development mode -- `pnpm dev:app` - Start with watch mode - -### Code Quality - -- `pnpm format` - Format code with Prettier -- `pnpm format:check` - Check formatting without modifying files -- `pnpm lint` - Lint code with Oxlint (auto-fix) -- `pnpm lint:check` - Lint code without auto-fix -- `pnpm typecheck` - Type check with tsgo -- `cargo fmt` - Format Rust code (run after any Rust changes) -- `cargo clippy` - Lint Rust code (run after any Rust changes) - -### Testing - -- `pnpm test` - Run tests once -- `pnpm test:watch` - Run tests in watch mode - -### Building - -- `pnpm build:native` - Build Rust native modules -- `pnpm build:native:debug` - Build native modules in debug mode -- `pnpm package` - Package the application -- `pnpm make` - Build and create distribution - -### Glyph Info - -- `pnpm generate:glyph-info` - Generate glyph data, decomposition, charsets, and FTS5 search index -- `pnpm glyph-info:repl` - Start interactive REPL with GlyphInfo pre-loaded - -### Maintenance - -- `pnpm clean` - Clean build artifacts and node_modules -- `pnpm check-deps` - Check for unused dependencies - -## Project Structure - -- `apps/desktop/src/` - Electron app (main, preload, renderer, shared) -- `crates/` - Rust workspace (shift-font, shift-backends, shift-bridge, shift-store) -- `packages/` - TypeScript packages (types, geo, font, ui) - -## Code Organization Rules - -### Package vs App Code - -- Geometry utilities (Vec2, Curve, Polygon) → import from `@shift/geo` -- Glyph-domain geometry (contour traversal, segment parsing, tight/x bounds) → import from `@shift/font` -- Core types (Point2D, Rect2D, PointId, ContourId) → import from `@shift/types` -- NEVER duplicate package code in app layer -- If you need functionality from a package, import it; don't copy it -- Do not synthesize fake point IDs for geometry-only operations -- Canonical glyph geometry APIs: `parseContourSegments`, `deriveGlyphTightBounds`, `deriveGlyphXBounds` - -### Import Conventions - -- `@shift/*` for imports from packages (external-facing shared code) -- `@/*` for app-wide imports (from renderer/src root) -- Relative imports (`./`, `../`) only within the same module directory -- Never mix import styles for the same module -- **Never use inline type imports** such as `import("@shift/types").PointId`, `import("@/types/hitResult").ContourEndpointHit`, or `import("../core").ToolEvent`. Always use top-level imports: `import type { PointId } from "@shift/types"`, `import type { ContourEndpointHit } from "@/types/hitResult"`, or `import type { ToolEvent } from "../core"`. - -### Type Definitions - -- Domain types belong in `/types/{domain}.ts`, not in implementation files -- NEVER define types (interfaces, type aliases, enums) directly in classes or service files -- Types should be imported from dedicated type files -- Re-export types from their domain's index.ts for public API -- NEVER re-declare types that exist in `@shift/types` (generated from Rust). Import from `@shift/types`; for derived views (e.g. readonly, nested) use the domain pattern in `packages/types/src/domain.ts` - -### Generated and domain types - -- **Generated types** (from Rust via ts-rs) live ONLY in `packages/types/src/generated/`. Run `cargo test --package shift-core` to regenerate. They are the single source of truth for shapes and field names (e.g. `familyName`, `versionMajor`, not `family` or `version`). -- **Domain types** (e.g. `Point`, `Contour`, `Glyph`) live in `packages/types/src/domain.ts`. They MUST derive from generated types (e.g. `Readonly`, `Omit` + composition). See `domain.ts`: same field names, no re-declaration of structure. -- **App layer**: NEVER re-declare types that exist in `@shift/types`. Import `FontMetadata`, `FontMetrics`, snapshot types, etc. from `@shift/types`. If you need a narrowed or immutable view, define it in `packages/types` (e.g. domain.ts) as a type derived from the generated type, not as a new interface in the app. -- Bridge and native layer are typed with `@shift/types`; engine and UI use those types and the same field names (e.g. `familyName` in the UI, not `family`). - -### File Size Guidelines - -- Single classes should not exceed 500 lines -- If a file grows beyond 300 lines, evaluate splitting by responsibility -- Prefer composition over monolithic classes - -## Architectural Constraints - -- **NEVER create Manager, Store, or Cache wrapper classes.** NativeBridge is the single interface to Rust. Do not wrap it in FooManager, FooStore, or FooCache. If you need derived data, compute it at the call site — NAPI calls are ~50μs. -- **NEVER create CONTEXT.md files.** These are agent-generated dumps that go stale. Use `docs/architecture/` for architecture docs. - -## Anti-Slop Rules - -Rules enforced by `scripts/oxlint/shift-plugin.mjs` are omitted here — the linter catches them. The rules below are conventions not covered by lint: - -- **Use Point2D in function signatures.** Never create `(x, y)` / `(Point2D)` overloads with `typeof` resolution code. -- **Blank lines between logical blocks.** Separate guard clauses, branches, and return statements with blank lines. -- **Do not add methods to Editor without justification.** Editor.ts is a facade with 150+ delegation methods. Ask: does it add logic? Can it be a pure function? Does it belong on NativeBridge? - -## GlyphDraft — Immer-style two-tier mutations - -`editor.createDraft()` returns a `GlyphDraft` for drag operations (translate, rotate, resize, bend). The draft separates JS preview (every frame) from Rust persistence (once at end): - -- **`setPositions(updates)`** — calls `glyph.apply()` directly. JS-only, no NAPI, no Rust. Fires internal Glyph signals which trigger render effects. -- **`finish(label)`** — syncs final state to Rust via `restoreSnapshot` once, records undo. -- **`discard()`** — restores JS model from base snapshot. Rust was never modified. - -**NEVER call `bridge.setNodePositions()` inside the draft hot path.** That sends N individual NAPI struct marshals to Rust per frame. For glyphs with thousands of points this causes ~450ms frames + GC pressure. The draft exists specifically to avoid this. - -**Render effects track `glyph.contours` and `glyph.anchors`**, not `$glyph`. The `$glyph` signal on NativeBridge is for glyph identity (loaded/unloaded). Glyph data changes propagate through the Glyph model's internal signals. `#patchPositions` fires `#contours` with a new array reference so glyph-level effects see the change. - -## Documentation Routing - -Before creating new documentation or exploring unfamiliar subsystems, consult `docs/architecture/index.md`. It maps every repo area to its canonical `DOCS.md` and lists API boundaries. Use `/docs` to update module documentation following the standard format. - -Run `python3 scripts/context-drift-check.py` to validate doc freshness and link integrity. - -## Architecture References - -- **Documentation routing & API boundaries:** Read `docs/architecture/index.md` -- **Signal patterns & Editor conventions:** Read `lib/editor/Editor.ts` header comments -- **Tool structure & behavior system:** Read `lib/tools/core/BaseTool.ts` -- **Command organization:** Read `lib/commands/core/Command.ts` +Read [AGENTS.md](./AGENTS.md) and follow the same repo guidance. diff --git a/apps/desktop/src/renderer/src/store/appStore.ts b/apps/desktop/src/renderer/src/store/appStore.ts index 87cc5db8..415784bb 100644 --- a/apps/desktop/src/renderer/src/store/appStore.ts +++ b/apps/desktop/src/renderer/src/store/appStore.ts @@ -30,24 +30,28 @@ let workspaceRuntimeLoad: Promise | null = null; export function connectWorkspaceRuntime(): Promise { if (!workspaceRuntimeLoad) { - workspaceRuntimeLoad = (async () => { - await workspace.connected(); - - const snapshot = workspace.workspaceCell.peek(); - if (!snapshot) { - throw new Error("workspace connected without a snapshot"); - } - - await serveDocumentRequests(); - })().catch((error) => { - workspaceRuntimeLoad = null; - throw error; - }); + workspaceRuntimeLoad = loadWorkspaceRuntime(); } return workspaceRuntimeLoad; } +async function loadWorkspaceRuntime(): Promise { + try { + await workspace.connected(); + + const snapshot = workspace.workspaceCell.peek(); + if (!snapshot) { + throw new Error("workspace connected without a snapshot"); + } + + await serveDocumentRequests(); + } catch (error) { + workspaceRuntimeLoad = null; + throw error; + } +} + async function serveDocumentRequests(): Promise { const port = nextDocumentPort(); From c1f8df4a58684306f78c7bfdf3ff42e3272a610b Mon Sep 17 00:00:00 2001 From: Kostya Farber Date: Fri, 26 Jun 2026 08:48:28 +0100 Subject: [PATCH 4/4] Replace renderer app store with workspace provider --- apps/desktop/src/renderer/src/app/App.tsx | 17 ++- apps/desktop/src/renderer/src/app/Screens.tsx | 53 ++++++--- .../src/components/chrome/FontInfoDialog.tsx | 6 +- .../src/components/chrome/Toolbar.tsx | 5 +- .../src/components/debug/DebugPanel.tsx | 4 +- .../src/components/editor/BooleanOps.tsx | 4 +- .../renderer/src/components/editor/Canvas.tsx | 4 +- .../src/components/editor/GlyphFinder.tsx | 2 +- .../components/editor/InteractiveScene.tsx | 4 +- .../src/components/editor/RightSidebar.tsx | 4 +- .../src/components/editor/ToolsPane.tsx | 4 +- .../editor/sidebar-right/AnchorSection.tsx | 4 +- .../editor/sidebar-right/GlyphSection.tsx | 5 +- .../editor/sidebar-right/ScaleSection.tsx | 4 +- .../editor/sidebar-right/TransformSection.tsx | 4 +- .../src/components/home/GlyphGrid.tsx | 7 +- .../src/components/home/GlyphPreview.tsx | 4 +- .../src/components/text/HiddenTextInput.tsx | 15 ++- .../src/components/variation/AxesPanel.tsx | 5 +- .../components/variation/CreateAxisDialog.tsx | 4 +- .../variation/CreateSourceDialog.tsx | 4 +- .../src/components/variation/Sources.tsx | 4 +- .../renderer/src/context/CanvasContext.tsx | 7 +- .../src/renderer/src/context/DebugContext.tsx | 10 +- .../src/context/GlyphCatalogContext.tsx | 8 +- .../desktop/src/renderer/src/hooks/useAxes.ts | 4 +- .../renderer/src/hooks/useDesignLocation.ts | 4 +- .../src/hooks/useDocumentChromeState.ts | 12 +- .../src/hooks/useGlyphSidebearings.ts | 4 +- .../renderer/src/hooks/useGlyphXAdvance.ts | 4 +- .../renderer/src/hooks/useLayerSourceId.ts | 5 +- .../renderer/src/hooks/useSelectionBounds.ts | 4 +- .../src/renderer/src/hooks/useSources.ts | 4 +- .../src/renderer/src/lib/editor/Editor.ts | 4 +- .../src/renderer/src/lib/model/Font.test.ts | 2 +- .../src/renderer/src/lib/model/Font.ts | 41 ++++--- .../src/renderer/src/lib/model/Glyph.ts | 8 +- .../renderer/src/lib/model/variation.test.ts | 4 +- .../src/renderer/src/lib/signals/useSignal.ts | 2 +- .../renderer/src/lib/text/layout/testUtils.ts | 2 +- .../src/lib/workspace/LayerIntents.ts | 37 +++--- ...WorkspaceSession.ts => WorkspaceClient.ts} | 101 ++++++++-------- ...st.ts => WorkspaceEditCoordinator.test.ts} | 28 ++--- ...itQueue.ts => WorkspaceEditCoordinator.ts} | 8 +- .../src/renderer/src/store/appStore.ts | 110 ------------------ .../src/renderer/src/testing/TestEditor.ts | 6 +- .../renderer/src/testing/workspaceStack.ts | 20 ++-- .../desktop/src/renderer/src/views/Editor.tsx | 10 +- .../src/renderer/src/workspace/Workspace.ts | 76 ++++++++++++ .../src/workspace/WorkspaceContext.tsx | 72 ++++++++++++ .../src/workspace/WorkspaceDocumentBridge.ts | 67 +++++++++++ .../src/renderer/src/workspace/glyphInfo.ts | 8 ++ 52 files changed, 498 insertions(+), 341 deletions(-) rename apps/desktop/src/renderer/src/lib/workspace/{WorkspaceSession.ts => WorkspaceClient.ts} (75%) rename apps/desktop/src/renderer/src/lib/workspace/{WorkspaceEditQueue.test.ts => WorkspaceEditCoordinator.test.ts} (62%) rename apps/desktop/src/renderer/src/lib/workspace/{WorkspaceEditQueue.ts => WorkspaceEditCoordinator.ts} (97%) delete mode 100644 apps/desktop/src/renderer/src/store/appStore.ts create mode 100644 apps/desktop/src/renderer/src/workspace/Workspace.ts create mode 100644 apps/desktop/src/renderer/src/workspace/WorkspaceContext.tsx create mode 100644 apps/desktop/src/renderer/src/workspace/WorkspaceDocumentBridge.ts create mode 100644 apps/desktop/src/renderer/src/workspace/glyphInfo.ts diff --git a/apps/desktop/src/renderer/src/app/App.tsx b/apps/desktop/src/renderer/src/app/App.tsx index 313f461e..279e2cd8 100644 --- a/apps/desktop/src/renderer/src/app/App.tsx +++ b/apps/desktop/src/renderer/src/app/App.tsx @@ -3,7 +3,6 @@ import { HashRouter } from "react-router-dom"; import { ThemeProvider } from "@/context/ThemeContext"; import { FocusZoneProvider } from "@/context/FocusZoneContext"; -import { DebugProvider } from "@/context/DebugContext"; import { ZoomToast } from "@/components/chrome/ZoomToast"; import { Screens } from "./Screens"; @@ -11,15 +10,13 @@ import { Screens } from "./Screens"; export const App = () => { return ( - - - - - - - - - + + + + + + + ); }; diff --git a/apps/desktop/src/renderer/src/app/Screens.tsx b/apps/desktop/src/renderer/src/app/Screens.tsx index 890c2bd9..87d1d9af 100644 --- a/apps/desktop/src/renderer/src/app/Screens.tsx +++ b/apps/desktop/src/renderer/src/app/Screens.tsx @@ -4,9 +4,10 @@ import { Navigate, Outlet, Route, Routes } from "react-router-dom"; import { Landing } from "@/views/Landing"; import { Home } from "@/views/Home"; import { Editor } from "@/views/Editor"; -import { connectWorkspaceRuntime, getEditor, getFont } from "@/store/appStore"; import { getShiftHost } from "@/host/shiftHost"; import { useSignalState } from "@/lib/signals/useSignal"; +import { useEditor, useFont, useWorkspace, WorkspaceProvider } from "@/workspace/WorkspaceContext"; +import { DebugProvider } from "@/context/DebugContext"; /** * Routes launcher and workspace windows to their screen trees. @@ -20,7 +21,15 @@ export const Screens = () => { return ( } /> - }> + + + + + + } + > } /> } /> @@ -30,27 +39,45 @@ export const Screens = () => { }; const WorkspaceScreens = () => { - const font = getFont(); - const editor = getEditor(); + const workspace = useWorkspace(); + const font = useFont(); + const editor = useEditor(); const documentLoaded = useSignalState(font.$loaded); const [connectionError, setConnectionError] = useState(null); useEffect(() => { - void connectWorkspaceRuntime().catch((error) => { - console.error("workspace runtime failed", error); - setConnectionError(error); - }); - }, []); + let cancelled = false; + + async function connectWorkspace(): Promise { + try { + await workspace.connect(); + } catch (error) { + console.error("workspace failed to connect", error); + if (!cancelled) setConnectionError(error); + } + } + + void connectWorkspace(); + + return () => { + cancelled = true; + }; + }, [workspace]); // Side effect of a document loading: give the editor room. useEffect(() => { if (!documentLoaded) return; - editor.scene.setLocation(font.defaultLocation()); + async function maximiseWorkspaceWindow(): Promise { + try { + await getShiftHost().commands.run("window.maximise"); + } catch (error) { + console.error("maximise on document load failed", error); + } + } - void getShiftHost() - .commands.run("window.maximise") - .catch((error) => console.error("maximise on document load failed", error)); + editor.scene.setLocation(font.defaultLocation()); + void maximiseWorkspaceWindow(); }, [documentLoaded, editor, font]); if (connectionError) { diff --git a/apps/desktop/src/renderer/src/components/chrome/FontInfoDialog.tsx b/apps/desktop/src/renderer/src/components/chrome/FontInfoDialog.tsx index 4677abe5..8558c055 100644 --- a/apps/desktop/src/renderer/src/components/chrome/FontInfoDialog.tsx +++ b/apps/desktop/src/renderer/src/components/chrome/FontInfoDialog.tsx @@ -11,7 +11,7 @@ import { X, cn, } from "@shift/ui"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; interface FontInfoDialogProps { open: boolean; @@ -29,7 +29,7 @@ const CATEGORIES: { id: CategoryId; label: string }[] = [ ]; export const FontInfoDialog = ({ open, onOpenChange }: FontInfoDialogProps) => { - const editor = getEditor(); + const editor = useEditor(); const [category, setCategory] = useState("font"); if (!editor.font.loaded) return null; @@ -95,7 +95,7 @@ const CategoryPanel = ({ category }: { category: CategoryId }) => { }; const FontPanel = () => { - const editor = getEditor(); + const editor = useEditor(); const m = editor.font.metadata; const version = m.versionMajor !== undefined ? `${m.versionMajor}.${m.versionMinor ?? 0}` : ""; diff --git a/apps/desktop/src/renderer/src/components/chrome/Toolbar.tsx b/apps/desktop/src/renderer/src/components/chrome/Toolbar.tsx index cfc685e2..0fe9017f 100644 --- a/apps/desktop/src/renderer/src/components/chrome/Toolbar.tsx +++ b/apps/desktop/src/renderer/src/components/chrome/Toolbar.tsx @@ -2,11 +2,12 @@ import { NavigationPane } from "./NavigationPane"; import { Titlebar } from "./Titlebar"; import { ToolsPane } from "@/components/editor/ToolsPane"; import { useDocumentChromeState } from "@/hooks/useDocumentChromeState"; -import { getFont } from "@/store/appStore"; +import { useFont } from "@/workspace/WorkspaceContext"; export const Toolbar = () => { + const font = useFont(); const { filename, dirty } = useDocumentChromeState(); - const familyName = getFont().metadata.familyName; + const familyName = font.metadata.familyName; const editedFilename = `${filename} — Edited`; return ( diff --git a/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx b/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx index 4bf81d85..a5d50ea5 100644 --- a/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx +++ b/apps/desktop/src/renderer/src/components/debug/DebugPanel.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useRef } from "react"; import { useSignalText } from "@/hooks/useSignalText"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { Separator } from "@shift/ui"; import { effect } from "@/lib/signals"; import { useSignalState, useSignalTrigger } from "@/lib/signals/useSignal"; @@ -16,7 +16,7 @@ function formatBytes(bytes: number): string { } export function DebugPanel() { - const editor = getEditor(); + const editor = useEditor(); const instance = useSignalState(editor.glyphInstanceCell); useSignalTrigger(instance?.xAdvanceCell); diff --git a/apps/desktop/src/renderer/src/components/editor/BooleanOps.tsx b/apps/desktop/src/renderer/src/components/editor/BooleanOps.tsx index 546dd72d..495d20f2 100644 --- a/apps/desktop/src/renderer/src/components/editor/BooleanOps.tsx +++ b/apps/desktop/src/renderer/src/components/editor/BooleanOps.tsx @@ -4,10 +4,10 @@ import { SidebarSection } from "./sidebar-right/SidebarSection"; import UnionIcon from "@/assets/sidebar-right/union.svg"; import IntersectIcon from "@/assets/sidebar-right/intersect.svg"; import SubtractIcon from "@/assets/sidebar-right/subtract.svg"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; export const BooleanOps = () => { - const editor = getEditor(); + const editor = useEditor(); const selectedContourIds = editor.selection.contourIds; if (selectedContourIds.size < 2) return null; diff --git a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx index 6f1148e4..410db789 100644 --- a/apps/desktop/src/renderer/src/components/editor/Canvas.tsx +++ b/apps/desktop/src/renderer/src/components/editor/Canvas.tsx @@ -4,7 +4,7 @@ import { useParams } from "react-router-dom"; import { CanvasContextProvider } from "@/context/CanvasContext"; import { useDebugSafe } from "@/context/DebugContext"; import { useSignalState } from "@/lib/signals"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { zoomMultiplierFromWheel } from "@/lib/transform"; import { InteractiveScene } from "./InteractiveScene"; import { StaticScene } from "./StaticScene"; @@ -14,7 +14,7 @@ import { Vec2 } from "@shift/geo"; import { asGlyphId } from "@shift/types"; export const Canvas: FC = () => { - const editor = getEditor(); + const editor = useEditor(); const debug = useDebugSafe(); const { glyphId: glyphIdParam } = useParams(); const containerRef = useRef(null); diff --git a/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx b/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx index 23d4eb6d..0cf7d830 100644 --- a/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx +++ b/apps/desktop/src/renderer/src/components/editor/GlyphFinder.tsx @@ -11,7 +11,7 @@ import { import { formatCodepointAsUPlus } from "@/lib/utils/unicode"; import type { SearchResult } from "@shift/glyph-info"; import { useFocusZone } from "@/context/FocusZoneContext"; -import { getGlyphInfo } from "@/store/appStore"; +import { getGlyphInfo } from "@/workspace/glyphInfo"; interface GlyphFinderProps { open: boolean; diff --git a/apps/desktop/src/renderer/src/components/editor/InteractiveScene.tsx b/apps/desktop/src/renderer/src/components/editor/InteractiveScene.tsx index 1bfd211e..8ba9bca8 100644 --- a/apps/desktop/src/renderer/src/components/editor/InteractiveScene.tsx +++ b/apps/desktop/src/renderer/src/components/editor/InteractiveScene.tsx @@ -1,11 +1,11 @@ import { useContext, useCallback } from "react"; import { CanvasContext } from "@/context/CanvasContext"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; export const InteractiveScene = () => { const { overlayCanvasRef } = useContext(CanvasContext); - const editor = getEditor(); + const editor = useEditor(); const toolManager = editor.toolManager; const getScreenPoint = useCallback( diff --git a/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx b/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx index 602a2b50..8261d550 100644 --- a/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx +++ b/apps/desktop/src/renderer/src/components/editor/RightSidebar.tsx @@ -3,7 +3,7 @@ import { Separator } from "@shift/ui"; import { TransformSection } from "./sidebar-right/TransformSection"; import { ScaleSection } from "./sidebar-right/ScaleSection"; import { TransformOriginProvider } from "@/context/TransformOriginContext"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { useSignalState } from "@/lib/signals"; import { useSignalEffect } from "@/hooks/useSignalEffect"; import { GlyphSection } from "./sidebar-right/GlyphSection"; @@ -11,7 +11,7 @@ import { AnchorSection } from "./sidebar-right/AnchorSection"; import { BooleanOps } from "./BooleanOps"; export const RightSidebar = () => { - const editor = getEditor(); + const editor = useEditor(); const zoom = useSignalState(editor.zoomCell); const zoomPercent = Math.round(zoom * 100); const { familyName } = editor.font.metadata; diff --git a/apps/desktop/src/renderer/src/components/editor/ToolsPane.tsx b/apps/desktop/src/renderer/src/components/editor/ToolsPane.tsx index 6dc0c80f..9beb9853 100644 --- a/apps/desktop/src/renderer/src/components/editor/ToolsPane.tsx +++ b/apps/desktop/src/renderer/src/components/editor/ToolsPane.tsx @@ -2,7 +2,7 @@ import { FC } from "react"; import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, cn } from "@shift/ui"; import { useSignalState } from "@/lib/signals"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { SVG } from "@/types/common"; import type { ToolName } from "@/lib/tools/core"; @@ -41,7 +41,7 @@ export const ToolbarIcon: FC = ({ Icon, name, tooltip, activeT }; export const ToolsPane: FC = () => { - const editor = getEditor(); + const editor = useEditor(); const activeTool = useSignalState(editor.activeToolCell); return ( diff --git a/apps/desktop/src/renderer/src/components/editor/sidebar-right/AnchorSection.tsx b/apps/desktop/src/renderer/src/components/editor/sidebar-right/AnchorSection.tsx index e3311723..d69b633a 100644 --- a/apps/desktop/src/renderer/src/components/editor/sidebar-right/AnchorSection.tsx +++ b/apps/desktop/src/renderer/src/components/editor/sidebar-right/AnchorSection.tsx @@ -1,13 +1,13 @@ import { useRef } from "react"; import { SidebarSection } from "./SidebarSection"; import { EditableSidebarInput, type EditableSidebarInputHandle } from "./EditableSidebarInput"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { useSignalEffect } from "@/hooks/useSignalEffect"; import { useState } from "react"; import type { AnchorId } from "@shift/types"; export const AnchorSection = () => { - const editor = getEditor(); + const editor = useEditor(); const [singleAnchorId, setSingleAnchorId] = useState(null); diff --git a/apps/desktop/src/renderer/src/components/editor/sidebar-right/GlyphSection.tsx b/apps/desktop/src/renderer/src/components/editor/sidebar-right/GlyphSection.tsx index df52c1f9..7da5388c 100644 --- a/apps/desktop/src/renderer/src/components/editor/sidebar-right/GlyphSection.tsx +++ b/apps/desktop/src/renderer/src/components/editor/sidebar-right/GlyphSection.tsx @@ -2,13 +2,14 @@ import { formatCodepointAsUPlus } from "@/lib/utils/unicode"; import { SidebarSection } from "./SidebarSection"; import { EditableSidebarInput } from "./EditableSidebarInput"; import PlaceholderGlyph from "@/assets/sidebar-right/placeholder-glyph.svg"; -import { getEditor, getGlyphInfo } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; +import { getGlyphInfo } from "@/workspace/glyphInfo"; import { useSignalState } from "@/lib/signals"; import { useGlyphSidebearings } from "@/hooks/useGlyphSidebearings"; import { useGlyphXAdvance } from "@/hooks/useGlyphXAdvance"; export const GlyphSection = () => { - const editor = getEditor(); + const editor = useEditor(); const glyph = useSignalState(editor.glyph); const sidebearings = useGlyphSidebearings(); const xAdvance = useGlyphXAdvance(); diff --git a/apps/desktop/src/renderer/src/components/editor/sidebar-right/ScaleSection.tsx b/apps/desktop/src/renderer/src/components/editor/sidebar-right/ScaleSection.tsx index cd8e119f..0b97ac13 100644 --- a/apps/desktop/src/renderer/src/components/editor/sidebar-right/ScaleSection.tsx +++ b/apps/desktop/src/renderer/src/components/editor/sidebar-right/ScaleSection.tsx @@ -3,14 +3,14 @@ import { SidebarSection } from "./SidebarSection"; import { TransformGrid } from "./TransformGrid"; import { EditableSidebarInput, type EditableSidebarInputHandle } from "./EditableSidebarInput"; import { useTransformOrigin } from "@/context/TransformOriginContext"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { anchorToPoint } from "@/lib/transform/anchor"; import { Bounds } from "@shift/geo"; import ScaleIcon from "@/assets/sidebar-right/scale.svg"; import { useSelectionBounds } from "@/hooks/useSelectionBounds"; export const ScaleSection = () => { - const editor = getEditor(); + const editor = useEditor(); const { anchor, setAnchor } = useTransformOrigin(); const selectionBounds = useSelectionBounds(); diff --git a/apps/desktop/src/renderer/src/components/editor/sidebar-right/TransformSection.tsx b/apps/desktop/src/renderer/src/components/editor/sidebar-right/TransformSection.tsx index 586862df..59201899 100644 --- a/apps/desktop/src/renderer/src/components/editor/sidebar-right/TransformSection.tsx +++ b/apps/desktop/src/renderer/src/components/editor/sidebar-right/TransformSection.tsx @@ -3,7 +3,7 @@ import { SidebarSection } from "./SidebarSection"; import { EditableSidebarInput, type EditableSidebarInputHandle } from "./EditableSidebarInput"; import { IconButton } from "./IconButton"; import { useTransformOrigin } from "@/context/TransformOriginContext"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { anchorToPoint } from "@/lib/transform/anchor"; import { useSignalState } from "@/lib/signals"; import { useSelectionBounds } from "@/hooks/useSelectionBounds"; @@ -90,7 +90,7 @@ const DistributeButtonsRow = React.memo(function DistributeButtonsRow({ }); export const TransformSection = () => { - const editor = getEditor(); + const editor = useEditor(); const { anchor } = useTransformOrigin(); const selectedPointIds = useSignalState(editor.selection.stateCell).pointIds; const selectionBounds = useSelectionBounds(); diff --git a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx index 8dc83423..8ad9fdf1 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphGrid.tsx @@ -45,7 +45,8 @@ import { memo, useCallback, useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useVirtualizer } from "@tanstack/react-virtual"; import { CELL_HEIGHT, GlyphPreview } from "@/components/home/GlyphPreview"; -import { getEditor, getGlyphInfo } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; +import { getGlyphInfo } from "@/workspace/glyphInfo"; import { type GlyphCatalogItem, useGlyphCatalog } from "@/context/GlyphCatalogContext"; import { Button, Input } from "@shift/ui"; import type { GlyphName } from "@shift/types"; @@ -66,7 +67,7 @@ function computeLayout(width: number) { export const GlyphGrid = memo(function GlyphGrid() { const navigate = useNavigate(); - const editor = getEditor(); + const editor = useEditor(); const { filteredGlyphs: glyphs } = useGlyphCatalog(); const scrollContainerRef = useRef(null); @@ -175,7 +176,7 @@ export const GlyphGrid = memo(function GlyphGrid() { }); function GlyphNameInput({ glyph }: { readonly glyph: GlyphCatalogItem }) { - const editor = getEditor(); + const editor = useEditor(); const glyphInfo = getGlyphInfo(); const glyphName = glyph.name; const [draft, setDraft] = useState(glyphName); diff --git a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx index e5669662..3dcda77e 100644 --- a/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx +++ b/apps/desktop/src/renderer/src/components/home/GlyphPreview.tsx @@ -2,7 +2,7 @@ import type { FontMetrics } from "@shift/types"; import type { Font } from "@/lib/model/Font"; import type { Glyph } from "@/lib/model/Glyph"; import { useSignalState } from "@/lib/signals"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import type { GlyphHandle } from "@shift/bridge"; export const CELL_HEIGHT = 75; @@ -64,7 +64,7 @@ export function GlyphPreview({ handle, font, height = CELL_HEIGHT }: GlyphPrevie } function GlyphCell({ font, height, glyph }: { font: Font; height: number; glyph: Glyph }) { - const editor = getEditor(); + const editor = useEditor(); const outline = glyph.instance(editor.$designLocation).render.outline; const svgPath = useSignalState(outline.$svgPath); diff --git a/apps/desktop/src/renderer/src/components/text/HiddenTextInput.tsx b/apps/desktop/src/renderer/src/components/text/HiddenTextInput.tsx index 381125d1..12dedfd9 100644 --- a/apps/desktop/src/renderer/src/components/text/HiddenTextInput.tsx +++ b/apps/desktop/src/renderer/src/components/text/HiddenTextInput.tsx @@ -6,12 +6,12 @@ * feed into `editor.textRun`; rendering updates reactively via signals. */ import { useCallback, useEffect, useRef, useState } from "react"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { effect } from "@/lib/signals/signal"; import { lineBreakTextItem } from "@/lib/text/layout"; export function TextInput() { - const editor = getEditor(); + const editor = useEditor(); const ref = useRef(null); const [isTextTool, setIsTextTool] = useState(false); @@ -59,7 +59,7 @@ export function TextInput() { textarea.value = ""; }; - const handleKeyDown = (e: React.KeyboardEvent) => { + const handleKeyDown = async (e: React.KeyboardEvent) => { const extend = e.shiftKey; const run = editor.textRun; @@ -140,15 +140,18 @@ export function TextInput() { case "v": if (e.metaKey || e.ctrlKey) { - navigator.clipboard?.readText().then((text) => { + e.preventDefault(); + try { + const text = (await navigator.clipboard?.readText()) ?? ""; for (const char of text) { const codepoint = char.codePointAt(0); if (codepoint !== undefined) { editor.insertTextCodepoint(codepoint); } } - }); - e.preventDefault(); + } catch (error) { + console.error("reading text clipboard failed", error); + } return; } break; diff --git a/apps/desktop/src/renderer/src/components/variation/AxesPanel.tsx b/apps/desktop/src/renderer/src/components/variation/AxesPanel.tsx index 8dbd2c78..ce0507e8 100644 --- a/apps/desktop/src/renderer/src/components/variation/AxesPanel.tsx +++ b/apps/desktop/src/renderer/src/components/variation/AxesPanel.tsx @@ -14,11 +14,12 @@ import { EditableSidebarInput } from "@/components/editor/sidebar-right/Editable import { useAxes } from "@/hooks/useAxes"; import { useDesignLocation } from "@/hooks/useDesignLocation"; import { axisValue, withAxisValue } from "@/lib/variation/location"; -import { getFont } from "@/store/appStore"; +import { useFont } from "@/workspace/WorkspaceContext"; import VerticalElipsis from "@/assets/vertical-ellipsis.svg"; export const AxesPanel = () => { + const font = useFont(); const axes = useAxes(); const [location, setDesignLocation] = useDesignLocation(); @@ -34,7 +35,7 @@ export const AxesPanel = () => { }; const deleteAxis = (axis: Axis) => { - getFont().deleteAxis(axis.id); + font.deleteAxis(axis.id); }; return ( diff --git a/apps/desktop/src/renderer/src/components/variation/CreateAxisDialog.tsx b/apps/desktop/src/renderer/src/components/variation/CreateAxisDialog.tsx index c9aef033..f9af73a1 100644 --- a/apps/desktop/src/renderer/src/components/variation/CreateAxisDialog.tsx +++ b/apps/desktop/src/renderer/src/components/variation/CreateAxisDialog.tsx @@ -11,7 +11,7 @@ import { X, cn, } from "@shift/ui"; -import { getFont } from "@/store/appStore"; +import { useFont } from "@/workspace/WorkspaceContext"; interface CreateAxisDialogProps { open: boolean; @@ -37,7 +37,7 @@ const DEFAULT_AXIS_VALUES: CreateAxisFormValues = { }; export const CreateAxisDialog = ({ open, onOpenChange }: CreateAxisDialogProps) => { - const font = getFont(); + const font = useFont(); const [values, setValues] = useState(DEFAULT_AXIS_VALUES); const updateValue = diff --git a/apps/desktop/src/renderer/src/components/variation/CreateSourceDialog.tsx b/apps/desktop/src/renderer/src/components/variation/CreateSourceDialog.tsx index 8670c9a0..bf1ab9ca 100644 --- a/apps/desktop/src/renderer/src/components/variation/CreateSourceDialog.tsx +++ b/apps/desktop/src/renderer/src/components/variation/CreateSourceDialog.tsx @@ -14,7 +14,7 @@ import { import type { Axis, AxisId, Source } from "@shift/types"; import { useAxes } from "@/hooks/useAxes"; import { useSources } from "@/hooks/useSources"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; interface CreateSourceDialogProps { open: boolean; @@ -51,7 +51,7 @@ function defaultValues(axes: readonly Axis[], sources: readonly Source[]): Creat } export const CreateSourceDialog = ({ open, onOpenChange }: CreateSourceDialogProps) => { - const editor = getEditor(); + const editor = useEditor(); const axes = useAxes(); const sources = useSources(); const [values, setValues] = useState(emptyValues); diff --git a/apps/desktop/src/renderer/src/components/variation/Sources.tsx b/apps/desktop/src/renderer/src/components/variation/Sources.tsx index bee0b1c4..f0b49fc6 100644 --- a/apps/desktop/src/renderer/src/components/variation/Sources.tsx +++ b/apps/desktop/src/renderer/src/components/variation/Sources.tsx @@ -10,7 +10,7 @@ import { import type { SourceId } from "@shift/types"; import { useSources } from "@/hooks/useSources"; import { useLayerSourceId } from "@/hooks/useLayerSourceId"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { SidebarActionRow } from "@/components/sidebar"; import VerticalElipsis from "@/assets/vertical-ellipsis.svg"; @@ -18,7 +18,7 @@ import VerticalElipsis from "@/assets/vertical-ellipsis.svg"; export const Sources = () => { const sources = useSources(); const layerSourceId = useLayerSourceId(); - const editor = getEditor(); + const editor = useEditor(); if (sources.length === 0) return null; diff --git a/apps/desktop/src/renderer/src/context/CanvasContext.tsx b/apps/desktop/src/renderer/src/context/CanvasContext.tsx index 475a8548..f83bee22 100644 --- a/apps/desktop/src/renderer/src/context/CanvasContext.tsx +++ b/apps/desktop/src/renderer/src/context/CanvasContext.tsx @@ -1,6 +1,6 @@ import { createContext, useEffect, useRef } from "react"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { CanvasRef } from "@/types/graphics"; import { Canvas2DSurface, MarkerCanvasSurface } from "@/lib/editor/rendering/CanvasSurface"; @@ -19,14 +19,13 @@ export const CanvasContext = createContext({ }); export const CanvasContextProvider = ({ children }: { children: React.ReactNode }) => { + const editor = useEditor(); const markerCanvasRef = useRef(null); const overlayCanvasRef = useRef(null); const sceneCanvasRef = useRef(null); const backgroundCanvasRef = useRef(null); useEffect(() => { - const editor = getEditor(); - const setUpContexts = ({ markerCanvas, overlayCanvas, @@ -92,7 +91,7 @@ export const CanvasContextProvider = ({ children }: { children: React.ReactNode }); return cleanup; - }, []); + }, [editor]); return ( { - const editor = getEditor(); const glyph = editor.glyph.peek(); if (!glyph) { return; @@ -37,11 +37,11 @@ export function DebugProvider({ children }: DebugProviderProps) { const json = JSON.stringify(glyph, null, 2); void navigator.clipboard?.writeText(json); - }, []); + }, [editor]); useEffect(() => { - getEditor().setDebugOverlays(overlays); - }, [overlays]); + editor.setDebugOverlays(overlays); + }, [editor, overlays]); if (!isDev) { return <>{children}; diff --git a/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx b/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx index 9863dcbe..4b23c7f4 100644 --- a/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx +++ b/apps/desktop/src/renderer/src/context/GlyphCatalogContext.tsx @@ -2,7 +2,8 @@ import { createContext, useContext, useMemo, useState, type ReactNode } from "re import type { GlyphCategory, GlyphCategoryCatalog, GlyphCategorySummary } from "@shift/glyph-info"; import type { GlyphId, GlyphName, GlyphRecord } from "@shift/types"; import { useSignalState } from "@/lib/signals"; -import { getEditor, getGlyphInfo } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; +import { getGlyphInfo } from "@/workspace/glyphInfo"; export type GlyphCatalogItem = | { @@ -46,8 +47,9 @@ export const useGlyphCatalog = (): GlyphCatalogState => { }; const useGlyphCatalogState = (): GlyphCatalogState => { + const editor = useEditor(); const glyphInfo = getGlyphInfo(); - const font = getEditor().font; + const font = editor.font; const fontLoaded = useSignalState(font.$loaded); const glyphRecords = useSignalState(font.glyphRecordsCell); @@ -123,7 +125,7 @@ const useGlyphCatalogState = (): GlyphCatalogState => { selectedSubCategoryKey, setQuery, createQuickGlyph: () => { - const record = getEditor().createGlyph("newGlyph" as GlyphName); + const record = editor.createGlyph("newGlyph" as GlyphName); setQuery(""); setSelectedCategory(null); setSelectedSubCategoryKey(null); diff --git a/apps/desktop/src/renderer/src/hooks/useAxes.ts b/apps/desktop/src/renderer/src/hooks/useAxes.ts index a2c5443b..fc279824 100644 --- a/apps/desktop/src/renderer/src/hooks/useAxes.ts +++ b/apps/desktop/src/renderer/src/hooks/useAxes.ts @@ -1,5 +1,5 @@ import type { Axis } from "@shift/types"; -import { getEditor } from "@/store/appStore"; +import { useFont } from "@/workspace/WorkspaceContext"; import { useSignalState } from "@/lib/signals"; /** @@ -8,6 +8,6 @@ import { useSignalState } from "@/lib/signals"; * design location, not the axis list, so they do not re-render subscribers. */ export const useAxes = (): Axis[] => { - const font = getEditor().font; + const font = useFont(); return useSignalState(font.axesCell); }; diff --git a/apps/desktop/src/renderer/src/hooks/useDesignLocation.ts b/apps/desktop/src/renderer/src/hooks/useDesignLocation.ts index eb556939..51f57a6e 100644 --- a/apps/desktop/src/renderer/src/hooks/useDesignLocation.ts +++ b/apps/desktop/src/renderer/src/hooks/useDesignLocation.ts @@ -1,10 +1,10 @@ import { useCallback } from "react"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { useSignalState } from "@/lib/signals"; import type { AxisLocation } from "@/types/variation"; export const useDesignLocation = (): [AxisLocation, (next: AxisLocation) => void] => { - const editor = getEditor(); + const editor = useEditor(); const location = useSignalState(editor.$designLocation); const setLocation = useCallback((next: AxisLocation) => editor.setDesignLocation(next), [editor]); diff --git a/apps/desktop/src/renderer/src/hooks/useDocumentChromeState.ts b/apps/desktop/src/renderer/src/hooks/useDocumentChromeState.ts index fdf03eed..558afd88 100644 --- a/apps/desktop/src/renderer/src/hooks/useDocumentChromeState.ts +++ b/apps/desktop/src/renderer/src/hooks/useDocumentChromeState.ts @@ -1,10 +1,10 @@ import { useMemo } from "react"; import { useSignalState } from "@/lib/signals"; -import { getEditQueue, getEditor, getWorkspace } from "@/store/appStore"; +import { useEditor, useWorkspace } from "@/workspace/WorkspaceContext"; import type { WorkspaceDocumentState } from "@shared/workspace/protocol"; -import type { WorkspaceCommitState } from "@/lib/workspace/WorkspaceEditQueue"; +import type { WorkspaceCommitState } from "@/lib/workspace/WorkspaceEditCoordinator"; export type DocumentActivity = "clean" | "editing" | "committing" | "dirty"; @@ -16,9 +16,11 @@ type DocumentChromeState = { }; export function useDocumentChromeState(): DocumentChromeState { - const documentState = useSignalState(getWorkspace().documentStateCell); - const isEditing = useSignalState(getEditor().isEditingCell); - const commitState = useSignalState(getEditQueue().commitStateCell); + const workspace = useWorkspace(); + const editor = useEditor(); + const documentState = useSignalState(workspace.documentStateCell); + const isEditing = useSignalState(editor.isEditingCell); + const commitState = useSignalState(workspace.commitStateCell); return useMemo(() => { const activity = activityForDocument(documentState, isEditing, commitState); diff --git a/apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts b/apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts index 3fb5e9f1..17fce7cf 100644 --- a/apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts +++ b/apps/desktop/src/renderer/src/hooks/useGlyphSidebearings.ts @@ -1,5 +1,5 @@ import type { GlyphSidebearings } from "@/lib/model/Glyph"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { useSignalState, useSignalTrigger } from "@/lib/signals"; const EMPTY_SIDEBEARINGS: GlyphSidebearings = { lsb: null, rsb: null }; @@ -19,7 +19,7 @@ export interface GlyphSidebearingsState { * @returns Current values and whether the displayed instance can be edited. */ export function useGlyphSidebearings(): GlyphSidebearingsState { - const editor = getEditor(); + const editor = useEditor(); const instance = useSignalState(editor.glyphInstanceCell); useSignalTrigger(instance?.sidebearingsCell, { schedule: "frame" }); diff --git a/apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts b/apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts index d8aede8d..837b8869 100644 --- a/apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts +++ b/apps/desktop/src/renderer/src/hooks/useGlyphXAdvance.ts @@ -1,4 +1,4 @@ -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { useSignalState, useSignalTrigger } from "@/lib/signals"; export interface GlyphXAdvanceState { @@ -10,7 +10,7 @@ export interface GlyphXAdvanceState { * Current glyph xAdvance, live-updating. Returns `0` when no glyph is loaded. */ export function useGlyphXAdvance(): GlyphXAdvanceState { - const editor = getEditor(); + const editor = useEditor(); const instance = useSignalState(editor.glyphInstanceCell); useSignalTrigger(instance?.xAdvanceCell, { schedule: "frame" }); diff --git a/apps/desktop/src/renderer/src/hooks/useLayerSourceId.ts b/apps/desktop/src/renderer/src/hooks/useLayerSourceId.ts index 1b9d7585..41bf23f6 100644 --- a/apps/desktop/src/renderer/src/hooks/useLayerSourceId.ts +++ b/apps/desktop/src/renderer/src/hooks/useLayerSourceId.ts @@ -1,6 +1,7 @@ -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { useSignalState } from "@/lib/signals"; export const useLayerSourceId = (): string | null => { - return useSignalState(getEditor().$layerSourceId); + const editor = useEditor(); + return useSignalState(editor.$layerSourceId); }; diff --git a/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts b/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts index 82111acb..af2e8094 100644 --- a/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts +++ b/apps/desktop/src/renderer/src/hooks/useSelectionBounds.ts @@ -1,5 +1,5 @@ import type { Bounds } from "@shift/geo"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { useSignalState } from "@/lib/signals"; /** @@ -16,6 +16,6 @@ import { useSignalState } from "@/lib/signals"; * unavailable or nothing is selected. */ export function useSelectionBounds(): Bounds | null { - const editor = getEditor(); + const editor = useEditor(); return useSignalState(editor.selection.boundsCell, { schedule: "frame" }); } diff --git a/apps/desktop/src/renderer/src/hooks/useSources.ts b/apps/desktop/src/renderer/src/hooks/useSources.ts index 57db4fcf..0a57a098 100644 --- a/apps/desktop/src/renderer/src/hooks/useSources.ts +++ b/apps/desktop/src/renderer/src/hooks/useSources.ts @@ -1,5 +1,5 @@ import type { Source } from "@shift/types"; -import { getEditor } from "@/store/appStore"; +import { useFont } from "@/workspace/WorkspaceContext"; import { useSignalState } from "@/lib/signals"; /** @@ -8,6 +8,6 @@ import { useSignalState } from "@/lib/signals"; * (filled with axis defaults), so callers can index it directly by tag. */ export const useSources = (): Source[] => { - const font = getEditor().font; + const font = useFont(); return useSignalState(font.sourcesCell); }; diff --git a/apps/desktop/src/renderer/src/lib/editor/Editor.ts b/apps/desktop/src/renderer/src/lib/editor/Editor.ts index 69ab8031..4aa9147b 100644 --- a/apps/desktop/src/renderer/src/lib/editor/Editor.ts +++ b/apps/desktop/src/renderer/src/lib/editor/Editor.ts @@ -844,11 +844,11 @@ export class Editor { public undo() { // One undo authority: the workspace ledger (state-pair replay). - void this.font.editQueue.undo(); + void this.font.editCoordinator.undo(); } public redo() { - void this.font.editQueue.redo(); + void this.font.editCoordinator.redo(); } public setCameraRect(rect: Rect2D) { diff --git a/apps/desktop/src/renderer/src/lib/model/Font.test.ts b/apps/desktop/src/renderer/src/lib/model/Font.test.ts index c8f86ae1..fbfe45b5 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.test.ts @@ -186,7 +186,7 @@ describe("font-level intents make the font variable", () => { expect(record.layers).toHaveLength(1); expect(record.layers[0]?.sourceId).toBe(source.id); - await stack.editQueue.settled(); + await stack.editCoordinator.settled(); const committed = stack.font.recordForId(record.id); expect(committed?.layers).toEqual(record.layers); diff --git a/apps/desktop/src/renderer/src/lib/model/Font.ts b/apps/desktop/src/renderer/src/lib/model/Font.ts index dfee79c6..be1d5e54 100644 --- a/apps/desktop/src/renderer/src/lib/model/Font.ts +++ b/apps/desktop/src/renderer/src/lib/model/Font.ts @@ -16,7 +16,7 @@ import type { } from "@shift/types"; import { mintAxisId, mintGlyphId, mintLayerId, mintSourceId } from "@shift/types"; import { computed, type Signal } from "@/lib/signals/signal"; -import type { WorkspaceEditQueue } from "@/lib/workspace/WorkspaceEditQueue"; +import type { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; import type { WorkspaceSnapshot } from "@shared/workspace/protocol"; import { Glyph, type GlyphLayer } from "./Glyph"; import { GlyphOutline } from "./GlyphOutline"; @@ -300,20 +300,23 @@ export class Font { /** Open glyph models keyed by stable id; survives directory re-keys. */ readonly #glyphsById = new Map(); readonly #glyphLayers = new Map(); - readonly #editQueue: WorkspaceEditQueue | null; + readonly #editCoordinator: WorkspaceEditCoordinator | null; #cachesKeyedTo: GlyphDirectory | null = null; /** * Projects the renderer's workspace snapshot into the font domain model. * * @param $workspace - Single source of workspace truth owned by - * `WorkspaceSession`. There is no load: every derived value follows this + * `WorkspaceClient`. There is no load: every derived value follows this * signal, and `null` means no font is open. - * @param editQueue - Optional queue used by authored layer projections to submit + * @param editCoordinator - Optional queue used by authored layer projections to submit * committed edits to the utility workspace. */ - constructor($workspace: Signal, editQueue?: WorkspaceEditQueue) { - this.#editQueue = editQueue ?? null; + constructor( + $workspace: Signal, + editCoordinator?: WorkspaceEditCoordinator, + ) { + this.#editCoordinator = editCoordinator ?? null; this.#$loaded = computed(() => $workspace.value !== null); this.#$metrics = computed(() => $workspace.value?.metrics ?? DEFAULT_FONT_METRICS); this.#$metadata = computed(() => $workspace.value?.metadata ?? {}); @@ -498,7 +501,7 @@ export class Font { * @throws {Error} always — glyph mutations return with workspace change sets. */ updateGlyphIdentity(glyphId: GlyphId, newName: GlyphName, newUnicodes: Unicode[]): void { - this.editQueue.push({ + this.editCoordinator.push({ kind: "updateGlyph", updateGlyph: { glyphId, newName, newUnicodes }, }); @@ -526,11 +529,11 @@ export class Font { const layerId = mintLayerId(); const sourceId = this.defaultSource.id; - this.editQueue.push({ + this.editCoordinator.push({ kind: "createGlyph", createGlyph: { glyphId, name: finalName, unicodes }, }); - this.editQueue.push({ + this.editCoordinator.push({ kind: "createGlyphLayer", createGlyphLayer: { layerId, glyphId, sourceId }, }); @@ -558,7 +561,7 @@ export class Font { */ createGlyphLayer(glyphId: GlyphId, sourceId: SourceId): LayerId { const layerId = mintLayerId(); - this.editQueue.push({ + this.editCoordinator.push({ kind: "createGlyphLayer", createGlyphLayer: { layerId, glyphId, sourceId }, }); @@ -821,12 +824,12 @@ export class Font { * @throws {Error} when constructed without a workspace (pure projection * tests) — same not-wired contract as the legacy bridge getter. */ - get editQueue(): WorkspaceEditQueue { - if (!this.#editQueue) { + get editCoordinator(): WorkspaceEditCoordinator { + if (!this.#editCoordinator) { throw new Error("editing is not wired to the workspace yet"); } - return this.#editQueue; + return this.#editCoordinator; } /** @@ -848,7 +851,7 @@ export class Font { const cached = this.#glyphsById.get(glyphId); if (cached) return cached; - const state = await this.editQueue.layer(layer.id); + const state = await this.editCoordinator.layer(layer.id); if (!state) return null; const handle = directory.glyphHandleForName(record.name); @@ -880,7 +883,7 @@ export class Font { const cached = this.glyphLayer(glyph.handle, source); if (cached) return cached; - const state = await this.editQueue.layer(layer.id); + const state = await this.editCoordinator.layer(layer.id); if (!state) return null; const glyphLayer = glyph.createGlyphLayer(source, state); @@ -892,7 +895,7 @@ export class Font { createSource(name: string, location: Location): SourceId { const sourceId = mintSourceId(); - this.editQueue.push({ + this.editCoordinator.push({ kind: "createSource", createSource: { sourceId, name, location }, }); @@ -910,7 +913,7 @@ export class Font { hidden: boolean = false, ): AxisId { const axisId = mintAxisId(); - this.editQueue.push({ + this.editCoordinator.push({ kind: "createAxis", createAxis: { axisId, name, tag, min, default: def, max, hidden }, }); @@ -920,14 +923,14 @@ export class Font { /** @knipclassignore — used by VariationPanel component */ deleteAxis(axisId: AxisId): void { - this.editQueue.push({ + this.editCoordinator.push({ kind: "deleteAxis", deleteAxis: { axisId }, }); } deleteSource(sourceId: SourceId): void { - this.editQueue.push({ + this.editCoordinator.push({ kind: "deleteSource", deleteSource: { sourceId }, }); diff --git a/apps/desktop/src/renderer/src/lib/model/Glyph.ts b/apps/desktop/src/renderer/src/lib/model/Glyph.ts index 15e70dfa..fe816fde 100644 --- a/apps/desktop/src/renderer/src/lib/model/Glyph.ts +++ b/apps/desktop/src/renderer/src/lib/model/Glyph.ts @@ -114,7 +114,7 @@ class GlyphEditSession { readonly #state: GlyphEditState; constructor(font: Font, layerId: LayerId, state: GlyphEditState) { - this.#intents = new LayerIntents(font.editQueue, layerId); + this.#intents = new LayerIntents(font.editCoordinator, layerId); this.#state = state; } @@ -155,7 +155,7 @@ class GlyphEditSession { } } - // Mixed patches push two intents in the same tick; the editQueue coalesces + // Mixed patches push two intents in the same tick; the editCoordinator coalesces // them into one apply and therefore one undo step. if (pointIds.length > 0) { this.#intents.movePoints({ pointIds, coords: pointCoords }); @@ -1357,7 +1357,7 @@ export class Glyph { if (glyphId) { // Echoes (apply/undo/redo) fold into this session's state by layerId. - font.editQueue.register(state.layerId, { + font.editCoordinator.register(state.layerId, { state: this.#layerState, }); } @@ -1527,7 +1527,7 @@ export class Glyph { if (this.#glyphId) { // Echoes for this source's layer fold here, same as the primary. - this.#font.editQueue.register(state.layerId, { + this.#font.editCoordinator.register(state.layerId, { state: layerState, }); } diff --git a/apps/desktop/src/renderer/src/lib/model/variation.test.ts b/apps/desktop/src/renderer/src/lib/model/variation.test.ts index 9796540e..ffdfb5dc 100644 --- a/apps/desktop/src/renderer/src/lib/model/variation.test.ts +++ b/apps/desktop/src/renderer/src/lib/model/variation.test.ts @@ -156,11 +156,11 @@ describe("variable editing across sources", () => { const point = boldSource.allPoints[1]!; boldSource.commitPositionPatch([{ kind: "point", id: point.id, x: 250, y: 0 }]); - await stack.editQueue.settled(); + await stack.editCoordinator.settled(); expect(boldSource.point(point.id)).toMatchObject({ x: 250, y: 0 }); - const undone = await stack.editQueue.undo(); + const undone = await stack.editCoordinator.undo(); expect(undone).not.toBeNull(); expect(boldSource.point(point.id)).toMatchObject({ x: 200, y: 0 }); }); diff --git a/apps/desktop/src/renderer/src/lib/signals/useSignal.ts b/apps/desktop/src/renderer/src/lib/signals/useSignal.ts index 01be2cb2..9300531f 100644 --- a/apps/desktop/src/renderer/src/lib/signals/useSignal.ts +++ b/apps/desktop/src/renderer/src/lib/signals/useSignal.ts @@ -21,7 +21,7 @@ function scheduleFrame(execute: () => void): void { * with proper subscriptions (no polling). * * @example - * const editor = getEditor(); + * const editor = useEditor(); * const activeTool = useSignalState(editor.activeTool); */ export function useSignalState(signal: Signal, options?: UseSignalOptions): T { diff --git a/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts index 90c0f321..f7b3470f 100644 --- a/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts +++ b/apps/desktop/src/renderer/src/lib/text/layout/testUtils.ts @@ -57,7 +57,7 @@ export async function layoutTestFont(): Promise { a.addOnCurvePoint(contourId, { x: 100, y: 0 }); a.addOnCurvePoint(contourId, { x: 50, y: 100 }); a.closeContour(contourId); - await stack.editQueue.settled(); + await stack.editCoordinator.settled(); return stack.font; } diff --git a/apps/desktop/src/renderer/src/lib/workspace/LayerIntents.ts b/apps/desktop/src/renderer/src/lib/workspace/LayerIntents.ts index 949bcfb7..1fc8007b 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/LayerIntents.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/LayerIntents.ts @@ -14,7 +14,7 @@ import type { SetXAdvanceIntent, TranslatePointsIntent, } from "@shift/types"; -import type { WorkspaceEditQueue } from "./WorkspaceEditQueue"; +import type { WorkspaceEditCoordinator } from "./WorkspaceEditCoordinator"; /** An intent payload minus the layer id this channel already carries. */ type Payload = Omit; @@ -30,97 +30,100 @@ type Payload = Omit; * for the ledger, not the renderer. */ export class LayerIntents { - readonly #editQueue: WorkspaceEditQueue; + readonly #editCoordinator: WorkspaceEditCoordinator; readonly #layerId: LayerId; - constructor(editQueue: WorkspaceEditQueue, layerId: LayerId) { - this.#editQueue = editQueue; + constructor(editCoordinator: WorkspaceEditCoordinator, layerId: LayerId) { + this.#editCoordinator = editCoordinator; this.#layerId = layerId; } addPoints(payload: Payload): void { - this.#editQueue.push({ kind: "addPoints", addPoints: { layerId: this.#layerId, ...payload } }); + this.#editCoordinator.push({ + kind: "addPoints", + addPoints: { layerId: this.#layerId, ...payload }, + }); } addContour(payload: Payload): void { - this.#editQueue.push({ + this.#editCoordinator.push({ kind: "addContour", addContour: { layerId: this.#layerId, ...payload }, }); } setContourClosed(payload: Payload): void { - this.#editQueue.push({ + this.#editCoordinator.push({ kind: "setContourClosed", setContourClosed: { layerId: this.#layerId, ...payload }, }); } movePoints(payload: Payload): void { - this.#editQueue.push({ + this.#editCoordinator.push({ kind: "movePoints", movePoints: { layerId: this.#layerId, ...payload }, }); } setPointSmooth(payload: Payload): void { - this.#editQueue.push({ + this.#editCoordinator.push({ kind: "setPointSmooth", setPointSmooth: { layerId: this.#layerId, ...payload }, }); } removePoints(payload: Payload): void { - this.#editQueue.push({ + this.#editCoordinator.push({ kind: "removePoints", removePoints: { layerId: this.#layerId, ...payload }, }); } reverseContour(payload: Payload): void { - this.#editQueue.push({ + this.#editCoordinator.push({ kind: "reverseContour", reverseContour: { layerId: this.#layerId, ...payload }, }); } translatePoints(payload: Payload): void { - this.#editQueue.push({ + this.#editCoordinator.push({ kind: "translatePoints", translatePoints: { layerId: this.#layerId, ...payload }, }); } setXAdvance(payload: Payload): void { - this.#editQueue.push({ + this.#editCoordinator.push({ kind: "setXAdvance", setXAdvance: { layerId: this.#layerId, ...payload }, }); } applyBooleanOp(payload: Payload): void { - this.#editQueue.push({ + this.#editCoordinator.push({ kind: "applyBooleanOp", applyBooleanOp: { layerId: this.#layerId, ...payload }, }); } addAnchors(payload: Payload): void { - this.#editQueue.push({ + this.#editCoordinator.push({ kind: "addAnchors", addAnchors: { layerId: this.#layerId, ...payload }, }); } moveAnchors(payload: Payload): void { - this.#editQueue.push({ + this.#editCoordinator.push({ kind: "moveAnchors", moveAnchors: { layerId: this.#layerId, ...payload }, }); } removeAnchors(payload: Payload): void { - this.#editQueue.push({ + this.#editCoordinator.push({ kind: "removeAnchors", removeAnchors: { layerId: this.#layerId, ...payload }, }); diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceSession.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts similarity index 75% rename from apps/desktop/src/renderer/src/lib/workspace/WorkspaceSession.ts rename to apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts index 41fdbc9e..bfcb3a40 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceSession.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceClient.ts @@ -18,7 +18,7 @@ import { signal } from "@/lib/signals/signal"; * disconnected/empty/crashed — it becomes a tagged union when recovery lands, * so do not derive connectedness from it. */ -export type WorkspaceSessionOptions = { +export type WorkspaceClientOptions = { /** * Test seam: supplies the sync-lane transport directly (in-process * WorkspaceHost over node ports). Production uses the preload port relay. @@ -26,37 +26,37 @@ export type WorkspaceSessionOptions = { transport?: () => Promise; }; -export class WorkspaceSession { +export class WorkspaceClient { readonly workspaceCell = signal(null); readonly documentStateCell = signal(null); readonly #host: ShiftHost | null; readonly #transport: (() => Promise) | null; #channel: Channel | null = null; - #connected: Promise | null = null; + #connection: Promise | null = null; - constructor(host: ShiftHost | null, options: WorkspaceSessionOptions = {}) { + constructor(host: ShiftHost | null, options: WorkspaceClientOptions = {}) { this.#host = host; this.#transport = options.transport ?? null; } /** - * Connects this renderer runtime to its bound workspace. - * - * @remarks - * A failed attempt clears the memo so the next call retries instead of - * re-awaiting a cached rejection for the rest of the session. + * Connects this renderer client to its bound workspace. */ - connected(): Promise { - if (!this.#connected) { - const attempt = this.#connect(); - this.#connected = attempt; - attempt.catch(() => { - if (this.#connected === attempt) this.#connected = null; - }); + connect(): Promise { + if (!this.#connection) { + this.#connection = this.#connect(); } - return this.#connected; + return this.#connection; + } + + dispose(): void { + this.#channel?.dispose(); + this.#channel = null; + this.#connection = null; + this.workspaceCell.set(null); + this.documentStateCell.set(null); } /** @@ -64,11 +64,11 @@ export class WorkspaceSession { * * @remarks * The record fold happens here (directory follows `$workspace`); layer - * folds belong to the glyph model and land with the CS3 WorkspaceEditQueue — + * folds belong to the glyph model and land with the CS3 WorkspaceEditCoordinator — * callers receive the AppliedChange to fold geometry themselves until then. */ async apply(intents: FontIntent[]): Promise { - await this.connected(); + await this.connect(); const { applied, documentState } = await this.#require().call("workspace.apply", { intents }); this.#setDocumentState(documentState); @@ -77,7 +77,7 @@ export class WorkspaceSession { /** Replays the latest ledger entry; null when nothing is undoable. */ async undo(): Promise { - await this.connected(); + await this.connect(); const { applied, documentState } = await this.#require().call("workspace.undo", undefined); this.documentStateCell.set(documentState); @@ -86,7 +86,7 @@ export class WorkspaceSession { /** Replays the latest undone entry; null when nothing is redoable. */ async redo(): Promise { - await this.connected(); + await this.connect(); const { applied, documentState } = await this.#require().call("workspace.redo", undefined); this.documentStateCell.set(documentState); @@ -95,7 +95,7 @@ export class WorkspaceSession { /** Reads utility-owned document state through the renderer sync lane. */ async documentState(): Promise { - await this.connected(); + await this.connect(); const state = await this.#require().call("document.state", undefined); this.documentStateCell.set(state); @@ -104,21 +104,21 @@ export class WorkspaceSession { /** Saves to the current target; rejects when the document still needs a path. */ async save(): Promise { - await this.connected(); + await this.connect(); return this.#setDocumentState(await this.#require().call("workspace.save", undefined)); } /** Saves to `path` and adopts it as the document's target. */ async saveAs(path: string): Promise { - await this.connected(); + await this.connect(); return this.#setDocumentState(await this.#require().call("workspace.saveAs", { path })); } /** Pulls replace-grade glyph state by stable layer id. */ async layer(layerId: LayerId): Promise { - await this.connected(); + await this.connect(); return this.#require().call("workspace.layer", { layerId }); } @@ -140,35 +140,40 @@ export class WorkspaceSession { } async #connect(): Promise { - if (this.#transport) { - const channel = new Channel(await this.#transport()); + try { + if (this.#transport) { + const channel = new Channel(await this.#transport()); + this.#installChannel(channel); + this.workspaceCell.set(await channel.call("workspace.snapshot", undefined)); + this.documentStateCell.set(await channel.call("document.state", undefined)); + return; + } + + if (!this.#host) { + throw new Error("WorkspaceClient needs a ShiftHost or a transport option"); + } + + // Install the port listener before asking main to post the port. + const port = this.#nextWorkspacePort(); + + try { + await this.#host.workspace.connect(); + } catch (error) { + port.cancel(); + throw error; + } + + const channel = new Channel(domPortTransport(await port.received)); this.#installChannel(channel); + + // Catch-up pull: covers renderer reattach (Vite hot reload now, crash + // recovery later). Ports are FIFO, so this cannot overtake a later create. this.workspaceCell.set(await channel.call("workspace.snapshot", undefined)); this.documentStateCell.set(await channel.call("document.state", undefined)); - return; - } - - if (!this.#host) { - throw new Error("WorkspaceSession needs a ShiftHost or a transport option"); - } - - // Install the port listener before asking main to post the port. - const port = this.#nextWorkspacePort(); - - try { - await this.#host.workspace.connect(); } catch (error) { - port.cancel(); + this.#connection = null; throw error; } - - const channel = new Channel(domPortTransport(await port.received)); - this.#installChannel(channel); - - // Catch-up pull: covers renderer reattach (Vite hot reload now, crash - // recovery later). Ports are FIFO, so this cannot overtake a later create. - this.workspaceCell.set(await channel.call("workspace.snapshot", undefined)); - this.documentStateCell.set(await channel.call("document.state", undefined)); } #installChannel(channel: Channel): void { diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.test.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts similarity index 62% rename from apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.test.ts rename to apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts index 0491f750..a94208bb 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.test.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.test.ts @@ -12,7 +12,7 @@ const createGlyph = (name: string, unicode: number): FontIntent => ({ const savePath = (): string => join(mkdtempSync(join(tmpdir(), "shift-save-")), "Saved.shift"); -describe("WorkspaceEditQueue issues save on the committed-op lane", () => { +describe("WorkspaceEditCoordinator issues save on the committed-op lane", () => { let stack: WorkspaceStack; beforeEach(async () => { @@ -21,35 +21,35 @@ describe("WorkspaceEditQueue issues save on the committed-op lane", () => { }); it("flushes queued edits before the save so the write includes them", async () => { - const { client, editQueue } = stack; + const { client, editCoordinator } = stack; - editQueue.push(createGlyph("A", 65)); // queued, not yet applied - const saved = await editQueue.save(savePath()); // flushes the push, then saves behind it + editCoordinator.push(createGlyph("A", 65)); // queued, not yet applied + const saved = await editCoordinator.save(savePath()); // flushes the push, then saves behind it expect(client.workspaceCell.peek()?.glyphs).toHaveLength(1); // the apply was folded expect(saved).toMatchObject({ dirty: false, needsSaveAs: false }); }); it("a current-target save serializes behind a later edit", async () => { - const { client, editQueue } = stack; - await editQueue.save(savePath()); // adopt a package target + const { client, editCoordinator } = stack; + await editCoordinator.save(savePath()); // adopt a package target - editQueue.push(createGlyph("B", 66)); - const saved = await editQueue.save(null); // null = save to current target + editCoordinator.push(createGlyph("B", 66)); + const saved = await editCoordinator.save(null); // null = save to current target expect(client.workspaceCell.peek()?.glyphs).toHaveLength(1); expect(saved).toMatchObject({ dirty: false }); }); it("marks locally committed edits as queued until the utility echo settles", async () => { - const { client, editQueue } = stack; - await editQueue.save(savePath()); + const { client, editCoordinator } = stack; + await editCoordinator.save(savePath()); - editQueue.push(createGlyph("C", 67)); + editCoordinator.push(createGlyph("C", 67)); - expect(editQueue.commitStateCell.peek()).toBe("queued"); - await editQueue.settled(); - expect(editQueue.commitStateCell.peek()).toBe("idle"); + expect(editCoordinator.commitStateCell.peek()).toBe("queued"); + await editCoordinator.settled(); + expect(editCoordinator.commitStateCell.peek()).toBe("idle"); expect(client.documentStateCell.peek()).toMatchObject({ dirty: true }); }); }); diff --git a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.ts b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts similarity index 97% rename from apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.ts rename to apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts index d8f28ed8..29158de7 100644 --- a/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditQueue.ts +++ b/apps/desktop/src/renderer/src/lib/workspace/WorkspaceEditCoordinator.ts @@ -2,7 +2,7 @@ import type { AppliedChange, FontIntent, GlyphState, LayerId } from "@shift/type import type { WorkspaceDocumentState } from "@shared/workspace/protocol"; import { signal, type Signal } from "@/lib/signals/signal"; import type { GlyphLayerState } from "@/lib/model/GlyphLayerState"; -import type { WorkspaceSession } from "./WorkspaceSession"; +import type { WorkspaceClient } from "./WorkspaceClient"; export type WorkspaceCommitState = "idle" | "queued" | "applying"; @@ -28,8 +28,8 @@ type FoldTarget = { * utility serializes the write behind every committed edit with no cross-lane * watermark. */ -export class WorkspaceEditQueue { - readonly #workspace: WorkspaceSession; +export class WorkspaceEditCoordinator { + readonly #workspace: WorkspaceClient; readonly #targets = new Map(); readonly #settledCell = signal(true); readonly #commitState = signal("idle", { @@ -41,7 +41,7 @@ export class WorkspaceEditQueue { #chain: Promise = Promise.resolve(); #busy = 0; - constructor(workspace: WorkspaceSession) { + constructor(workspace: WorkspaceClient) { this.#workspace = workspace; } diff --git a/apps/desktop/src/renderer/src/store/appStore.ts b/apps/desktop/src/renderer/src/store/appStore.ts deleted file mode 100644 index 415784bb..00000000 --- a/apps/desktop/src/renderer/src/store/appStore.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Editor } from "@/lib/editor/Editor"; -import { electronSystemClipboard } from "@/lib/clipboard"; -import { registerBuiltInTools } from "@/lib/tools/tools"; -import { defaultResources, GlyphInfo } from "@shift/glyph-info"; -import { Font } from "@/lib/model/Font"; -import { WorkspaceSession } from "@/lib/workspace/WorkspaceSession"; -import { WorkspaceEditQueue } from "@/lib/workspace/WorkspaceEditQueue"; -import { getShiftHost } from "@/host/shiftHost"; -import type { DocumentCallMap, DocumentEventMap } from "@shared/ipc/contract"; -import { domPortTransport, serveChannel, type ChannelServer } from "@shared/workspace/channel"; - -let instance: GlyphInfo | null = null; -export function getGlyphInfo(): GlyphInfo { - if (!instance) instance = new GlyphInfo(defaultResources); - return instance; -} - -const workspace = new WorkspaceSession(getShiftHost()); -const editQueue = new WorkspaceEditQueue(workspace); -const font = new Font(workspace.workspaceCell, editQueue); -const editor = new Editor({ font, clipboard: electronSystemClipboard }); -registerBuiltInTools(editor); - -// Set select tool as ready on startup -editor.setActiveTool("select"); - -const host = getShiftHost(); -let documentRequests: ChannelServer | null = null; -let workspaceRuntimeLoad: Promise | null = null; - -export function connectWorkspaceRuntime(): Promise { - if (!workspaceRuntimeLoad) { - workspaceRuntimeLoad = loadWorkspaceRuntime(); - } - - return workspaceRuntimeLoad; -} - -async function loadWorkspaceRuntime(): Promise { - try { - await workspace.connected(); - - const snapshot = workspace.workspaceCell.peek(); - if (!snapshot) { - throw new Error("workspace connected without a snapshot"); - } - - await serveDocumentRequests(); - } catch (error) { - workspaceRuntimeLoad = null; - throw error; - } -} - -async function serveDocumentRequests(): Promise { - const port = nextDocumentPort(); - - try { - await host.document.connect(); - } catch (error) { - port.cancel(); - throw error; - } - - documentRequests?.dispose(); - documentRequests = serveChannel( - domPortTransport(await port.received), - { - "document.state": () => editQueue.state(), - "document.save": ({ path }) => editQueue.save(path), - }, - ); -} - -function nextDocumentPort(): { received: Promise; cancel: () => void } { - let cancel = () => {}; - - const received = new Promise((resolve) => { - const listener = (event: MessageEvent) => { - if (event.origin !== window.location.origin) return; - if ((event.data as { type?: string } | null)?.type !== "document.port") return; - - const port = event.ports[0]; - if (!port) return; - - window.removeEventListener("message", listener); - resolve(port); - }; - - cancel = () => window.removeEventListener("message", listener); - window.addEventListener("message", listener); - }); - - return { received, cancel }; -} - -export const getWorkspace = () => workspace; -export const getEditQueue = () => editQueue; -export const getEditor = () => editor; -export const getFont = () => font; - -// Expose editor on window for Playwright E2E tests. -declare const __PLAYWRIGHT__: boolean | undefined; -if (typeof __PLAYWRIGHT__ !== "undefined" && __PLAYWRIGHT__) { - (window as unknown as Record).__shift = { - getEditor, - getWorkspace, - getFont, - }; -} diff --git a/apps/desktop/src/renderer/src/testing/TestEditor.ts b/apps/desktop/src/renderer/src/testing/TestEditor.ts index 7b51f0ae..e0aea15a 100644 --- a/apps/desktop/src/renderer/src/testing/TestEditor.ts +++ b/apps/desktop/src/renderer/src/testing/TestEditor.ts @@ -107,19 +107,19 @@ export class TestEditor extends Editor { /** Awaits every queued and in-flight apply; geometry reads confirmed truth after. */ async settle(): Promise { - await this.font.editQueue.settled(); + await this.font.editCoordinator.settled(); return this; } /** Undo through the one authority (workspace ledger), settled. */ async undoAndSettle(): Promise { - await this.font.editQueue.undo(); + await this.font.editCoordinator.undo(); return this; } /** Redo through the workspace ledger, settled. */ async redoAndSettle(): Promise { - await this.font.editQueue.redo(); + await this.font.editCoordinator.redo(); return this; } diff --git a/apps/desktop/src/renderer/src/testing/workspaceStack.ts b/apps/desktop/src/renderer/src/testing/workspaceStack.ts index a9c3de05..e761cea2 100644 --- a/apps/desktop/src/renderer/src/testing/workspaceStack.ts +++ b/apps/desktop/src/renderer/src/testing/workspaceStack.ts @@ -5,13 +5,13 @@ import { MessageChannel, type MessagePort as NodeMessagePort } from "node:worker import { Channel, nodePortTransport } from "@shared/workspace/channel"; import type { ShellCallMap, ShellEventMap } from "@shared/workspace/protocol"; import { WorkspaceHost } from "../../../utility/workspace/WorkspaceHost"; -import { WorkspaceSession } from "@/lib/workspace/WorkspaceSession"; -import { WorkspaceEditQueue } from "@/lib/workspace/WorkspaceEditQueue"; +import { WorkspaceClient } from "@/lib/workspace/WorkspaceClient"; +import { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; import { Font } from "@/lib/model/Font"; export type WorkspaceStack = { - client: WorkspaceSession; - editQueue: WorkspaceEditQueue; + client: WorkspaceClient; + editCoordinator: WorkspaceEditCoordinator; font: Font; createWorkspace(): Promise; }; @@ -19,7 +19,7 @@ export type WorkspaceStack = { /** * The full production editing stack, in-process: real WorkspaceHost (real * NAPI, real SQLite in a temp dir) served over real node MessagePorts, with - * the real client/editQueue/font wiring. No Electron, no mocks — the same + * the real client/editCoordinator/font wiring. No Electron, no mocks — the same * pattern as WorkspaceHost.test.ts, extended to the renderer side. */ export function createWorkspaceStack(): WorkspaceStack { @@ -33,23 +33,23 @@ export function createWorkspaceStack(): WorkspaceStack { }).start(); const shell = new Channel(nodePortTransport(shellLane.port1)); - const client = new WorkspaceSession(null, { + const client = new WorkspaceClient(null, { transport: async () => { const lane = new MessageChannel(); await shell.call("workspace.connect", undefined, [lane.port1]); return nodePortTransport(lane.port2); }, }); - const editQueue = new WorkspaceEditQueue(client); - const font = new Font(client.workspaceCell, editQueue); + const editCoordinator = new WorkspaceEditCoordinator(client); + const font = new Font(client.workspaceCell, editCoordinator); return { client, - editQueue, + editCoordinator, font, async createWorkspace(): Promise { await shell.call("workspace.create", undefined); - await client.connected(); + await client.connect(); if (!client.workspaceCell.peek()) { throw new Error("workspace stack connected without a snapshot"); diff --git a/apps/desktop/src/renderer/src/views/Editor.tsx b/apps/desktop/src/renderer/src/views/Editor.tsx index 4f39e465..52f715a3 100644 --- a/apps/desktop/src/renderer/src/views/Editor.tsx +++ b/apps/desktop/src/renderer/src/views/Editor.tsx @@ -8,7 +8,7 @@ import { LeftSidebar } from "@/components/editor/LeftSidebar"; import { RightSidebar } from "@/components/editor/RightSidebar"; import { GlyphFinder } from "@/components/editor/GlyphFinder"; import { Canvas } from "@/components/editor/Canvas"; -import { getEditor } from "@/store/appStore"; +import { useEditor } from "@/workspace/WorkspaceContext"; import { useFocusZone, ZoneContainer } from "@/context/FocusZoneContext"; import { useSignalState } from "@/lib/signals"; import { KeyboardRouter } from "@/lib/keyboard"; @@ -17,7 +17,7 @@ import type { Unicode } from "@shift/types"; export const Editor = () => { const { glyphId } = useParams(); - const editor = getEditor(); + const editor = useEditor(); const { activeZone } = useFocusZone(); @@ -50,7 +50,6 @@ export const Editor = () => { ); useEffect(() => { - const editor = getEditor(); const toolManager = editor.toolManager; const keyboardRouter = new KeyboardRouter(() => ({ canvasActive: activeZone === "canvas" || toolManager.isDragging, @@ -74,12 +73,11 @@ export const Editor = () => { document.removeEventListener("keydown", keyDownHandler); document.removeEventListener("keyup", keyUpHandler); }; - }, [glyphId, activeZone]); + }, [glyphId, activeZone, editor]); useEffect(() => { - const editor = getEditor(); editor.setZone(activeZone); - }, [activeZone]); + }, [activeZone, editor]); if (!glyphId) return null; diff --git a/apps/desktop/src/renderer/src/workspace/Workspace.ts b/apps/desktop/src/renderer/src/workspace/Workspace.ts new file mode 100644 index 00000000..78ce4b2f --- /dev/null +++ b/apps/desktop/src/renderer/src/workspace/Workspace.ts @@ -0,0 +1,76 @@ +import type { ShiftHost } from "@shared/host/ShiftHost"; +import type { WorkspaceDocumentState } from "@shared/workspace/protocol"; +import type { SystemClipboard } from "@/lib/clipboard"; +import { Editor } from "@/lib/editor/Editor"; +import { Font } from "@/lib/model/Font"; +import { registerBuiltInTools } from "@/lib/tools/tools"; +import { WorkspaceClient } from "@/lib/workspace/WorkspaceClient"; +import { + WorkspaceEditCoordinator, + type WorkspaceCommitState, +} from "@/lib/workspace/WorkspaceEditCoordinator"; +import type { Signal } from "@/lib/signals/signal"; +import { WorkspaceDocumentBridge } from "./WorkspaceDocumentBridge"; + +export interface WorkspaceOptions { + readonly host: ShiftHost; + readonly clipboard: SystemClipboard; +} + +export class Workspace { + readonly #client: WorkspaceClient; + readonly #edits: WorkspaceEditCoordinator; + readonly #documentBridge: WorkspaceDocumentBridge; + #connection: Promise | null = null; + + readonly font: Font; + readonly editor: Editor; + readonly documentStateCell: Signal; + readonly commitStateCell: Signal; + + constructor(options: WorkspaceOptions) { + this.#client = new WorkspaceClient(options.host); + this.#edits = new WorkspaceEditCoordinator(this.#client); + this.#documentBridge = new WorkspaceDocumentBridge({ + host: options.host, + edits: this.#edits, + }); + + this.font = new Font(this.#client.workspaceCell, this.#edits); + this.editor = new Editor({ font: this.font, clipboard: options.clipboard }); + this.documentStateCell = this.#client.documentStateCell; + this.commitStateCell = this.#edits.commitStateCell; + + registerBuiltInTools(this.editor); + this.editor.setActiveTool("select"); + } + + connect(): Promise { + if (!this.#connection) { + this.#connection = this.#connect(); + } + + return this.#connection; + } + + dispose(): void { + this.#documentBridge.dispose(); + this.#client.dispose(); + } + + async #connect(): Promise { + try { + await this.#client.connect(); + + const snapshot = this.#client.workspaceCell.peek(); + if (!snapshot) { + throw new Error("workspace connected without a snapshot"); + } + + await this.#documentBridge.connect(); + } catch (error) { + this.#connection = null; + throw error; + } + } +} diff --git a/apps/desktop/src/renderer/src/workspace/WorkspaceContext.tsx b/apps/desktop/src/renderer/src/workspace/WorkspaceContext.tsx new file mode 100644 index 00000000..f888f2b1 --- /dev/null +++ b/apps/desktop/src/renderer/src/workspace/WorkspaceContext.tsx @@ -0,0 +1,72 @@ +import { createContext, useContext, useEffect, useMemo, type ReactNode } from "react"; +import { electronSystemClipboard } from "@/lib/clipboard"; +import { getShiftHost } from "@/host/shiftHost"; +import type { Editor } from "@/lib/editor/Editor"; +import type { Font } from "@/lib/model/Font"; +import { Workspace } from "./Workspace"; + +const WorkspaceContext = createContext(null); + +export function WorkspaceProvider({ children }: { children: ReactNode }) { + const workspace = useMemo( + () => + new Workspace({ + host: getShiftHost(), + clipboard: electronSystemClipboard, + }), + [], + ); + + useEffect(() => { + return () => workspace.dispose(); + }, [workspace]); + + useEffect(() => { + if (typeof __PLAYWRIGHT__ === "undefined" || !__PLAYWRIGHT__) return undefined; + + const exposed: ShiftPlaywrightApi = { + getEditor: () => workspace.editor, + getWorkspace: () => workspace, + getFont: () => workspace.font, + }; + + window.__shift = exposed; + + return () => { + delete window.__shift; + }; + }, [workspace]); + + return {children}; +} + +export function useWorkspace(): Workspace { + const workspace = useContext(WorkspaceContext); + if (!workspace) { + throw new Error("useWorkspace must be used within a WorkspaceProvider"); + } + + return workspace; +} + +export function useEditor(): Editor { + return useWorkspace().editor; +} + +export function useFont(): Font { + return useWorkspace().font; +} + +declare const __PLAYWRIGHT__: boolean | undefined; + +interface ShiftPlaywrightApi { + readonly getEditor: () => Editor; + readonly getWorkspace: () => Workspace; + readonly getFont: () => Font; +} + +declare global { + interface Window { + __shift?: ShiftPlaywrightApi; + } +} diff --git a/apps/desktop/src/renderer/src/workspace/WorkspaceDocumentBridge.ts b/apps/desktop/src/renderer/src/workspace/WorkspaceDocumentBridge.ts new file mode 100644 index 00000000..6a96ba28 --- /dev/null +++ b/apps/desktop/src/renderer/src/workspace/WorkspaceDocumentBridge.ts @@ -0,0 +1,67 @@ +import type { ShiftHost } from "@shared/host/ShiftHost"; +import type { DocumentCallMap, DocumentEventMap } from "@shared/ipc/contract"; +import { domPortTransport, serveChannel, type ChannelServer } from "@shared/workspace/channel"; +import type { WorkspaceEditCoordinator } from "@/lib/workspace/WorkspaceEditCoordinator"; + +interface WorkspaceDocumentBridgeOptions { + readonly host: ShiftHost; + readonly edits: WorkspaceEditCoordinator; +} + +export class WorkspaceDocumentBridge { + readonly #host: ShiftHost; + readonly #edits: WorkspaceEditCoordinator; + #requests: ChannelServer | null = null; + + constructor(options: WorkspaceDocumentBridgeOptions) { + this.#host = options.host; + this.#edits = options.edits; + } + + async connect(): Promise { + const port = nextDocumentPort(); + + try { + await this.#host.document.connect(); + } catch (error) { + port.cancel(); + throw error; + } + + this.#requests?.dispose(); + this.#requests = serveChannel( + domPortTransport(await port.received), + { + "document.state": () => this.#edits.state(), + "document.save": ({ path }) => this.#edits.save(path), + }, + ); + } + + dispose(): void { + this.#requests?.dispose(); + this.#requests = null; + } +} + +function nextDocumentPort(): { received: Promise; cancel: () => void } { + let cancel = () => {}; + + const received = new Promise((resolve) => { + const listener = (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + if ((event.data as { type?: string } | null)?.type !== "document.port") return; + + const port = event.ports[0]; + if (!port) return; + + window.removeEventListener("message", listener); + resolve(port); + }; + + cancel = () => window.removeEventListener("message", listener); + window.addEventListener("message", listener); + }); + + return { received, cancel }; +} diff --git a/apps/desktop/src/renderer/src/workspace/glyphInfo.ts b/apps/desktop/src/renderer/src/workspace/glyphInfo.ts new file mode 100644 index 00000000..85264aa9 --- /dev/null +++ b/apps/desktop/src/renderer/src/workspace/glyphInfo.ts @@ -0,0 +1,8 @@ +import { defaultResources, GlyphInfo } from "@shift/glyph-info"; + +let glyphInfo: GlyphInfo | null = null; + +export function getGlyphInfo(): GlyphInfo { + glyphInfo ??= new GlyphInfo(defaultResources); + return glyphInfo; +}