experiments in a post-browser web
10
fork

Configure Feed

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

fix: revert cross-boundary TS imports, add .d.ts for shared modules

The Electron TS backend can't import from app/lib/ at runtime (compiled
to dist/ which doesn't include app/). Restore original TS implementations
and add .d.ts declaration files for the shared JS modules.

+434 -28
+12
app/lib/frecency.d.ts
··· 1 + export declare const DECAY_HALF_LIFE_DAYS: number; 2 + export declare const VISIT_WEIGHTS: { 3 + interacted: number; 4 + direct: number; 5 + default: number; 6 + }; 7 + 8 + export declare function calculateItemFrecency( 9 + visits: Array<{ timestamp: number; interacted: number; source: string }> 10 + ): number; 11 + 12 + export default calculateItemFrecency;
+53
app/lib/izui-state.d.ts
··· 1 + export type IzuiState = 'idle' | 'transient' | 'active' | 'overlay'; 2 + 3 + export interface WindowInfo { 4 + id: number; 5 + isDestroyed(): boolean; 6 + isFocused(): boolean; 7 + isVisible(): boolean; 8 + } 9 + 10 + export interface WindowProvider { 11 + getAllWindows(): WindowInfo[]; 12 + fromId(id: number): { show(): void; isDestroyed(): boolean } | null; 13 + } 14 + 15 + export interface IzuiSession { 16 + id: string; 17 + entryMode: 'active' | 'transient'; 18 + windowStack: number[]; 19 + overlayWindowId: number | null; 20 + hiddenWindowIds: number[]; 21 + focusedWindowId: number | null; 22 + } 23 + 24 + export declare class IzuiStateCoordinator { 25 + setWindowProvider(provider: WindowProvider): void; 26 + _setWindowProvider(provider: WindowProvider): void; 27 + resetWindowProvider(): void; 28 + _resetWindowProvider(): void; 29 + setAppFocused(focused: boolean): void; 30 + isAppFocused(): boolean; 31 + _resetAll(): void; 32 + startSession(entryMode: 'active' | 'transient'): void; 33 + endSession(): void; 34 + evaluateOnShow(excludeWindowId?: number): 'active' | 'transient'; 35 + enterOverlay(windowId: number, hiddenIds: number[]): void; 36 + exitOverlay(): void; 37 + setFocusedWindow(windowId: number): void; 38 + clearFocusedWindow(windowId: number): void; 39 + pushWindow(windowId: number): void; 40 + popWindow(windowId: number): void; 41 + isTransient(): boolean; 42 + getState(): IzuiState; 43 + getSession(): IzuiSession | null; 44 + getEffectiveMode(targetWindowId?: number): 'default' | 'active'; 45 + isOverlay(): boolean; 46 + getFocusedWindowId(): number | null; 47 + } 48 + 49 + export declare function getIzuiCoordinator(): IzuiStateCoordinator; 50 + export declare function escPolicy( 51 + sessionState: string, 52 + role: string 53 + ): 'close' | 'close-and-restore' | 'nothing';
+356 -26
backend/electron/izui-state.ts
··· 1 1 /** 2 - * IZUI State Coordinator — Electron adapter 3 - * 4 - * Thin wrapper around the shared IZUI state machine (app/lib/izui-state.js). 5 - * Sets up the Electron-specific WindowProvider using BrowserWindow. 2 + * IZUI State Coordinator 6 3 * 7 - * All state machine logic lives in the shared module. 4 + * Centralized state machine for managing IZUI (Invocable Zoom User Interface) states. 8 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 11 + * 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) 17 + * 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 | 9 24 */ 10 25 11 - import { DEBUG } from './config.js'; 26 + import { DEBUG, isHeadless } from './config.js'; 12 27 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'; 28 + // Lazy-load Electron modules to allow testing without Electron 29 + let BrowserWindow: typeof import('electron').BrowserWindow | null = null; 17 30 18 - export { IzuiStateCoordinator, getIzuiCoordinator, escPolicy }; 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 + } 19 38 20 - // Re-export types for TypeScript consumers 21 39 export type IzuiState = 'idle' | 'transient' | 'active' | 'overlay'; 22 40 41 + /** 42 + * Window provider interface for dependency injection (enables testing) 43 + */ 23 44 export interface WindowProvider { 24 45 getAllWindows(): Array<{ id: number; isDestroyed(): boolean; isFocused(): boolean; isVisible(): boolean; webContents?: { getURL(): string } }>; 25 46 fromId(id: number): { show(): void; isDestroyed(): boolean } | null; 26 47 } 27 48 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 + 28 57 export interface IzuiSession { 29 58 id: string; 30 59 entryMode: 'active' | 'transient'; ··· 34 63 focusedWindowId: number | null; 35 64 } 36 65 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; 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 + } 42 165 43 - const electronWindowProvider = { 44 - getAllWindows: () => BrowserWindow.getAllWindows(), 45 - fromId: (id: number) => BrowserWindow.fromId(id), 46 - }; 166 + DEBUG && console.log('[izui] Ending session:', this.session.id); 167 + this.session = null; 168 + this.state = 'idle'; 169 + } 47 170 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)'); 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 + } 277 + 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 + } 287 + 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 + } 53 371 } 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 };
+13 -2
backend/electron/windows.ts
··· 25 25 26 26 import { 27 27 getIzuiCoordinator, 28 - escPolicy, 29 28 } from './izui-state.js'; 30 29 31 30 /** ··· 100 99 }); 101 100 } 102 101 103 - // escPolicy is imported from the shared IZUI module via ./izui-state.js 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 + } 104 115 105 116 /** 106 117 * Core ESC handling logic for a BrowserWindow.