experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(izui): extract shared IZUI state machine to app/lib

+433 -369
+405
app/lib/izui-state.js
··· 1 + /** 2 + * IZUI State Coordinator (shared) 3 + * 4 + * Centralized state machine for managing IZUI (Invocable Zoom User Interface) states. 5 + * Platform-agnostic — works with any backend (Electron, Tauri) via WindowProvider injection. 6 + * 7 + * See docs/izui.md for comprehensive documentation. 8 + * 9 + * States: 10 + * - idle: No visible windows, app in background 11 + * - transient: Invoked via global hotkey while unfocused 12 + * - active: User working within focused Peek windows 13 + * - overlay: Full-screen overlay (windows picker) 14 + * 15 + * Key Rules: 16 + * | State | Mode Display | ESC Behavior | 17 + * |-----------|----------------------|--------------------------------| 18 + * | TRANSIENT | Always "default" | Close immediately | 19 + * | ACTIVE | Target window's mode | Internal nav only, never close | 20 + * | OVERLAY | Inherit from previous| Always close, restore hidden | 21 + */ 22 + 23 + const DEBUG = typeof process !== 'undefined' && process.env.DEBUG; 24 + 25 + /** 26 + * Generate a unique session ID 27 + */ 28 + function generateSessionId() { 29 + return `izui-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; 30 + } 31 + 32 + /** 33 + * No-op window provider (safe default when no backend is connected) 34 + * @type {WindowProvider} 35 + * 36 + * @typedef {Object} WindowProvider 37 + * @property {function(): Array<{id: number, isDestroyed(): boolean, isFocused(): boolean, isVisible(): boolean}>} getAllWindows 38 + * @property {function(number): {show(): void, isDestroyed(): boolean}|null} fromId 39 + */ 40 + const nullWindowProvider = { 41 + getAllWindows: () => [], 42 + fromId: () => null, 43 + }; 44 + 45 + /** 46 + * IZUI State Coordinator 47 + * 48 + * Manages the session lifecycle for IZUI state transitions. 49 + * Inject a WindowProvider for backend-specific window operations. 50 + */ 51 + class IzuiStateCoordinator { 52 + constructor() { 53 + /** @type {'idle'|'transient'|'active'|'overlay'} */ 54 + this._state = 'idle'; 55 + 56 + /** @type {IzuiSession|null} */ 57 + this._session = null; 58 + 59 + /** @type {WindowProvider} */ 60 + this._windowProvider = nullWindowProvider; 61 + 62 + // OS-level app focus state. True when Peek is the active app. 63 + // Updated by backend via setAppFocused(). 64 + // Initial value is true: at app launch, Peek IS the active app. 65 + /** @type {boolean} */ 66 + this._appFocused = true; 67 + } 68 + 69 + /** 70 + * @typedef {Object} IzuiSession 71 + * @property {string} id 72 + * @property {'active'|'transient'} entryMode 73 + * @property {number[]} windowStack 74 + * @property {number|null} overlayWindowId 75 + * @property {number[]} hiddenWindowIds 76 + * @property {number|null} focusedWindowId 77 + */ 78 + 79 + /** 80 + * Set custom window provider (for backend integration or testing) 81 + * @param {WindowProvider} provider 82 + */ 83 + setWindowProvider(provider) { 84 + this._windowProvider = provider; 85 + } 86 + 87 + /** @deprecated Use setWindowProvider() — kept for backward compatibility with tests */ 88 + _setWindowProvider(provider) { 89 + this._windowProvider = provider; 90 + } 91 + 92 + /** 93 + * Reset to null window provider 94 + */ 95 + resetWindowProvider() { 96 + this._windowProvider = nullWindowProvider; 97 + } 98 + 99 + /** @deprecated Use resetWindowProvider() — kept for backward compatibility with tests */ 100 + _resetWindowProvider() { 101 + this._windowProvider = nullWindowProvider; 102 + } 103 + 104 + /** 105 + * Update OS-level app focus state. 106 + * Called from backend focus event handlers. 107 + * @param {boolean} focused 108 + */ 109 + setAppFocused(focused) { 110 + this._appFocused = focused; 111 + DEBUG && console.log('[izui] App focus changed:', focused); 112 + 113 + // One-way ratchet: when app gains focus during a transient session, 114 + // promote to active. The user is now working in Peek. 115 + if (focused && this._session && this._session.entryMode === 'transient') { 116 + this._session.entryMode = 'active'; 117 + this._state = 'active'; 118 + DEBUG && console.log('[izui] Promoted session from transient to active (app gained focus)'); 119 + } 120 + } 121 + 122 + /** 123 + * Check if app currently has OS-level focus. 124 + * @returns {boolean} 125 + */ 126 + isAppFocused() { 127 + return this._appFocused; 128 + } 129 + 130 + /** 131 + * Reset all state (for testing only) 132 + */ 133 + _resetAll() { 134 + this._state = 'idle'; 135 + this._session = null; 136 + this._appFocused = true; 137 + } 138 + 139 + /** 140 + * Start a new IZUI session when the first window opens 141 + * @param {'active'|'transient'} entryMode How the session was started 142 + */ 143 + startSession(entryMode) { 144 + if (this._session) { 145 + DEBUG && console.log('[izui] Session already active, not starting new one'); 146 + return; 147 + } 148 + 149 + this._session = { 150 + id: generateSessionId(), 151 + entryMode, 152 + windowStack: [], 153 + overlayWindowId: null, 154 + hiddenWindowIds: [], 155 + focusedWindowId: null, 156 + }; 157 + 158 + this._state = entryMode; 159 + DEBUG && console.log('[izui] Started session:', this._session.id, 'entryMode:', entryMode); 160 + } 161 + 162 + /** 163 + * End the current IZUI session when the last window closes 164 + */ 165 + endSession() { 166 + if (!this._session) { 167 + DEBUG && console.log('[izui] No session to end'); 168 + return; 169 + } 170 + 171 + DEBUG && console.log('[izui] Ending session:', this._session.id); 172 + this._session = null; 173 + this._state = 'idle'; 174 + } 175 + 176 + /** 177 + * Evaluate transient state based on OS-level app focus. 178 + * 179 + * IZUI policy: transient = Peek was NOT the active/focused app at the OS 180 + * window manager level when the window was opened. 181 + * 182 + * @param {number} [_excludeWindowId] Unused (kept for API compat) 183 + * @returns {'active'|'transient'} 184 + */ 185 + evaluateOnShow(_excludeWindowId) { 186 + const result = this._appFocused ? 'active' : 'transient'; 187 + 188 + DEBUG && console.log('[izui] evaluateOnShow:', result, 'appFocused:', this._appFocused); 189 + 190 + if (!this._session) { 191 + this.startSession(result); 192 + } else { 193 + // One-way ratchet: transient -> active is allowed, active -> transient is not. 194 + if (this._session.entryMode === 'transient' && result === 'active') { 195 + this._session.entryMode = 'active'; 196 + this._state = 'active'; 197 + DEBUG && console.log('[izui] Promoted session from transient to active'); 198 + } else { 199 + DEBUG && console.log('[izui] Session entryMode unchanged:', this._session.entryMode, '(evaluated:', result, ')'); 200 + } 201 + } 202 + 203 + return result; 204 + } 205 + 206 + /** 207 + * Enter overlay mode (e.g., windows picker) 208 + * @param {number} windowId The overlay window's ID 209 + * @param {number[]} hiddenIds IDs of windows that were hidden for the overlay 210 + */ 211 + enterOverlay(windowId, hiddenIds) { 212 + if (!this._session) { 213 + DEBUG && console.log('[izui] Cannot enter overlay without active session'); 214 + return; 215 + } 216 + 217 + this._state = 'overlay'; 218 + this._session.overlayWindowId = windowId; 219 + this._session.hiddenWindowIds = hiddenIds; 220 + DEBUG && console.log('[izui] Entered overlay mode, windowId:', windowId, 'hidden:', hiddenIds.length); 221 + } 222 + 223 + /** 224 + * Exit overlay mode and restore hidden windows 225 + */ 226 + exitOverlay() { 227 + if (!this._session || this._state !== 'overlay') { 228 + DEBUG && console.log('[izui] Not in overlay mode'); 229 + return; 230 + } 231 + 232 + const hiddenIds = this._session.hiddenWindowIds; 233 + DEBUG && console.log('[izui] Exiting overlay, restoring', hiddenIds.length, 'windows'); 234 + 235 + // Restore hidden windows via provider 236 + for (const id of hiddenIds) { 237 + const win = this._windowProvider.fromId(id); 238 + if (win && !win.isDestroyed()) { 239 + win.show(); 240 + } 241 + } 242 + 243 + // Return to previous state based on entry mode 244 + this._state = this._session.entryMode; 245 + this._session.overlayWindowId = null; 246 + this._session.hiddenWindowIds = []; 247 + } 248 + 249 + /** 250 + * Track when a window gains focus 251 + * @param {number} windowId 252 + */ 253 + setFocusedWindow(windowId) { 254 + if (this._session) { 255 + this._session.focusedWindowId = windowId; 256 + } 257 + DEBUG && console.log('[izui] Focused window set:', windowId); 258 + } 259 + 260 + /** 261 + * Clear focus tracking when a window closes or loses focus 262 + * @param {number} windowId 263 + */ 264 + clearFocusedWindow(windowId) { 265 + if (this._session && this._session.focusedWindowId === windowId) { 266 + this._session.focusedWindowId = null; 267 + DEBUG && console.log('[izui] Cleared focused window:', windowId); 268 + } 269 + } 270 + 271 + /** 272 + * Add a window to the session's window stack 273 + * @param {number} windowId 274 + */ 275 + pushWindow(windowId) { 276 + if (!this._session) { 277 + DEBUG && console.log('[izui] Cannot push window without active session'); 278 + return; 279 + } 280 + 281 + if (!this._session.windowStack.includes(windowId)) { 282 + this._session.windowStack.push(windowId); 283 + DEBUG && console.log('[izui] Pushed window:', windowId, 'stack size:', this._session.windowStack.length); 284 + } 285 + } 286 + 287 + /** 288 + * Remove a window from the session's window stack 289 + * If this was the last window, end the session 290 + * @param {number} windowId 291 + */ 292 + popWindow(windowId) { 293 + if (!this._session) { 294 + return; 295 + } 296 + 297 + const index = this._session.windowStack.indexOf(windowId); 298 + if (index !== -1) { 299 + this._session.windowStack.splice(index, 1); 300 + DEBUG && console.log('[izui] Popped window:', windowId, 'remaining:', this._session.windowStack.length); 301 + } 302 + 303 + // End session if no more windows 304 + if (this._session.windowStack.length === 0) { 305 + this.endSession(); 306 + } 307 + } 308 + 309 + /** 310 + * Check if currently in transient mode 311 + * @returns {boolean} 312 + */ 313 + isTransient() { 314 + if (!this._session) { 315 + return false; 316 + } 317 + return this._session.entryMode === 'transient'; 318 + } 319 + 320 + /** 321 + * Get the current IZUI state 322 + * @returns {'idle'|'transient'|'active'|'overlay'} 323 + */ 324 + getState() { 325 + return this._state; 326 + } 327 + 328 + /** 329 + * Get the current session info 330 + * @returns {IzuiSession|null} 331 + */ 332 + getSession() { 333 + return this._session; 334 + } 335 + 336 + /** 337 + * Get the effective mode for display 338 + * @param {number} [targetWindowId] Optional window to get mode for 339 + * @returns {'default'|'active'} 340 + */ 341 + getEffectiveMode(targetWindowId) { 342 + if (this.isTransient()) { 343 + return 'default'; 344 + } 345 + return 'active'; 346 + } 347 + 348 + /** 349 + * Check if currently in overlay mode 350 + * @returns {boolean} 351 + */ 352 + isOverlay() { 353 + return this._state === 'overlay'; 354 + } 355 + 356 + /** 357 + * Get the focused window ID from the session 358 + * @returns {number|null} 359 + */ 360 + getFocusedWindowId() { 361 + return this._session?.focusedWindowId ?? null; 362 + } 363 + } 364 + 365 + // ============================================================================ 366 + // ESC Policy 367 + // ============================================================================ 368 + 369 + /** 370 + * ESC policy: given session state and window role, determine what to do. 371 + * Pure function — no side effects. 372 + * 373 + * @param {string} sessionState - Current IZUI state ('idle'|'transient'|'active'|'overlay') 374 + * @param {string} role - Window role ('quick-view'|'palette'|'utility'|'overlay'|'child-content'|'workspace'|'content') 375 + * @returns {'close'|'close-and-restore'|'nothing'} 376 + */ 377 + function escPolicy(sessionState, role) { 378 + // These roles always close regardless of session state 379 + if (['quick-view', 'palette', 'utility'].includes(role)) return 'close'; 380 + if (role === 'overlay') return 'close-and-restore'; 381 + if (role === 'child-content') return 'close'; 382 + // workspace and content: only close in transient sessions 383 + if (sessionState === 'transient') return 'close'; 384 + return 'nothing'; 385 + } 386 + 387 + // ============================================================================ 388 + // Singleton & Exports 389 + // ============================================================================ 390 + 391 + const coordinator = new IzuiStateCoordinator(); 392 + 393 + /** 394 + * Get the IZUI State Coordinator singleton 395 + * @returns {IzuiStateCoordinator} 396 + */ 397 + function getIzuiCoordinator() { 398 + return coordinator; 399 + } 400 + 401 + export { 402 + IzuiStateCoordinator, 403 + getIzuiCoordinator, 404 + escPolicy, 405 + };
+26 -356
backend/electron/izui-state.ts
··· 1 1 /** 2 - * IZUI State Coordinator 3 - * 4 - * Centralized state machine for managing IZUI (Invocable Zoom User Interface) states. 5 - * See docs/izui.md for comprehensive documentation. 6 - * 7 - * Problems solved: 8 - * - Transient state frozen at creation for keepLive windows 9 - * - No session concept for unified state 10 - * - Stale focus trackers never cleaned up 2 + * IZUI State Coordinator — Electron adapter 11 3 * 12 - * States: 13 - * - IDLE: No visible windows, app in background 14 - * - TRANSIENT: Invoked via global hotkey while unfocused 15 - * - ACTIVE: User working within focused Peek windows 16 - * - OVERLAY: Full-screen overlay (windows picker) 4 + * Thin wrapper around the shared IZUI state machine (app/lib/izui-state.js). 5 + * Sets up the Electron-specific WindowProvider using BrowserWindow. 17 6 * 18 - * Key Rules: 19 - * | State | Mode Display | ESC Behavior | 20 - * |-----------|----------------------|--------------------------------| 21 - * | TRANSIENT | Always "default" | Close immediately | 22 - * | ACTIVE | Target window's mode | Internal nav only, never close | 23 - * | OVERLAY | Inherit from previous| Always close, restore hidden | 7 + * All state machine logic lives in the shared module. 8 + * See docs/izui.md for comprehensive documentation. 24 9 */ 25 10 26 - import { DEBUG, isHeadless } from './config.js'; 11 + import { DEBUG } from './config.js'; 27 12 28 - // Lazy-load Electron modules to allow testing without Electron 29 - let BrowserWindow: typeof import('electron').BrowserWindow | null = null; 13 + // Re-export everything from the shared module 14 + // Path is ../../../ because at runtime this executes from dist/backend/electron/ 15 + // @ts-expect-error — shared JS module in app/lib, no .d.ts declaration file 16 + import { IzuiStateCoordinator, getIzuiCoordinator, escPolicy } from '../../../app/lib/izui-state.js'; 30 17 31 - try { 32 - const electron = await import('electron'); 33 - BrowserWindow = electron.BrowserWindow; 34 - } catch { 35 - // Electron not available (e.g., in unit tests) 36 - DEBUG && console.log('[izui-state] Running without Electron (test mode)'); 37 - } 18 + export { IzuiStateCoordinator, getIzuiCoordinator, escPolicy }; 38 19 20 + // Re-export types for TypeScript consumers 39 21 export type IzuiState = 'idle' | 'transient' | 'active' | 'overlay'; 40 22 41 - /** 42 - * Window provider interface for dependency injection (enables testing) 43 - */ 44 23 export interface WindowProvider { 45 24 getAllWindows(): Array<{ id: number; isDestroyed(): boolean; isFocused(): boolean; isVisible(): boolean; webContents?: { getURL(): string } }>; 46 25 fromId(id: number): { show(): void; isDestroyed(): boolean } | null; 47 26 } 48 27 49 - /** 50 - * Default window provider using Electron's BrowserWindow (or stub for testing) 51 - */ 52 - const defaultWindowProvider: WindowProvider = { 53 - getAllWindows: () => BrowserWindow?.getAllWindows() ?? [], 54 - fromId: (id: number) => BrowserWindow?.fromId(id) ?? null, 55 - }; 56 - 57 28 export interface IzuiSession { 58 29 id: string; 59 30 entryMode: 'active' | 'transient'; ··· 63 34 focusedWindowId: number | null; 64 35 } 65 36 66 - /** 67 - * Generate a unique session ID 68 - */ 69 - function generateSessionId(): string { 70 - return `izui-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; 71 - } 72 - 73 - /** 74 - * IZUI State Coordinator singleton 75 - */ 76 - class IzuiStateCoordinator { 77 - private state: IzuiState = 'idle'; 78 - private session: IzuiSession | null = null; 79 - private windowProvider: WindowProvider = defaultWindowProvider; 80 - 81 - // OS-level app focus state. True when Peek is the active app. 82 - // Updated by browser-window-focus/blur events in main.ts. 83 - // Initial value is true: at app launch, Peek IS the active app. 84 - // In headless mode, stays true (no real focus events fire). 85 - private appFocused: boolean = true; 86 - 87 - /** 88 - * Set custom window provider (for testing) 89 - */ 90 - _setWindowProvider(provider: WindowProvider): void { 91 - this.windowProvider = provider; 92 - } 93 - 94 - /** 95 - * Reset to default window provider 96 - */ 97 - _resetWindowProvider(): void { 98 - this.windowProvider = defaultWindowProvider; 99 - } 100 - 101 - /** 102 - * Update OS-level app focus state. 103 - * Called from main.ts browser-window-focus/blur event handlers. 104 - */ 105 - setAppFocused(focused: boolean): void { 106 - this.appFocused = focused; 107 - DEBUG && console.log('[izui] App focus changed:', focused); 108 - 109 - // One-way ratchet: when app gains focus during a transient session, 110 - // promote to active. The user is now working in Peek. 111 - if (focused && this.session && this.session.entryMode === 'transient') { 112 - this.session.entryMode = 'active'; 113 - this.state = 'active'; 114 - DEBUG && console.log('[izui] Promoted session from transient to active (app gained focus)'); 115 - } 116 - } 117 - 118 - /** 119 - * Check if app currently has OS-level focus. 120 - */ 121 - isAppFocused(): boolean { 122 - return this.appFocused; 123 - } 124 - 125 - /** 126 - * Reset all state (for testing only) 127 - */ 128 - _resetAll(): void { 129 - this.state = 'idle'; 130 - this.session = null; 131 - this.appFocused = true; 132 - } 133 - 134 - /** 135 - * Start a new IZUI session when the first window opens 136 - * @param entryMode How the session was started (active if app was focused, transient if not) 137 - */ 138 - startSession(entryMode: 'active' | 'transient'): void { 139 - if (this.session) { 140 - DEBUG && console.log('[izui] Session already active, not starting new one'); 141 - return; 142 - } 143 - 144 - this.session = { 145 - id: generateSessionId(), 146 - entryMode, 147 - windowStack: [], 148 - overlayWindowId: null, 149 - hiddenWindowIds: [], 150 - focusedWindowId: null, 151 - }; 152 - 153 - this.state = entryMode; 154 - DEBUG && console.log('[izui] Started session:', this.session.id, 'entryMode:', entryMode); 155 - } 156 - 157 - /** 158 - * End the current IZUI session when the last window closes 159 - */ 160 - endSession(): void { 161 - if (!this.session) { 162 - DEBUG && console.log('[izui] No session to end'); 163 - return; 164 - } 165 - 166 - DEBUG && console.log('[izui] Ending session:', this.session.id); 167 - this.session = null; 168 - this.state = 'idle'; 169 - } 170 - 171 - /** 172 - * Evaluate transient state based on OS-level app focus. 173 - * 174 - * IZUI policy: transient = Peek was NOT the active/focused app at the OS 175 - * window manager level when the window was opened. This is the single source 176 - * of truth for transient detection. 177 - * 178 - * Called from: 179 - * - window-open handler (before window is shown, so focus state is still accurate) 180 - * - keepLive reuse path (re-evaluates on each show) 181 - * - izui-is-transient IPC handler (when no session exists) 182 - * 183 - * @param _excludeWindowId Unused (kept for API compat), focus is now app-level 184 - * @returns 'active' if Peek has OS focus, 'transient' if not 185 - */ 186 - evaluateOnShow(_excludeWindowId?: number): 'active' | 'transient' { 187 - // IZUI policy: app focused → active, app not focused → transient. 188 - // appFocused is tracked via browser-window-focus/blur events in main.ts. 189 - // At startup, appFocused defaults to true (app is active when launched). 190 - // In headless mode, appFocused stays true (no real focus events fire). 191 - const result = this.appFocused ? 'active' : 'transient'; 192 - 193 - DEBUG && console.log('[izui] evaluateOnShow:', result, 'appFocused:', this.appFocused); 194 - 195 - if (!this.session) { 196 - this.startSession(result); 197 - } else { 198 - // One-way ratchet: transient -> active is allowed, active -> transient is not. 199 - // Once a session becomes active (user is working in Peek), it stays active. 200 - if (this.session.entryMode === 'transient' && result === 'active') { 201 - this.session.entryMode = 'active'; 202 - this.state = 'active'; 203 - DEBUG && console.log('[izui] Promoted session from transient to active'); 204 - } else { 205 - DEBUG && console.log('[izui] Session entryMode unchanged:', this.session.entryMode, '(evaluated:', result, ')'); 206 - } 207 - } 208 - 209 - return result; 210 - } 211 - 212 - /** 213 - * Enter overlay mode (e.g., windows picker) 214 - * @param windowId The overlay window's ID 215 - * @param hiddenIds IDs of windows that were hidden for the overlay 216 - */ 217 - enterOverlay(windowId: number, hiddenIds: number[]): void { 218 - if (!this.session) { 219 - DEBUG && console.log('[izui] Cannot enter overlay without active session'); 220 - return; 221 - } 222 - 223 - this.state = 'overlay'; 224 - this.session.overlayWindowId = windowId; 225 - this.session.hiddenWindowIds = hiddenIds; 226 - DEBUG && console.log('[izui] Entered overlay mode, windowId:', windowId, 'hidden:', hiddenIds.length); 227 - } 228 - 229 - /** 230 - * Exit overlay mode and restore hidden windows 231 - */ 232 - exitOverlay(): void { 233 - if (!this.session || this.state !== 'overlay') { 234 - DEBUG && console.log('[izui] Not in overlay mode'); 235 - return; 236 - } 237 - 238 - const hiddenIds = this.session.hiddenWindowIds; 239 - DEBUG && console.log('[izui] Exiting overlay, restoring', hiddenIds.length, 'windows'); 240 - 241 - // Restore hidden windows 242 - for (const id of hiddenIds) { 243 - const win = this.windowProvider.fromId(id); 244 - if (win && !win.isDestroyed()) { 245 - win.show(); 246 - } 247 - } 248 - 249 - // Return to previous state based on entry mode 250 - this.state = this.session.entryMode; 251 - this.session.overlayWindowId = null; 252 - this.session.hiddenWindowIds = []; 253 - } 254 - 255 - /** 256 - * Track when a window gains focus 257 - * @param windowId The window that gained focus 258 - */ 259 - setFocusedWindow(windowId: number): void { 260 - if (this.session) { 261 - this.session.focusedWindowId = windowId; 262 - } 263 - DEBUG && console.log('[izui] Focused window set:', windowId); 264 - } 265 - 266 - /** 267 - * Clear focus tracking when a window closes or loses focus 268 - * This prevents stale window IDs from being tracked 269 - * @param windowId The window to clear 270 - */ 271 - clearFocusedWindow(windowId: number): void { 272 - if (this.session && this.session.focusedWindowId === windowId) { 273 - this.session.focusedWindowId = null; 274 - DEBUG && console.log('[izui] Cleared focused window:', windowId); 275 - } 276 - } 37 + // Set up Electron-specific WindowProvider 38 + // Lazy-load to allow testing without Electron 39 + try { 40 + const electron = await import('electron'); 41 + const BrowserWindow = electron.BrowserWindow; 277 42 278 - /** 279 - * Add a window to the session's window stack 280 - * @param windowId The window to add 281 - */ 282 - pushWindow(windowId: number): void { 283 - if (!this.session) { 284 - DEBUG && console.log('[izui] Cannot push window without active session'); 285 - return; 286 - } 43 + const electronWindowProvider = { 44 + getAllWindows: () => BrowserWindow.getAllWindows(), 45 + fromId: (id: number) => BrowserWindow.fromId(id), 46 + }; 287 47 288 - if (!this.session.windowStack.includes(windowId)) { 289 - this.session.windowStack.push(windowId); 290 - DEBUG && console.log('[izui] Pushed window:', windowId, 'stack size:', this.session.windowStack.length); 291 - } 292 - } 293 - 294 - /** 295 - * Remove a window from the session's window stack 296 - * If this was the last window, end the session 297 - * @param windowId The window to remove 298 - */ 299 - popWindow(windowId: number): void { 300 - if (!this.session) { 301 - return; 302 - } 303 - 304 - const index = this.session.windowStack.indexOf(windowId); 305 - if (index !== -1) { 306 - this.session.windowStack.splice(index, 1); 307 - DEBUG && console.log('[izui] Popped window:', windowId, 'remaining:', this.session.windowStack.length); 308 - } 309 - 310 - // End session if no more windows 311 - if (this.session.windowStack.length === 0) { 312 - this.endSession(); 313 - } 314 - } 315 - 316 - /** 317 - * Check if currently in transient mode 318 - * This is the primary query for determining mode display and ESC behavior 319 - */ 320 - isTransient(): boolean { 321 - if (!this.session) { 322 - return false; 323 - } 324 - // Transient means opened without app focus 325 - return this.session.entryMode === 'transient'; 326 - } 327 - 328 - /** 329 - * Get the current IZUI state 330 - */ 331 - getState(): IzuiState { 332 - return this.state; 333 - } 334 - 335 - /** 336 - * Get the current session info (for debugging) 337 - */ 338 - getSession(): IzuiSession | null { 339 - return this.session; 340 - } 341 - 342 - /** 343 - * Get the effective mode for display 344 - * @param targetWindowId Optional window to get mode for 345 - * @returns 'default' for transient sessions, actual mode otherwise 346 - */ 347 - getEffectiveMode(targetWindowId?: number): string { 348 - // In transient mode, always show "default" 349 - if (this.isTransient()) { 350 - return 'default'; 351 - } 352 - 353 - // For active sessions, return the window's actual mode 354 - // This will be resolved by the caller using the context API 355 - return 'active'; 356 - } 357 - 358 - /** 359 - * Check if currently in overlay mode 360 - */ 361 - isOverlay(): boolean { 362 - return this.state === 'overlay'; 363 - } 364 - 365 - /** 366 - * Get the focused window ID from the session 367 - */ 368 - getFocusedWindowId(): number | null { 369 - return this.session?.focusedWindowId ?? null; 370 - } 48 + getIzuiCoordinator().setWindowProvider(electronWindowProvider); 49 + DEBUG && console.log('[izui-state] Electron WindowProvider installed'); 50 + } catch { 51 + // Electron not available (e.g., in unit tests) 52 + DEBUG && console.log('[izui-state] Running without Electron (test mode)'); 371 53 } 372 - 373 - // Singleton instance 374 - const coordinator = new IzuiStateCoordinator(); 375 - 376 - /** 377 - * Get the IZUI State Coordinator singleton 378 - */ 379 - export function getIzuiCoordinator(): IzuiStateCoordinator { 380 - return coordinator; 381 - } 382 - 383 - export { IzuiStateCoordinator };
+2 -13
backend/electron/windows.ts
··· 25 25 26 26 import { 27 27 getIzuiCoordinator, 28 + escPolicy, 28 29 } from './izui-state.js'; 29 30 30 31 /** ··· 99 100 }); 100 101 } 101 102 102 - /** 103 - * ESC policy: given session state and window role, determine what to do. 104 - * Pure function — no side effects. 105 - */ 106 - function escPolicy(sessionState: string, role: string): 'close' | 'close-and-restore' | 'nothing' { 107 - // These roles always close regardless of session state 108 - if (['quick-view', 'palette', 'utility'].includes(role)) return 'close'; 109 - if (role === 'overlay') return 'close-and-restore'; 110 - if (role === 'child-content') return 'close'; 111 - // workspace and content: only close in transient sessions 112 - if (sessionState === 'transient') return 'close'; 113 - return 'nothing'; 114 - } 103 + // escPolicy is imported from the shared IZUI module via ./izui-state.js 115 104 116 105 /** 117 106 * Core ESC handling logic for a BrowserWindow.