···11+/**
22+ * IZUI State Coordinator (shared)
33+ *
44+ * Centralized state machine for managing IZUI (Invocable Zoom User Interface) states.
55+ * Platform-agnostic — works with any backend (Electron, Tauri) via WindowProvider injection.
66+ *
77+ * See docs/izui.md for comprehensive documentation.
88+ *
99+ * States:
1010+ * - idle: No visible windows, app in background
1111+ * - transient: Invoked via global hotkey while unfocused
1212+ * - active: User working within focused Peek windows
1313+ * - overlay: Full-screen overlay (windows picker)
1414+ *
1515+ * Key Rules:
1616+ * | State | Mode Display | ESC Behavior |
1717+ * |-----------|----------------------|--------------------------------|
1818+ * | TRANSIENT | Always "default" | Close immediately |
1919+ * | ACTIVE | Target window's mode | Internal nav only, never close |
2020+ * | OVERLAY | Inherit from previous| Always close, restore hidden |
2121+ */
2222+2323+const DEBUG = typeof process !== 'undefined' && process.env.DEBUG;
2424+2525+/**
2626+ * Generate a unique session ID
2727+ */
2828+function generateSessionId() {
2929+ return `izui-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
3030+}
3131+3232+/**
3333+ * No-op window provider (safe default when no backend is connected)
3434+ * @type {WindowProvider}
3535+ *
3636+ * @typedef {Object} WindowProvider
3737+ * @property {function(): Array<{id: number, isDestroyed(): boolean, isFocused(): boolean, isVisible(): boolean}>} getAllWindows
3838+ * @property {function(number): {show(): void, isDestroyed(): boolean}|null} fromId
3939+ */
4040+const nullWindowProvider = {
4141+ getAllWindows: () => [],
4242+ fromId: () => null,
4343+};
4444+4545+/**
4646+ * IZUI State Coordinator
4747+ *
4848+ * Manages the session lifecycle for IZUI state transitions.
4949+ * Inject a WindowProvider for backend-specific window operations.
5050+ */
5151+class IzuiStateCoordinator {
5252+ constructor() {
5353+ /** @type {'idle'|'transient'|'active'|'overlay'} */
5454+ this._state = 'idle';
5555+5656+ /** @type {IzuiSession|null} */
5757+ this._session = null;
5858+5959+ /** @type {WindowProvider} */
6060+ this._windowProvider = nullWindowProvider;
6161+6262+ // OS-level app focus state. True when Peek is the active app.
6363+ // Updated by backend via setAppFocused().
6464+ // Initial value is true: at app launch, Peek IS the active app.
6565+ /** @type {boolean} */
6666+ this._appFocused = true;
6767+ }
6868+6969+ /**
7070+ * @typedef {Object} IzuiSession
7171+ * @property {string} id
7272+ * @property {'active'|'transient'} entryMode
7373+ * @property {number[]} windowStack
7474+ * @property {number|null} overlayWindowId
7575+ * @property {number[]} hiddenWindowIds
7676+ * @property {number|null} focusedWindowId
7777+ */
7878+7979+ /**
8080+ * Set custom window provider (for backend integration or testing)
8181+ * @param {WindowProvider} provider
8282+ */
8383+ setWindowProvider(provider) {
8484+ this._windowProvider = provider;
8585+ }
8686+8787+ /** @deprecated Use setWindowProvider() — kept for backward compatibility with tests */
8888+ _setWindowProvider(provider) {
8989+ this._windowProvider = provider;
9090+ }
9191+9292+ /**
9393+ * Reset to null window provider
9494+ */
9595+ resetWindowProvider() {
9696+ this._windowProvider = nullWindowProvider;
9797+ }
9898+9999+ /** @deprecated Use resetWindowProvider() — kept for backward compatibility with tests */
100100+ _resetWindowProvider() {
101101+ this._windowProvider = nullWindowProvider;
102102+ }
103103+104104+ /**
105105+ * Update OS-level app focus state.
106106+ * Called from backend focus event handlers.
107107+ * @param {boolean} focused
108108+ */
109109+ setAppFocused(focused) {
110110+ this._appFocused = focused;
111111+ DEBUG && console.log('[izui] App focus changed:', focused);
112112+113113+ // One-way ratchet: when app gains focus during a transient session,
114114+ // promote to active. The user is now working in Peek.
115115+ if (focused && this._session && this._session.entryMode === 'transient') {
116116+ this._session.entryMode = 'active';
117117+ this._state = 'active';
118118+ DEBUG && console.log('[izui] Promoted session from transient to active (app gained focus)');
119119+ }
120120+ }
121121+122122+ /**
123123+ * Check if app currently has OS-level focus.
124124+ * @returns {boolean}
125125+ */
126126+ isAppFocused() {
127127+ return this._appFocused;
128128+ }
129129+130130+ /**
131131+ * Reset all state (for testing only)
132132+ */
133133+ _resetAll() {
134134+ this._state = 'idle';
135135+ this._session = null;
136136+ this._appFocused = true;
137137+ }
138138+139139+ /**
140140+ * Start a new IZUI session when the first window opens
141141+ * @param {'active'|'transient'} entryMode How the session was started
142142+ */
143143+ startSession(entryMode) {
144144+ if (this._session) {
145145+ DEBUG && console.log('[izui] Session already active, not starting new one');
146146+ return;
147147+ }
148148+149149+ this._session = {
150150+ id: generateSessionId(),
151151+ entryMode,
152152+ windowStack: [],
153153+ overlayWindowId: null,
154154+ hiddenWindowIds: [],
155155+ focusedWindowId: null,
156156+ };
157157+158158+ this._state = entryMode;
159159+ DEBUG && console.log('[izui] Started session:', this._session.id, 'entryMode:', entryMode);
160160+ }
161161+162162+ /**
163163+ * End the current IZUI session when the last window closes
164164+ */
165165+ endSession() {
166166+ if (!this._session) {
167167+ DEBUG && console.log('[izui] No session to end');
168168+ return;
169169+ }
170170+171171+ DEBUG && console.log('[izui] Ending session:', this._session.id);
172172+ this._session = null;
173173+ this._state = 'idle';
174174+ }
175175+176176+ /**
177177+ * Evaluate transient state based on OS-level app focus.
178178+ *
179179+ * IZUI policy: transient = Peek was NOT the active/focused app at the OS
180180+ * window manager level when the window was opened.
181181+ *
182182+ * @param {number} [_excludeWindowId] Unused (kept for API compat)
183183+ * @returns {'active'|'transient'}
184184+ */
185185+ evaluateOnShow(_excludeWindowId) {
186186+ const result = this._appFocused ? 'active' : 'transient';
187187+188188+ DEBUG && console.log('[izui] evaluateOnShow:', result, 'appFocused:', this._appFocused);
189189+190190+ if (!this._session) {
191191+ this.startSession(result);
192192+ } else {
193193+ // One-way ratchet: transient -> active is allowed, active -> transient is not.
194194+ if (this._session.entryMode === 'transient' && result === 'active') {
195195+ this._session.entryMode = 'active';
196196+ this._state = 'active';
197197+ DEBUG && console.log('[izui] Promoted session from transient to active');
198198+ } else {
199199+ DEBUG && console.log('[izui] Session entryMode unchanged:', this._session.entryMode, '(evaluated:', result, ')');
200200+ }
201201+ }
202202+203203+ return result;
204204+ }
205205+206206+ /**
207207+ * Enter overlay mode (e.g., windows picker)
208208+ * @param {number} windowId The overlay window's ID
209209+ * @param {number[]} hiddenIds IDs of windows that were hidden for the overlay
210210+ */
211211+ enterOverlay(windowId, hiddenIds) {
212212+ if (!this._session) {
213213+ DEBUG && console.log('[izui] Cannot enter overlay without active session');
214214+ return;
215215+ }
216216+217217+ this._state = 'overlay';
218218+ this._session.overlayWindowId = windowId;
219219+ this._session.hiddenWindowIds = hiddenIds;
220220+ DEBUG && console.log('[izui] Entered overlay mode, windowId:', windowId, 'hidden:', hiddenIds.length);
221221+ }
222222+223223+ /**
224224+ * Exit overlay mode and restore hidden windows
225225+ */
226226+ exitOverlay() {
227227+ if (!this._session || this._state !== 'overlay') {
228228+ DEBUG && console.log('[izui] Not in overlay mode');
229229+ return;
230230+ }
231231+232232+ const hiddenIds = this._session.hiddenWindowIds;
233233+ DEBUG && console.log('[izui] Exiting overlay, restoring', hiddenIds.length, 'windows');
234234+235235+ // Restore hidden windows via provider
236236+ for (const id of hiddenIds) {
237237+ const win = this._windowProvider.fromId(id);
238238+ if (win && !win.isDestroyed()) {
239239+ win.show();
240240+ }
241241+ }
242242+243243+ // Return to previous state based on entry mode
244244+ this._state = this._session.entryMode;
245245+ this._session.overlayWindowId = null;
246246+ this._session.hiddenWindowIds = [];
247247+ }
248248+249249+ /**
250250+ * Track when a window gains focus
251251+ * @param {number} windowId
252252+ */
253253+ setFocusedWindow(windowId) {
254254+ if (this._session) {
255255+ this._session.focusedWindowId = windowId;
256256+ }
257257+ DEBUG && console.log('[izui] Focused window set:', windowId);
258258+ }
259259+260260+ /**
261261+ * Clear focus tracking when a window closes or loses focus
262262+ * @param {number} windowId
263263+ */
264264+ clearFocusedWindow(windowId) {
265265+ if (this._session && this._session.focusedWindowId === windowId) {
266266+ this._session.focusedWindowId = null;
267267+ DEBUG && console.log('[izui] Cleared focused window:', windowId);
268268+ }
269269+ }
270270+271271+ /**
272272+ * Add a window to the session's window stack
273273+ * @param {number} windowId
274274+ */
275275+ pushWindow(windowId) {
276276+ if (!this._session) {
277277+ DEBUG && console.log('[izui] Cannot push window without active session');
278278+ return;
279279+ }
280280+281281+ if (!this._session.windowStack.includes(windowId)) {
282282+ this._session.windowStack.push(windowId);
283283+ DEBUG && console.log('[izui] Pushed window:', windowId, 'stack size:', this._session.windowStack.length);
284284+ }
285285+ }
286286+287287+ /**
288288+ * Remove a window from the session's window stack
289289+ * If this was the last window, end the session
290290+ * @param {number} windowId
291291+ */
292292+ popWindow(windowId) {
293293+ if (!this._session) {
294294+ return;
295295+ }
296296+297297+ const index = this._session.windowStack.indexOf(windowId);
298298+ if (index !== -1) {
299299+ this._session.windowStack.splice(index, 1);
300300+ DEBUG && console.log('[izui] Popped window:', windowId, 'remaining:', this._session.windowStack.length);
301301+ }
302302+303303+ // End session if no more windows
304304+ if (this._session.windowStack.length === 0) {
305305+ this.endSession();
306306+ }
307307+ }
308308+309309+ /**
310310+ * Check if currently in transient mode
311311+ * @returns {boolean}
312312+ */
313313+ isTransient() {
314314+ if (!this._session) {
315315+ return false;
316316+ }
317317+ return this._session.entryMode === 'transient';
318318+ }
319319+320320+ /**
321321+ * Get the current IZUI state
322322+ * @returns {'idle'|'transient'|'active'|'overlay'}
323323+ */
324324+ getState() {
325325+ return this._state;
326326+ }
327327+328328+ /**
329329+ * Get the current session info
330330+ * @returns {IzuiSession|null}
331331+ */
332332+ getSession() {
333333+ return this._session;
334334+ }
335335+336336+ /**
337337+ * Get the effective mode for display
338338+ * @param {number} [targetWindowId] Optional window to get mode for
339339+ * @returns {'default'|'active'}
340340+ */
341341+ getEffectiveMode(targetWindowId) {
342342+ if (this.isTransient()) {
343343+ return 'default';
344344+ }
345345+ return 'active';
346346+ }
347347+348348+ /**
349349+ * Check if currently in overlay mode
350350+ * @returns {boolean}
351351+ */
352352+ isOverlay() {
353353+ return this._state === 'overlay';
354354+ }
355355+356356+ /**
357357+ * Get the focused window ID from the session
358358+ * @returns {number|null}
359359+ */
360360+ getFocusedWindowId() {
361361+ return this._session?.focusedWindowId ?? null;
362362+ }
363363+}
364364+365365+// ============================================================================
366366+// ESC Policy
367367+// ============================================================================
368368+369369+/**
370370+ * ESC policy: given session state and window role, determine what to do.
371371+ * Pure function — no side effects.
372372+ *
373373+ * @param {string} sessionState - Current IZUI state ('idle'|'transient'|'active'|'overlay')
374374+ * @param {string} role - Window role ('quick-view'|'palette'|'utility'|'overlay'|'child-content'|'workspace'|'content')
375375+ * @returns {'close'|'close-and-restore'|'nothing'}
376376+ */
377377+function escPolicy(sessionState, role) {
378378+ // These roles always close regardless of session state
379379+ if (['quick-view', 'palette', 'utility'].includes(role)) return 'close';
380380+ if (role === 'overlay') return 'close-and-restore';
381381+ if (role === 'child-content') return 'close';
382382+ // workspace and content: only close in transient sessions
383383+ if (sessionState === 'transient') return 'close';
384384+ return 'nothing';
385385+}
386386+387387+// ============================================================================
388388+// Singleton & Exports
389389+// ============================================================================
390390+391391+const coordinator = new IzuiStateCoordinator();
392392+393393+/**
394394+ * Get the IZUI State Coordinator singleton
395395+ * @returns {IzuiStateCoordinator}
396396+ */
397397+function getIzuiCoordinator() {
398398+ return coordinator;
399399+}
400400+401401+export {
402402+ IzuiStateCoordinator,
403403+ getIzuiCoordinator,
404404+ escPolicy,
405405+};
+26-356
backend/electron/izui-state.ts
···11/**
22- * IZUI State Coordinator
33- *
44- * Centralized state machine for managing IZUI (Invocable Zoom User Interface) states.
55- * See docs/izui.md for comprehensive documentation.
66- *
77- * Problems solved:
88- * - Transient state frozen at creation for keepLive windows
99- * - No session concept for unified state
1010- * - Stale focus trackers never cleaned up
22+ * IZUI State Coordinator — Electron adapter
113 *
1212- * States:
1313- * - IDLE: No visible windows, app in background
1414- * - TRANSIENT: Invoked via global hotkey while unfocused
1515- * - ACTIVE: User working within focused Peek windows
1616- * - OVERLAY: Full-screen overlay (windows picker)
44+ * Thin wrapper around the shared IZUI state machine (app/lib/izui-state.js).
55+ * Sets up the Electron-specific WindowProvider using BrowserWindow.
176 *
1818- * Key Rules:
1919- * | State | Mode Display | ESC Behavior |
2020- * |-----------|----------------------|--------------------------------|
2121- * | TRANSIENT | Always "default" | Close immediately |
2222- * | ACTIVE | Target window's mode | Internal nav only, never close |
2323- * | OVERLAY | Inherit from previous| Always close, restore hidden |
77+ * All state machine logic lives in the shared module.
88+ * See docs/izui.md for comprehensive documentation.
249 */
25102626-import { DEBUG, isHeadless } from './config.js';
1111+import { DEBUG } from './config.js';
27122828-// Lazy-load Electron modules to allow testing without Electron
2929-let BrowserWindow: typeof import('electron').BrowserWindow | null = null;
1313+// Re-export everything from the shared module
1414+// Path is ../../../ because at runtime this executes from dist/backend/electron/
1515+// @ts-expect-error — shared JS module in app/lib, no .d.ts declaration file
1616+import { IzuiStateCoordinator, getIzuiCoordinator, escPolicy } from '../../../app/lib/izui-state.js';
30173131-try {
3232- const electron = await import('electron');
3333- BrowserWindow = electron.BrowserWindow;
3434-} catch {
3535- // Electron not available (e.g., in unit tests)
3636- DEBUG && console.log('[izui-state] Running without Electron (test mode)');
3737-}
1818+export { IzuiStateCoordinator, getIzuiCoordinator, escPolicy };
38192020+// Re-export types for TypeScript consumers
3921export type IzuiState = 'idle' | 'transient' | 'active' | 'overlay';
40224141-/**
4242- * Window provider interface for dependency injection (enables testing)
4343- */
4423export interface WindowProvider {
4524 getAllWindows(): Array<{ id: number; isDestroyed(): boolean; isFocused(): boolean; isVisible(): boolean; webContents?: { getURL(): string } }>;
4625 fromId(id: number): { show(): void; isDestroyed(): boolean } | null;
4726}
48274949-/**
5050- * Default window provider using Electron's BrowserWindow (or stub for testing)
5151- */
5252-const defaultWindowProvider: WindowProvider = {
5353- getAllWindows: () => BrowserWindow?.getAllWindows() ?? [],
5454- fromId: (id: number) => BrowserWindow?.fromId(id) ?? null,
5555-};
5656-5728export interface IzuiSession {
5829 id: string;
5930 entryMode: 'active' | 'transient';
···6334 focusedWindowId: number | null;
6435}
65366666-/**
6767- * Generate a unique session ID
6868- */
6969-function generateSessionId(): string {
7070- return `izui-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
7171-}
7272-7373-/**
7474- * IZUI State Coordinator singleton
7575- */
7676-class IzuiStateCoordinator {
7777- private state: IzuiState = 'idle';
7878- private session: IzuiSession | null = null;
7979- private windowProvider: WindowProvider = defaultWindowProvider;
8080-8181- // OS-level app focus state. True when Peek is the active app.
8282- // Updated by browser-window-focus/blur events in main.ts.
8383- // Initial value is true: at app launch, Peek IS the active app.
8484- // In headless mode, stays true (no real focus events fire).
8585- private appFocused: boolean = true;
8686-8787- /**
8888- * Set custom window provider (for testing)
8989- */
9090- _setWindowProvider(provider: WindowProvider): void {
9191- this.windowProvider = provider;
9292- }
9393-9494- /**
9595- * Reset to default window provider
9696- */
9797- _resetWindowProvider(): void {
9898- this.windowProvider = defaultWindowProvider;
9999- }
100100-101101- /**
102102- * Update OS-level app focus state.
103103- * Called from main.ts browser-window-focus/blur event handlers.
104104- */
105105- setAppFocused(focused: boolean): void {
106106- this.appFocused = focused;
107107- DEBUG && console.log('[izui] App focus changed:', focused);
108108-109109- // One-way ratchet: when app gains focus during a transient session,
110110- // promote to active. The user is now working in Peek.
111111- if (focused && this.session && this.session.entryMode === 'transient') {
112112- this.session.entryMode = 'active';
113113- this.state = 'active';
114114- DEBUG && console.log('[izui] Promoted session from transient to active (app gained focus)');
115115- }
116116- }
117117-118118- /**
119119- * Check if app currently has OS-level focus.
120120- */
121121- isAppFocused(): boolean {
122122- return this.appFocused;
123123- }
124124-125125- /**
126126- * Reset all state (for testing only)
127127- */
128128- _resetAll(): void {
129129- this.state = 'idle';
130130- this.session = null;
131131- this.appFocused = true;
132132- }
133133-134134- /**
135135- * Start a new IZUI session when the first window opens
136136- * @param entryMode How the session was started (active if app was focused, transient if not)
137137- */
138138- startSession(entryMode: 'active' | 'transient'): void {
139139- if (this.session) {
140140- DEBUG && console.log('[izui] Session already active, not starting new one');
141141- return;
142142- }
143143-144144- this.session = {
145145- id: generateSessionId(),
146146- entryMode,
147147- windowStack: [],
148148- overlayWindowId: null,
149149- hiddenWindowIds: [],
150150- focusedWindowId: null,
151151- };
152152-153153- this.state = entryMode;
154154- DEBUG && console.log('[izui] Started session:', this.session.id, 'entryMode:', entryMode);
155155- }
156156-157157- /**
158158- * End the current IZUI session when the last window closes
159159- */
160160- endSession(): void {
161161- if (!this.session) {
162162- DEBUG && console.log('[izui] No session to end');
163163- return;
164164- }
165165-166166- DEBUG && console.log('[izui] Ending session:', this.session.id);
167167- this.session = null;
168168- this.state = 'idle';
169169- }
170170-171171- /**
172172- * Evaluate transient state based on OS-level app focus.
173173- *
174174- * IZUI policy: transient = Peek was NOT the active/focused app at the OS
175175- * window manager level when the window was opened. This is the single source
176176- * of truth for transient detection.
177177- *
178178- * Called from:
179179- * - window-open handler (before window is shown, so focus state is still accurate)
180180- * - keepLive reuse path (re-evaluates on each show)
181181- * - izui-is-transient IPC handler (when no session exists)
182182- *
183183- * @param _excludeWindowId Unused (kept for API compat), focus is now app-level
184184- * @returns 'active' if Peek has OS focus, 'transient' if not
185185- */
186186- evaluateOnShow(_excludeWindowId?: number): 'active' | 'transient' {
187187- // IZUI policy: app focused → active, app not focused → transient.
188188- // appFocused is tracked via browser-window-focus/blur events in main.ts.
189189- // At startup, appFocused defaults to true (app is active when launched).
190190- // In headless mode, appFocused stays true (no real focus events fire).
191191- const result = this.appFocused ? 'active' : 'transient';
192192-193193- DEBUG && console.log('[izui] evaluateOnShow:', result, 'appFocused:', this.appFocused);
194194-195195- if (!this.session) {
196196- this.startSession(result);
197197- } else {
198198- // One-way ratchet: transient -> active is allowed, active -> transient is not.
199199- // Once a session becomes active (user is working in Peek), it stays active.
200200- if (this.session.entryMode === 'transient' && result === 'active') {
201201- this.session.entryMode = 'active';
202202- this.state = 'active';
203203- DEBUG && console.log('[izui] Promoted session from transient to active');
204204- } else {
205205- DEBUG && console.log('[izui] Session entryMode unchanged:', this.session.entryMode, '(evaluated:', result, ')');
206206- }
207207- }
208208-209209- return result;
210210- }
211211-212212- /**
213213- * Enter overlay mode (e.g., windows picker)
214214- * @param windowId The overlay window's ID
215215- * @param hiddenIds IDs of windows that were hidden for the overlay
216216- */
217217- enterOverlay(windowId: number, hiddenIds: number[]): void {
218218- if (!this.session) {
219219- DEBUG && console.log('[izui] Cannot enter overlay without active session');
220220- return;
221221- }
222222-223223- this.state = 'overlay';
224224- this.session.overlayWindowId = windowId;
225225- this.session.hiddenWindowIds = hiddenIds;
226226- DEBUG && console.log('[izui] Entered overlay mode, windowId:', windowId, 'hidden:', hiddenIds.length);
227227- }
228228-229229- /**
230230- * Exit overlay mode and restore hidden windows
231231- */
232232- exitOverlay(): void {
233233- if (!this.session || this.state !== 'overlay') {
234234- DEBUG && console.log('[izui] Not in overlay mode');
235235- return;
236236- }
237237-238238- const hiddenIds = this.session.hiddenWindowIds;
239239- DEBUG && console.log('[izui] Exiting overlay, restoring', hiddenIds.length, 'windows');
240240-241241- // Restore hidden windows
242242- for (const id of hiddenIds) {
243243- const win = this.windowProvider.fromId(id);
244244- if (win && !win.isDestroyed()) {
245245- win.show();
246246- }
247247- }
248248-249249- // Return to previous state based on entry mode
250250- this.state = this.session.entryMode;
251251- this.session.overlayWindowId = null;
252252- this.session.hiddenWindowIds = [];
253253- }
254254-255255- /**
256256- * Track when a window gains focus
257257- * @param windowId The window that gained focus
258258- */
259259- setFocusedWindow(windowId: number): void {
260260- if (this.session) {
261261- this.session.focusedWindowId = windowId;
262262- }
263263- DEBUG && console.log('[izui] Focused window set:', windowId);
264264- }
265265-266266- /**
267267- * Clear focus tracking when a window closes or loses focus
268268- * This prevents stale window IDs from being tracked
269269- * @param windowId The window to clear
270270- */
271271- clearFocusedWindow(windowId: number): void {
272272- if (this.session && this.session.focusedWindowId === windowId) {
273273- this.session.focusedWindowId = null;
274274- DEBUG && console.log('[izui] Cleared focused window:', windowId);
275275- }
276276- }
3737+// Set up Electron-specific WindowProvider
3838+// Lazy-load to allow testing without Electron
3939+try {
4040+ const electron = await import('electron');
4141+ const BrowserWindow = electron.BrowserWindow;
27742278278- /**
279279- * Add a window to the session's window stack
280280- * @param windowId The window to add
281281- */
282282- pushWindow(windowId: number): void {
283283- if (!this.session) {
284284- DEBUG && console.log('[izui] Cannot push window without active session');
285285- return;
286286- }
4343+ const electronWindowProvider = {
4444+ getAllWindows: () => BrowserWindow.getAllWindows(),
4545+ fromId: (id: number) => BrowserWindow.fromId(id),
4646+ };
28747288288- if (!this.session.windowStack.includes(windowId)) {
289289- this.session.windowStack.push(windowId);
290290- DEBUG && console.log('[izui] Pushed window:', windowId, 'stack size:', this.session.windowStack.length);
291291- }
292292- }
293293-294294- /**
295295- * Remove a window from the session's window stack
296296- * If this was the last window, end the session
297297- * @param windowId The window to remove
298298- */
299299- popWindow(windowId: number): void {
300300- if (!this.session) {
301301- return;
302302- }
303303-304304- const index = this.session.windowStack.indexOf(windowId);
305305- if (index !== -1) {
306306- this.session.windowStack.splice(index, 1);
307307- DEBUG && console.log('[izui] Popped window:', windowId, 'remaining:', this.session.windowStack.length);
308308- }
309309-310310- // End session if no more windows
311311- if (this.session.windowStack.length === 0) {
312312- this.endSession();
313313- }
314314- }
315315-316316- /**
317317- * Check if currently in transient mode
318318- * This is the primary query for determining mode display and ESC behavior
319319- */
320320- isTransient(): boolean {
321321- if (!this.session) {
322322- return false;
323323- }
324324- // Transient means opened without app focus
325325- return this.session.entryMode === 'transient';
326326- }
327327-328328- /**
329329- * Get the current IZUI state
330330- */
331331- getState(): IzuiState {
332332- return this.state;
333333- }
334334-335335- /**
336336- * Get the current session info (for debugging)
337337- */
338338- getSession(): IzuiSession | null {
339339- return this.session;
340340- }
341341-342342- /**
343343- * Get the effective mode for display
344344- * @param targetWindowId Optional window to get mode for
345345- * @returns 'default' for transient sessions, actual mode otherwise
346346- */
347347- getEffectiveMode(targetWindowId?: number): string {
348348- // In transient mode, always show "default"
349349- if (this.isTransient()) {
350350- return 'default';
351351- }
352352-353353- // For active sessions, return the window's actual mode
354354- // This will be resolved by the caller using the context API
355355- return 'active';
356356- }
357357-358358- /**
359359- * Check if currently in overlay mode
360360- */
361361- isOverlay(): boolean {
362362- return this.state === 'overlay';
363363- }
364364-365365- /**
366366- * Get the focused window ID from the session
367367- */
368368- getFocusedWindowId(): number | null {
369369- return this.session?.focusedWindowId ?? null;
370370- }
4848+ getIzuiCoordinator().setWindowProvider(electronWindowProvider);
4949+ DEBUG && console.log('[izui-state] Electron WindowProvider installed');
5050+} catch {
5151+ // Electron not available (e.g., in unit tests)
5252+ DEBUG && console.log('[izui-state] Running without Electron (test mode)');
37153}
372372-373373-// Singleton instance
374374-const coordinator = new IzuiStateCoordinator();
375375-376376-/**
377377- * Get the IZUI State Coordinator singleton
378378- */
379379-export function getIzuiCoordinator(): IzuiStateCoordinator {
380380- return coordinator;
381381-}
382382-383383-export { IzuiStateCoordinator };
+2-13
backend/electron/windows.ts
···25252626import {
2727 getIzuiCoordinator,
2828+ escPolicy,
2829} from './izui-state.js';
29303031/**
···99100 });
100101}
101102102102-/**
103103- * ESC policy: given session state and window role, determine what to do.
104104- * Pure function — no side effects.
105105- */
106106-function escPolicy(sessionState: string, role: string): 'close' | 'close-and-restore' | 'nothing' {
107107- // These roles always close regardless of session state
108108- if (['quick-view', 'palette', 'utility'].includes(role)) return 'close';
109109- if (role === 'overlay') return 'close-and-restore';
110110- if (role === 'child-content') return 'close';
111111- // workspace and content: only close in transient sessions
112112- if (sessionState === 'transient') return 'close';
113113- return 'nothing';
114114-}
103103+// escPolicy is imported from the shared IZUI module via ./izui-state.js
115104116105/**
117106 * Core ESC handling logic for a BrowserWindow.