experiments in a post-browser web
10
fork

Configure Feed

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

refactor(modes): remove modes.ts, consolidate into context API and datastore

+169 -808
+92 -5
backend/electron/datastore.ts
··· 1478 1478 const valueJson = JSON.stringify(value); 1479 1479 const metadataJson = JSON.stringify(metadata); 1480 1480 1481 - // Insert into database 1482 - getDb().prepare(` 1483 - INSERT INTO context_history (id, key, value, metadata, windowId, source, timestamp, prevEntryId) 1484 - VALUES (?, ?, ?, ?, ?, ?, ?, ?) 1485 - `).run(entryId, key, valueJson, metadataJson, windowId, source, timestamp, prevEntryId); 1481 + // Insert into database (if initialized - may not be during tests) 1482 + if (db) { 1483 + db.prepare(` 1484 + INSERT INTO context_history (id, key, value, metadata, windowId, source, timestamp, prevEntryId) 1485 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 1486 + `).run(entryId, key, valueJson, metadataJson, windowId, source, timestamp, prevEntryId); 1487 + } 1486 1488 1487 1489 // Update in-memory state 1488 1490 const entry: ContextEntry = { ··· 1663 1665 } 1664 1666 } 1665 1667 return windowIds; 1668 + } 1669 + 1670 + // ==================== Mode Helpers (Context-based replacements for modes.ts) ==================== 1671 + 1672 + export type MajorModeId = 'page' | 'group' | 'settings' | 'default'; 1673 + export type MinorModeId = 'preview' | 'edit' | 'annotate' | 'search'; 1674 + 1675 + /** 1676 + * Detect the appropriate major mode based on URL 1677 + */ 1678 + export function detectModeFromUrl(url: string): MajorModeId { 1679 + if (!url) return 'default'; 1680 + 1681 + // Settings page 1682 + if (url.includes('/settings/') || url.includes('settings.html')) { 1683 + return 'settings'; 1684 + } 1685 + 1686 + // Groups extension 1687 + if (url.includes('/groups/') || url.includes('groups.html')) { 1688 + return 'group'; 1689 + } 1690 + 1691 + // Web pages 1692 + if (url.startsWith('http://') || url.startsWith('https://')) { 1693 + return 'page'; 1694 + } 1695 + 1696 + return 'default'; 1697 + } 1698 + 1699 + /** 1700 + * Update mode for navigation - sets context based on URL 1701 + */ 1702 + export function updateModeForNavigation(windowId: number, url: string): void { 1703 + const detectedMode = detectModeFromUrl(url); 1704 + const currentEntry = getContextEntry('mode', windowId); 1705 + 1706 + // Don't override group mode with page mode (group mode persists) 1707 + if (currentEntry?.value === 'group' && detectedMode === 'page') { 1708 + DEBUG && console.log('main', `Preserving group mode for window ${windowId} during navigation`); 1709 + return; 1710 + } 1711 + 1712 + if (!currentEntry || currentEntry.value !== detectedMode) { 1713 + addContextEntry('mode', detectedMode, { 1714 + windowId, 1715 + source: 'navigation', 1716 + metadata: { url } 1717 + }); 1718 + } 1719 + } 1720 + 1721 + /** 1722 + * Check if mode conditions are satisfied for a window 1723 + * Used by shortcuts system for mode-conditional shortcuts 1724 + */ 1725 + export function checkModeConditions( 1726 + windowId: number, 1727 + requiredMajorMode?: MajorModeId, 1728 + requiredMinorModes?: MinorModeId[] 1729 + ): boolean { 1730 + const entry = getContextEntry('mode', windowId); 1731 + const currentMode = entry?.value as MajorModeId | undefined; 1732 + 1733 + // No mode state = default mode 1734 + if (!currentMode) { 1735 + if (requiredMajorMode && requiredMajorMode !== 'default') { 1736 + return false; 1737 + } 1738 + if (requiredMinorModes && requiredMinorModes.length > 0) { 1739 + return false; 1740 + } 1741 + return true; 1742 + } 1743 + 1744 + // Check major mode 1745 + if (requiredMajorMode && currentMode !== requiredMajorMode) { 1746 + return false; 1747 + } 1748 + 1749 + // Minor modes not implemented in context API yet - always pass 1750 + // (minor modes were rarely used and can be added later if needed) 1751 + 1752 + return true; 1666 1753 } 1667 1754 1668 1755 // ==================== Version Check ====================
+67 -79
backend/electron/ipc.ts
··· 163 163 } from './backup.js'; 164 164 165 165 import { 166 - getWindowModeState, 167 - setMajorMode, 168 - enableMinorMode, 169 - disableMinorMode, 170 - toggleMinorMode, 171 - getAllModes, 172 - buildCommandContext, 173 - cleanupWindowMode, 174 166 detectModeFromUrl, 175 167 type MajorModeId, 176 168 type MinorModeId, 177 - } from './modes.js'; 169 + } from './datastore.js'; 178 170 179 171 // ============================================================================ 180 172 // Window Focus Tracking for Window-Targeted Commands ··· 1753 1745 } 1754 1746 } 1755 1747 1756 - // Also set via old modes API for backwards compatibility 1748 + // Log the detected mode 1757 1749 const detectedMode = detectModeFromUrl(modeUrl); 1758 - setMajorMode(win.id, detectedMode); 1759 1750 DEBUG && console.log('Set window mode:', win.id, 'url:', modeUrl, 'mode:', detectedMode); 1760 1751 1761 1752 // Track this load in history (skip internal peek:// URLs) ··· 2923 2914 /** 2924 2915 * Register modes-related IPC handlers 2925 2916 * 2926 - * Handles mode state queries and changes for the context-aware command system. 2917 + * These handlers provide backwards compatibility with the old modes API. 2918 + * New code should use the context API (api.context) instead. 2927 2919 */ 2928 2920 export function registerModesHandlers(): void { 2929 - // Get mode state for a window 2921 + // Mode definitions (hardcoded since modes.ts is removed) 2922 + const MAJOR_MODES = [ 2923 + { id: 'default', name: 'Default', description: 'Standard browsing mode', type: 'major' }, 2924 + { id: 'page', name: 'Page', description: 'Viewing a web page', type: 'major' }, 2925 + { id: 'group', name: 'Group', description: 'Managing tab groups', type: 'major' }, 2926 + { id: 'settings', name: 'Settings', description: 'Application settings', type: 'major' }, 2927 + ]; 2928 + const MINOR_MODES = [ 2929 + { id: 'preview', name: 'Preview', description: 'Preview mode (read-only)', type: 'minor' }, 2930 + { id: 'edit', name: 'Edit', description: 'Edit mode', type: 'minor' }, 2931 + { id: 'annotate', name: 'Annotate', description: 'Annotation mode', type: 'minor' }, 2932 + { id: 'search', name: 'Search', description: 'Search mode active', type: 'minor' }, 2933 + ]; 2934 + 2935 + // Get mode state for a window (uses context API) 2930 2936 ipcMain.handle('modes:getWindowMode', async (ev, data: { windowId?: number | null }) => { 2931 2937 try { 2932 2938 let windowId = data.windowId; 2933 2939 2934 - // If no windowId provided, try to get the calling window or last focused 2935 2940 if (windowId === undefined || windowId === null) { 2936 2941 const callingWin = BrowserWindow.fromWebContents(ev.sender); 2937 2942 windowId = callingWin?.id ?? lastFocusedVisibleWindowId; ··· 2941 2946 return { success: false, error: 'No target window' }; 2942 2947 } 2943 2948 2944 - const state = getWindowModeState(windowId); 2949 + const entry = getContextEntry('mode', windowId); 2950 + const state = { 2951 + major: (entry?.value as MajorModeId) || 'default', 2952 + minors: [] as MinorModeId[] // Minor modes not implemented in context API 2953 + }; 2945 2954 return { success: true, data: state }; 2946 2955 } catch (error) { 2947 2956 const message = error instanceof Error ? error.message : String(error); ··· 2949 2958 } 2950 2959 }); 2951 2960 2952 - // Set major mode for a window 2961 + // Set major mode for a window (uses context API) 2953 2962 ipcMain.handle('modes:setMajorMode', async (ev, data: { mode: MajorModeId; windowId?: number | null }) => { 2954 2963 try { 2955 2964 let windowId = data.windowId; 2965 + let source = ''; 2956 2966 2957 2967 if (windowId === undefined || windowId === null) { 2958 2968 const callingWin = BrowserWindow.fromWebContents(ev.sender); 2959 2969 windowId = callingWin?.id ?? lastFocusedVisibleWindowId; 2970 + source = callingWin?.webContents.getURL() ?? ''; 2960 2971 } 2961 2972 2962 2973 if (windowId === null || windowId === undefined) { 2963 2974 return { success: false, error: 'No target window' }; 2964 2975 } 2965 2976 2966 - setMajorMode(windowId, data.mode); 2967 - return { success: true }; 2968 - } catch (error) { 2969 - const message = error instanceof Error ? error.message : String(error); 2970 - return { success: false, error: message }; 2971 - } 2972 - }); 2977 + addContextEntry('mode', data.mode, { windowId, source }); 2973 2978 2974 - // Enable a minor mode 2975 - ipcMain.handle('modes:enableMinorMode', async (ev, data: { mode: MinorModeId; windowId?: number | null }) => { 2976 - try { 2977 - let windowId = data.windowId; 2979 + // Publish mode change for backwards compatibility 2980 + publish(getSystemAddress(), PubSubScopes.GLOBAL, 'modes:changed', { 2981 + windowId, 2982 + major: data.mode, 2983 + minors: [], 2984 + }); 2978 2985 2979 - if (windowId === undefined || windowId === null) { 2980 - const callingWin = BrowserWindow.fromWebContents(ev.sender); 2981 - windowId = callingWin?.id ?? lastFocusedVisibleWindowId; 2982 - } 2983 - 2984 - if (windowId === null || windowId === undefined) { 2985 - return { success: false, error: 'No target window' }; 2986 - } 2987 - 2988 - enableMinorMode(windowId, data.mode); 2989 2986 return { success: true }; 2990 2987 } catch (error) { 2991 2988 const message = error instanceof Error ? error.message : String(error); ··· 2993 2990 } 2994 2991 }); 2995 2992 2996 - // Disable a minor mode 2997 - ipcMain.handle('modes:disableMinorMode', async (ev, data: { mode: MinorModeId; windowId?: number | null }) => { 2998 - try { 2999 - let windowId = data.windowId; 2993 + // Minor modes - stub implementations (not used in context API) 2994 + ipcMain.handle('modes:enableMinorMode', async () => { 2995 + return { success: true }; // No-op, minor modes not implemented 2996 + }); 3000 2997 3001 - if (windowId === undefined || windowId === null) { 3002 - const callingWin = BrowserWindow.fromWebContents(ev.sender); 3003 - windowId = callingWin?.id ?? lastFocusedVisibleWindowId; 3004 - } 3005 - 3006 - if (windowId === null || windowId === undefined) { 3007 - return { success: false, error: 'No target window' }; 3008 - } 3009 - 3010 - disableMinorMode(windowId, data.mode); 3011 - return { success: true }; 3012 - } catch (error) { 3013 - const message = error instanceof Error ? error.message : String(error); 3014 - return { success: false, error: message }; 3015 - } 2998 + ipcMain.handle('modes:disableMinorMode', async () => { 2999 + return { success: true }; // No-op, minor modes not implemented 3016 3000 }); 3017 3001 3018 - // Toggle a minor mode 3019 - ipcMain.handle('modes:toggleMinorMode', async (ev, data: { mode: MinorModeId; windowId?: number | null }) => { 3020 - try { 3021 - let windowId = data.windowId; 3022 - 3023 - if (windowId === undefined || windowId === null) { 3024 - const callingWin = BrowserWindow.fromWebContents(ev.sender); 3025 - windowId = callingWin?.id ?? lastFocusedVisibleWindowId; 3026 - } 3027 - 3028 - if (windowId === null || windowId === undefined) { 3029 - return { success: false, error: 'No target window' }; 3030 - } 3031 - 3032 - const enabled = toggleMinorMode(windowId, data.mode); 3033 - return { success: true, data: enabled }; 3034 - } catch (error) { 3035 - const message = error instanceof Error ? error.message : String(error); 3036 - return { success: false, error: message }; 3037 - } 3002 + ipcMain.handle('modes:toggleMinorMode', async () => { 3003 + return { success: true, data: false }; // No-op, minor modes not implemented 3038 3004 }); 3039 3005 3040 3006 // List all available modes 3041 3007 ipcMain.handle('modes:listModes', async () => { 3042 3008 try { 3043 - const modes = getAllModes(); 3044 - return { success: true, data: modes }; 3009 + return { success: true, data: [...MAJOR_MODES, ...MINOR_MODES] }; 3045 3010 } catch (error) { 3046 3011 const message = error instanceof Error ? error.message : String(error); 3047 3012 return { success: false, error: message }; ··· 3051 3016 // Get command context for current state 3052 3017 ipcMain.handle('modes:getCommandContext', async () => { 3053 3018 try { 3054 - const context = buildCommandContext(lastFocusedVisibleWindowId); 3019 + const windowId = lastFocusedVisibleWindowId; 3020 + const entry = getContextEntry('mode', windowId); 3021 + 3022 + let url: string | null = null; 3023 + let title: string | null = null; 3024 + 3025 + if (windowId !== null) { 3026 + const win = BrowserWindow.fromId(windowId); 3027 + if (win && !win.isDestroyed()) { 3028 + url = win.webContents.getURL(); 3029 + title = win.getTitle(); 3030 + } 3031 + } 3032 + 3033 + const context = { 3034 + windowId, 3035 + mode: { 3036 + major: (entry?.value as MajorModeId) || 'default', 3037 + minors: [] as MinorModeId[] 3038 + }, 3039 + url, 3040 + title, 3041 + hasSelection: false 3042 + }; 3055 3043 return { success: true, data: context }; 3056 3044 } catch (error) { 3057 3045 const message = error instanceof Error ? error.message : String(error);
+1 -2
backend/electron/main.ts
··· 9 9 import path from 'node:path'; 10 10 import fs from 'node:fs'; 11 11 12 - import { initDatabase, closeDatabase, getDb, trackWindowLoad } from './datastore.js'; 12 + import { initDatabase, closeDatabase, getDb, trackWindowLoad, updateModeForNavigation } from './datastore.js'; 13 13 import { registerScheme, initProtocol, registerExtensionPath, getExtensionPath, getRegisteredExtensionIds, registerThemePath } from './protocol.js'; 14 14 import { discoverExtensions, loadExtensionManifest, isBuiltinExtensionEnabled, getExternalExtensions } from './extensions.js'; 15 15 import { initTray } from './tray.js'; ··· 17 17 import { scopes, publish, subscribe, setExtensionBroadcaster, getSystemAddress } from './pubsub.js'; 18 18 import { APP_DEF_WIDTH, APP_DEF_HEIGHT, WEB_CORE_ADDRESS, getPreloadPath, isTestProfile, isDevProfile, isHeadless, getProfile, DEBUG } from './config.js'; 19 19 import { addEscHandler, winDevtoolsConfig, closeOrHideWindow, getSystemThemeBackgroundColor, getPrefs } from './windows.js'; 20 - import { updateModeForNavigation } from './modes.js'; 21 20 22 21 // Configuration 23 22 export interface AppConfig {
-309
backend/electron/modes.test.ts
··· 1 - /** 2 - * Unit tests for Modes module 3 - * Tests the mode manager, mode state tracking, and mode-conditional logic 4 - */ 5 - 6 - import { describe, it, before, after, beforeEach } from 'node:test'; 7 - import * as assert from 'node:assert'; 8 - 9 - // Import will be done after ensuring module is compiled 10 - let modes: typeof import('./modes.js'); 11 - 12 - describe('Modes Module Tests', () => { 13 - before(async () => { 14 - // Dynamic import of the compiled module 15 - modes = await import('./modes.js'); 16 - }); 17 - 18 - describe('getWindowModeState', () => { 19 - it('should return default mode state for new window', () => { 20 - const state = modes.getWindowModeState(9999); 21 - assert.strictEqual(state.major, 'default'); 22 - assert.deepStrictEqual(state.minors, []); 23 - }); 24 - 25 - it('should return a copy, not the original state', () => { 26 - const state1 = modes.getWindowModeState(9998); 27 - const state2 = modes.getWindowModeState(9998); 28 - 29 - // Should be equal but not the same object 30 - assert.deepStrictEqual(state1, state2); 31 - assert.notStrictEqual(state1, state2); 32 - assert.notStrictEqual(state1.minors, state2.minors); 33 - }); 34 - }); 35 - 36 - describe('setMajorMode', () => { 37 - it('should set major mode for a window', () => { 38 - modes.setMajorMode(1001, 'page'); 39 - const state = modes.getWindowModeState(1001); 40 - assert.strictEqual(state.major, 'page'); 41 - }); 42 - 43 - it('should update existing major mode', () => { 44 - modes.setMajorMode(1002, 'page'); 45 - modes.setMajorMode(1002, 'group'); 46 - const state = modes.getWindowModeState(1002); 47 - assert.strictEqual(state.major, 'group'); 48 - }); 49 - 50 - it('should preserve minor modes when changing major mode', () => { 51 - modes.setMajorMode(1003, 'page'); 52 - modes.enableMinorMode(1003, 'edit'); 53 - modes.setMajorMode(1003, 'settings'); 54 - 55 - const state = modes.getWindowModeState(1003); 56 - assert.strictEqual(state.major, 'settings'); 57 - assert.ok(state.minors.includes('edit')); 58 - }); 59 - }); 60 - 61 - describe('enableMinorMode', () => { 62 - it('should enable a minor mode', () => { 63 - modes.setMajorMode(2001, 'default'); 64 - modes.enableMinorMode(2001, 'preview'); 65 - 66 - const state = modes.getWindowModeState(2001); 67 - assert.ok(state.minors.includes('preview')); 68 - }); 69 - 70 - it('should allow multiple minor modes', () => { 71 - modes.setMajorMode(2002, 'page'); 72 - modes.enableMinorMode(2002, 'preview'); 73 - modes.enableMinorMode(2002, 'edit'); 74 - modes.enableMinorMode(2002, 'search'); 75 - 76 - const state = modes.getWindowModeState(2002); 77 - assert.strictEqual(state.minors.length, 3); 78 - assert.ok(state.minors.includes('preview')); 79 - assert.ok(state.minors.includes('edit')); 80 - assert.ok(state.minors.includes('search')); 81 - }); 82 - 83 - it('should not duplicate minor modes', () => { 84 - modes.setMajorMode(2003, 'page'); 85 - modes.enableMinorMode(2003, 'edit'); 86 - modes.enableMinorMode(2003, 'edit'); 87 - 88 - const state = modes.getWindowModeState(2003); 89 - const editCount = state.minors.filter(m => m === 'edit').length; 90 - assert.strictEqual(editCount, 1); 91 - }); 92 - }); 93 - 94 - describe('disableMinorMode', () => { 95 - it('should disable a minor mode', () => { 96 - modes.setMajorMode(3001, 'page'); 97 - modes.enableMinorMode(3001, 'edit'); 98 - modes.disableMinorMode(3001, 'edit'); 99 - 100 - const state = modes.getWindowModeState(3001); 101 - assert.ok(!state.minors.includes('edit')); 102 - }); 103 - 104 - it('should only remove specified minor mode', () => { 105 - modes.setMajorMode(3002, 'page'); 106 - modes.enableMinorMode(3002, 'preview'); 107 - modes.enableMinorMode(3002, 'edit'); 108 - modes.disableMinorMode(3002, 'edit'); 109 - 110 - const state = modes.getWindowModeState(3002); 111 - assert.ok(state.minors.includes('preview')); 112 - assert.ok(!state.minors.includes('edit')); 113 - }); 114 - 115 - it('should handle disabling non-existent minor mode gracefully', () => { 116 - modes.setMajorMode(3003, 'page'); 117 - modes.disableMinorMode(3003, 'search'); // Never enabled 118 - 119 - const state = modes.getWindowModeState(3003); 120 - assert.deepStrictEqual(state.minors, []); 121 - }); 122 - }); 123 - 124 - describe('toggleMinorMode', () => { 125 - it('should enable when disabled', () => { 126 - modes.setMajorMode(4001, 'page'); 127 - const enabled = modes.toggleMinorMode(4001, 'edit'); 128 - 129 - assert.strictEqual(enabled, true); 130 - const state = modes.getWindowModeState(4001); 131 - assert.ok(state.minors.includes('edit')); 132 - }); 133 - 134 - it('should disable when enabled', () => { 135 - modes.setMajorMode(4002, 'page'); 136 - modes.enableMinorMode(4002, 'edit'); 137 - const enabled = modes.toggleMinorMode(4002, 'edit'); 138 - 139 - assert.strictEqual(enabled, false); 140 - const state = modes.getWindowModeState(4002); 141 - assert.ok(!state.minors.includes('edit')); 142 - }); 143 - }); 144 - 145 - describe('cleanupWindowMode', () => { 146 - it('should remove mode state for a window', () => { 147 - modes.setMajorMode(5001, 'settings'); 148 - modes.enableMinorMode(5001, 'edit'); 149 - modes.cleanupWindowMode(5001); 150 - 151 - // After cleanup, should get default state 152 - const state = modes.getWindowModeState(5001); 153 - assert.strictEqual(state.major, 'default'); 154 - assert.deepStrictEqual(state.minors, []); 155 - }); 156 - }); 157 - 158 - describe('getAllModes', () => { 159 - it('should return all major and minor modes', () => { 160 - const allModes = modes.getAllModes(); 161 - 162 - // Check major modes exist 163 - const majorModes = allModes.filter(m => m.type === 'major'); 164 - const majorIds = majorModes.map(m => m.id); 165 - assert.ok(majorIds.includes('default')); 166 - assert.ok(majorIds.includes('page')); 167 - assert.ok(majorIds.includes('group')); 168 - assert.ok(majorIds.includes('settings')); 169 - 170 - // Check minor modes exist 171 - const minorModes = allModes.filter(m => m.type === 'minor'); 172 - const minorIds = minorModes.map(m => m.id); 173 - assert.ok(minorIds.includes('preview')); 174 - assert.ok(minorIds.includes('edit')); 175 - assert.ok(minorIds.includes('annotate')); 176 - assert.ok(minorIds.includes('search')); 177 - }); 178 - 179 - it('should include names for all modes', () => { 180 - const allModes = modes.getAllModes(); 181 - for (const mode of allModes) { 182 - assert.ok(mode.name, `Mode ${mode.id} should have a name`); 183 - assert.ok(typeof mode.name === 'string'); 184 - } 185 - }); 186 - }); 187 - 188 - describe('isInMajorMode', () => { 189 - it('should return true when in the specified mode', () => { 190 - modes.setMajorMode(6001, 'page'); 191 - assert.strictEqual(modes.isInMajorMode(6001, 'page'), true); 192 - }); 193 - 194 - it('should return false when in a different mode', () => { 195 - modes.setMajorMode(6002, 'group'); 196 - assert.strictEqual(modes.isInMajorMode(6002, 'page'), false); 197 - }); 198 - 199 - it('should return false for unknown window', () => { 200 - // Fresh window ID that was never set 201 - assert.strictEqual(modes.isInMajorMode(99999, 'page'), false); 202 - }); 203 - }); 204 - 205 - describe('hasMinorMode', () => { 206 - it('should return true when minor mode is active', () => { 207 - modes.setMajorMode(7001, 'page'); 208 - modes.enableMinorMode(7001, 'edit'); 209 - assert.strictEqual(modes.hasMinorMode(7001, 'edit'), true); 210 - }); 211 - 212 - it('should return false when minor mode is not active', () => { 213 - modes.setMajorMode(7002, 'page'); 214 - assert.strictEqual(modes.hasMinorMode(7002, 'search'), false); 215 - }); 216 - }); 217 - 218 - describe('detectModeFromUrl', () => { 219 - it('should detect settings mode from settings URL', () => { 220 - assert.strictEqual(modes.detectModeFromUrl('peek://app/settings/settings.html'), 'settings'); 221 - assert.strictEqual(modes.detectModeFromUrl('peek://ext/cmd/settings.html'), 'settings'); 222 - }); 223 - 224 - it('should detect group mode from groups URL', () => { 225 - assert.strictEqual(modes.detectModeFromUrl('peek://ext/groups/index.html'), 'group'); 226 - assert.strictEqual(modes.detectModeFromUrl('peek://groups/groups.html'), 'group'); 227 - }); 228 - 229 - it('should detect page mode from http URLs', () => { 230 - assert.strictEqual(modes.detectModeFromUrl('https://example.com'), 'page'); 231 - assert.strictEqual(modes.detectModeFromUrl('http://localhost:3000'), 'page'); 232 - }); 233 - 234 - it('should return default for other URLs', () => { 235 - assert.strictEqual(modes.detectModeFromUrl('peek://app/background.html'), 'default'); 236 - assert.strictEqual(modes.detectModeFromUrl('peek://ext/cmd/panel.html'), 'default'); 237 - }); 238 - 239 - it('should return default for empty URL', () => { 240 - assert.strictEqual(modes.detectModeFromUrl(''), 'default'); 241 - }); 242 - }); 243 - 244 - describe('checkModeConditions', () => { 245 - beforeEach(() => { 246 - // Set up a known state 247 - modes.setMajorMode(8001, 'page'); 248 - modes.enableMinorMode(8001, 'edit'); 249 - modes.enableMinorMode(8001, 'search'); 250 - }); 251 - 252 - it('should return true when major mode matches', () => { 253 - assert.strictEqual(modes.checkModeConditions(8001, 'page'), true); 254 - }); 255 - 256 - it('should return false when major mode does not match', () => { 257 - assert.strictEqual(modes.checkModeConditions(8001, 'group'), false); 258 - }); 259 - 260 - it('should return true when all required minor modes are active', () => { 261 - assert.strictEqual(modes.checkModeConditions(8001, undefined, ['edit']), true); 262 - assert.strictEqual(modes.checkModeConditions(8001, undefined, ['edit', 'search']), true); 263 - }); 264 - 265 - it('should return false when required minor modes are not all active', () => { 266 - assert.strictEqual(modes.checkModeConditions(8001, undefined, ['preview']), false); 267 - assert.strictEqual(modes.checkModeConditions(8001, undefined, ['edit', 'preview']), false); 268 - }); 269 - 270 - it('should check both major and minor conditions', () => { 271 - assert.strictEqual(modes.checkModeConditions(8001, 'page', ['edit']), true); 272 - assert.strictEqual(modes.checkModeConditions(8001, 'group', ['edit']), false); 273 - assert.strictEqual(modes.checkModeConditions(8001, 'page', ['preview']), false); 274 - }); 275 - 276 - it('should return true when no conditions specified', () => { 277 - assert.strictEqual(modes.checkModeConditions(8001), true); 278 - assert.strictEqual(modes.checkModeConditions(8001, undefined, []), true); 279 - }); 280 - 281 - it('should handle unknown window (default mode)', () => { 282 - // Unknown window = default mode, no minors 283 - assert.strictEqual(modes.checkModeConditions(88888, 'default'), true); 284 - assert.strictEqual(modes.checkModeConditions(88888, 'page'), false); 285 - }); 286 - }); 287 - 288 - describe('buildCommandContext', () => { 289 - it('should return context with mode for known window', () => { 290 - modes.setMajorMode(9001, 'settings'); 291 - modes.enableMinorMode(9001, 'edit'); 292 - 293 - // Note: buildCommandContext uses BrowserWindow.fromId which won't work in unit tests 294 - // This test would need to be an integration test or we'd need to mock BrowserWindow 295 - // For now, test with null windowId 296 - const context = modes.buildCommandContext(null); 297 - assert.strictEqual(context.windowId, null); 298 - assert.strictEqual(context.mode, null); 299 - }); 300 - 301 - it('should return null mode for null windowId', () => { 302 - const context = modes.buildCommandContext(null); 303 - assert.strictEqual(context.windowId, null); 304 - assert.strictEqual(context.mode, null); 305 - assert.strictEqual(context.url, null); 306 - assert.strictEqual(context.title, null); 307 - }); 308 - }); 309 - });
-404
backend/electron/modes.ts
··· 1 - /** 2 - * Modes System 3 - * 4 - * Implements a hybrid Emacs-style major/minor mode system for context-aware 5 - * command dispatch and UI state management. 6 - * 7 - * Major Modes (exclusive, one per window): 8 - * - 'default': Standard browsing mode 9 - * - 'page': Viewing a web page 10 - * - 'group': Managing tab groups 11 - * - 'settings': In settings UI 12 - * 13 - * Minor Modes (stackable, multiple per window): 14 - * - 'preview': Preview mode (read-only) 15 - * - 'edit': Edit mode 16 - * - 'annotate': Annotation mode 17 - * - 'search': Search mode active 18 - * 19 - * The mode system enables: 20 - * 1. Mode-conditional shortcuts (same key does different things in different modes) 21 - * 2. Command availability guards (canExecute based on mode) 22 - * 3. UI state indicators (for future visual mode display) 23 - * 4. Scope-aware command targeting 24 - */ 25 - 26 - import { DEBUG } from './config.js'; 27 - 28 - // Lazy-load Electron modules to allow testing without Electron 29 - let BrowserWindow: typeof import('electron').BrowserWindow | null = null; 30 - let publish: typeof import('./pubsub.js').publish | null = null; 31 - let PubSubScopes: typeof import('./pubsub.js').scopes | null = null; 32 - let getSystemAddress: typeof import('./pubsub.js').getSystemAddress | null = null; 33 - 34 - async function loadElectronModules(): Promise<void> { 35 - try { 36 - const electron = await import('electron'); 37 - BrowserWindow = electron.BrowserWindow; 38 - const pubsub = await import('./pubsub.js'); 39 - publish = pubsub.publish; 40 - PubSubScopes = pubsub.scopes; 41 - getSystemAddress = pubsub.getSystemAddress; 42 - } catch { 43 - // Electron not available (e.g., in unit tests) 44 - DEBUG && console.log('[modes] Running without Electron (test mode)'); 45 - } 46 - } 47 - 48 - // Initialize asynchronously 49 - loadElectronModules(); 50 - 51 - // ============================================================================ 52 - // Types 53 - // ============================================================================ 54 - 55 - export type MajorModeId = 'page' | 'group' | 'settings' | 'default'; 56 - export type MinorModeId = 'preview' | 'edit' | 'annotate' | 'search'; 57 - export type CommandScope = 'global' | 'window' | 'page'; 58 - 59 - export interface ModeInfo { 60 - id: MajorModeId | MinorModeId; 61 - name: string; 62 - description?: string; 63 - type: 'major' | 'minor'; 64 - } 65 - 66 - export interface WindowModeState { 67 - major: MajorModeId; 68 - minors: MinorModeId[]; 69 - } 70 - 71 - export interface CommandContext { 72 - windowId: number | null; 73 - mode: WindowModeState | null; 74 - url: string | null; 75 - title: string | null; 76 - hasSelection: boolean; 77 - } 78 - 79 - // ============================================================================ 80 - // Mode Definitions 81 - // ============================================================================ 82 - 83 - const MAJOR_MODES: ModeInfo[] = [ 84 - { 85 - id: 'default', 86 - name: 'Default', 87 - description: 'Standard browsing mode', 88 - type: 'major', 89 - }, 90 - { 91 - id: 'page', 92 - name: 'Page', 93 - description: 'Viewing a web page', 94 - type: 'major', 95 - }, 96 - { 97 - id: 'group', 98 - name: 'Group', 99 - description: 'Managing tab groups', 100 - type: 'major', 101 - }, 102 - { 103 - id: 'settings', 104 - name: 'Settings', 105 - description: 'Application settings', 106 - type: 'major', 107 - }, 108 - ]; 109 - 110 - const MINOR_MODES: ModeInfo[] = [ 111 - { 112 - id: 'preview', 113 - name: 'Preview', 114 - description: 'Preview mode (read-only)', 115 - type: 'minor', 116 - }, 117 - { 118 - id: 'edit', 119 - name: 'Edit', 120 - description: 'Edit mode', 121 - type: 'minor', 122 - }, 123 - { 124 - id: 'annotate', 125 - name: 'Annotate', 126 - description: 'Annotation mode', 127 - type: 'minor', 128 - }, 129 - { 130 - id: 'search', 131 - name: 'Search', 132 - description: 'Search mode active', 133 - type: 'minor', 134 - }, 135 - ]; 136 - 137 - // ============================================================================ 138 - // Mode State Management 139 - // ============================================================================ 140 - 141 - /** 142 - * Per-window mode state storage 143 - * Maps window ID to its mode state 144 - */ 145 - const windowModes = new Map<number, WindowModeState>(); 146 - 147 - /** 148 - * Default mode state for new windows 149 - */ 150 - function getDefaultModeState(): WindowModeState { 151 - return { 152 - major: 'default', 153 - minors: [], 154 - }; 155 - } 156 - 157 - /** 158 - * Get the mode state for a window, creating default if not exists 159 - */ 160 - export function getWindowModeState(windowId: number): WindowModeState { 161 - let state = windowModes.get(windowId); 162 - if (!state) { 163 - state = getDefaultModeState(); 164 - windowModes.set(windowId, state); 165 - } 166 - return { ...state, minors: [...state.minors] }; // Return copy 167 - } 168 - 169 - /** 170 - * Set the major mode for a window 171 - */ 172 - export function setMajorMode(windowId: number, mode: MajorModeId): void { 173 - DEBUG && console.log(`[modes] setMajorMode: windowId=${windowId}, mode=${mode}`); 174 - 175 - let state = windowModes.get(windowId); 176 - if (!state) { 177 - state = getDefaultModeState(); 178 - } 179 - 180 - const oldMode = state.major; 181 - state.major = mode; 182 - windowModes.set(windowId, state); 183 - 184 - // Publish mode change event 185 - if (oldMode !== mode) { 186 - publishModeChange(windowId, state); 187 - } 188 - } 189 - 190 - /** 191 - * Enable a minor mode for a window 192 - */ 193 - export function enableMinorMode(windowId: number, mode: MinorModeId): void { 194 - DEBUG && console.log(`[modes] enableMinorMode: windowId=${windowId}, mode=${mode}`); 195 - 196 - let state = windowModes.get(windowId); 197 - if (!state) { 198 - state = getDefaultModeState(); 199 - } 200 - 201 - if (!state.minors.includes(mode)) { 202 - state.minors.push(mode); 203 - windowModes.set(windowId, state); 204 - publishModeChange(windowId, state); 205 - } 206 - } 207 - 208 - /** 209 - * Disable a minor mode for a window 210 - */ 211 - export function disableMinorMode(windowId: number, mode: MinorModeId): void { 212 - DEBUG && console.log(`[modes] disableMinorMode: windowId=${windowId}, mode=${mode}`); 213 - 214 - const state = windowModes.get(windowId); 215 - if (!state) return; 216 - 217 - const index = state.minors.indexOf(mode); 218 - if (index !== -1) { 219 - state.minors.splice(index, 1); 220 - windowModes.set(windowId, state); 221 - publishModeChange(windowId, state); 222 - } 223 - } 224 - 225 - /** 226 - * Toggle a minor mode for a window 227 - * Returns true if mode is now enabled 228 - */ 229 - export function toggleMinorMode(windowId: number, mode: MinorModeId): boolean { 230 - const state = getWindowModeState(windowId); 231 - if (state.minors.includes(mode)) { 232 - disableMinorMode(windowId, mode); 233 - return false; 234 - } else { 235 - enableMinorMode(windowId, mode); 236 - return true; 237 - } 238 - } 239 - 240 - /** 241 - * Clean up mode state when a window is closed 242 - */ 243 - export function cleanupWindowMode(windowId: number): void { 244 - DEBUG && console.log(`[modes] cleanupWindowMode: windowId=${windowId}`); 245 - windowModes.delete(windowId); 246 - } 247 - 248 - /** 249 - * Publish mode change event via pubsub 250 - */ 251 - function publishModeChange(windowId: number, state: WindowModeState): void { 252 - // Only publish if pubsub is available (not in test mode) 253 - if (publish && PubSubScopes && getSystemAddress) { 254 - publish(getSystemAddress(), PubSubScopes.GLOBAL, 'modes:changed', { 255 - windowId, 256 - major: state.major, 257 - minors: [...state.minors], 258 - }); 259 - } 260 - } 261 - 262 - // ============================================================================ 263 - // Mode Queries 264 - // ============================================================================ 265 - 266 - /** 267 - * Get all available modes (major and minor) 268 - */ 269 - export function getAllModes(): ModeInfo[] { 270 - return [...MAJOR_MODES, ...MINOR_MODES]; 271 - } 272 - 273 - /** 274 - * Check if a window is in a specific major mode 275 - */ 276 - export function isInMajorMode(windowId: number, mode: MajorModeId): boolean { 277 - const state = windowModes.get(windowId); 278 - return state?.major === mode; 279 - } 280 - 281 - /** 282 - * Check if a window has a specific minor mode enabled 283 - */ 284 - export function hasMinorMode(windowId: number, mode: MinorModeId): boolean { 285 - const state = windowModes.get(windowId); 286 - return state?.minors.includes(mode) ?? false; 287 - } 288 - 289 - // ============================================================================ 290 - // Mode Detection (Automatic Mode Assignment) 291 - // ============================================================================ 292 - 293 - /** 294 - * Detect the appropriate major mode based on window URL 295 - * This provides automatic mode detection based on URL patterns 296 - */ 297 - export function detectModeFromUrl(url: string): MajorModeId { 298 - if (!url) return 'default'; 299 - 300 - // Settings page 301 - if (url.includes('/settings/') || url.includes('settings.html')) { 302 - return 'settings'; 303 - } 304 - 305 - // Groups extension 306 - if (url.includes('/groups/') || url.includes('groups.html')) { 307 - return 'group'; 308 - } 309 - 310 - // Web pages 311 - if (url.startsWith('http://') || url.startsWith('https://')) { 312 - return 'page'; 313 - } 314 - 315 - return 'default'; 316 - } 317 - 318 - /** 319 - * Auto-update mode when window navigates to a new URL 320 - * Call this from the navigation handler 321 - */ 322 - export function updateModeForNavigation(windowId: number, url: string): void { 323 - const detectedMode = detectModeFromUrl(url); 324 - const currentState = windowModes.get(windowId); 325 - 326 - if (!currentState || currentState.major !== detectedMode) { 327 - setMajorMode(windowId, detectedMode); 328 - } 329 - } 330 - 331 - // ============================================================================ 332 - // Command Context 333 - // ============================================================================ 334 - 335 - /** 336 - * Build command context for the target window 337 - */ 338 - export function buildCommandContext(targetWindowId: number | null): CommandContext { 339 - const context: CommandContext = { 340 - windowId: targetWindowId, 341 - mode: null, 342 - url: null, 343 - title: null, 344 - hasSelection: false, // TODO: Implement selection tracking 345 - }; 346 - 347 - if (targetWindowId !== null) { 348 - // BrowserWindow may not be available in test mode 349 - if (BrowserWindow) { 350 - const win = BrowserWindow.fromId(targetWindowId); 351 - if (win && !win.isDestroyed()) { 352 - context.mode = getWindowModeState(targetWindowId); 353 - context.url = win.webContents.getURL(); 354 - context.title = win.getTitle(); 355 - } 356 - } else { 357 - // Test mode - just get mode state without window info 358 - context.mode = getWindowModeState(targetWindowId); 359 - } 360 - } 361 - 362 - return context; 363 - } 364 - 365 - // ============================================================================ 366 - // Mode-Conditional Shortcut Matching 367 - // ============================================================================ 368 - 369 - /** 370 - * Check if shortcut mode conditions are satisfied 371 - */ 372 - export function checkModeConditions( 373 - windowId: number, 374 - requiredMajorMode?: MajorModeId, 375 - requiredMinorModes?: MinorModeId[] 376 - ): boolean { 377 - const state = windowModes.get(windowId); 378 - if (!state) { 379 - // No mode state = default mode, only match if no mode required or 'default' 380 - if (requiredMajorMode && requiredMajorMode !== 'default') { 381 - return false; 382 - } 383 - if (requiredMinorModes && requiredMinorModes.length > 0) { 384 - return false; 385 - } 386 - return true; 387 - } 388 - 389 - // Check major mode 390 - if (requiredMajorMode && state.major !== requiredMajorMode) { 391 - return false; 392 - } 393 - 394 - // Check minor modes (all required minors must be active) 395 - if (requiredMinorModes && requiredMinorModes.length > 0) { 396 - for (const minor of requiredMinorModes) { 397 - if (!state.minors.includes(minor)) { 398 - return false; 399 - } 400 - } 401 - } 402 - 403 - return true; 404 - }
+7 -7
backend/electron/shortcuts.test.ts
··· 9 9 10 10 // Import will be done after ensuring module is compiled 11 11 let shortcuts: typeof import('./shortcuts.js'); 12 - let modes: typeof import('./modes.js'); 12 + let datastore: typeof import('./datastore.js'); 13 13 14 14 describe('Shortcuts Module Tests', () => { 15 15 before(async () => { 16 16 // Dynamic import of the compiled modules 17 17 shortcuts = await import('./shortcuts.js'); 18 - modes = await import('./modes.js'); 18 + datastore = await import('./datastore.js'); 19 19 }); 20 20 21 21 describe('parseShortcut', () => { ··· 219 219 defaultCallCount = 0; 220 220 221 221 // Set up test window mode 222 - modes.setMajorMode(10001, 'page'); 222 + datastore.addContextEntry('mode', 'page', { windowId: 10001 }); 223 223 }); 224 224 225 225 afterEach(() => { ··· 227 227 shortcuts.unregisterLocalShortcut('Ctrl+M', 'test-mode-page', { majorMode: 'page' }); 228 228 shortcuts.unregisterLocalShortcut('Ctrl+M', 'test-mode-group', { majorMode: 'group' }); 229 229 shortcuts.unregisterLocalShortcut('Ctrl+M', 'test-mode-default'); 230 - modes.cleanupWindowMode(10001); 230 + datastore.cleanupWindowContext(10001); 231 231 }); 232 232 233 233 it('should trigger mode-conditional shortcut when mode matches', () => { ··· 290 290 }; 291 291 292 292 // In page mode - should trigger page handler 293 - modes.setMajorMode(10001, 'page'); 293 + datastore.addContextEntry('mode', 'page', { windowId: 10001 }); 294 294 let handled = shortcuts.handleLocalShortcut(input, 10001); 295 295 assert.strictEqual(handled, true); 296 296 assert.strictEqual(pageCallCount, 1); 297 297 assert.strictEqual(groupCallCount, 0); 298 298 299 299 // Switch to group mode - should trigger group handler 300 - modes.setMajorMode(10001, 'group'); 300 + datastore.addContextEntry('mode', 'group', { windowId: 10001 }); 301 301 handled = shortcuts.handleLocalShortcut(input, 10001); 302 302 assert.strictEqual(handled, true); 303 303 assert.strictEqual(pageCallCount, 1); // No change ··· 325 325 }; 326 326 327 327 // In page mode - group doesn't match, should trigger default 328 - modes.setMajorMode(10001, 'page'); 328 + datastore.addContextEntry('mode', 'page', { windowId: 10001 }); 329 329 const handled = shortcuts.handleLocalShortcut(input, 10001); 330 330 assert.strictEqual(handled, true); 331 331 assert.strictEqual(groupCallCount, 0);
+1 -1
backend/electron/shortcuts.ts
··· 9 9 */ 10 10 11 11 import { DEBUG } from './config.js'; 12 - import { checkModeConditions, type MajorModeId, type MinorModeId } from './modes.js'; 12 + import { checkModeConditions, type MajorModeId, type MinorModeId } from './datastore.js'; 13 13 14 14 // Lazy-load Electron modules to allow testing without Electron 15 15 let globalShortcut: typeof import('electron').globalShortcut | null = null;
+1 -1
backend/server/package-lock.json
··· 15 15 "hono": "^4.10.7" 16 16 }, 17 17 "engines": { 18 - "node": ">=24" 18 + "node": ">=22" 19 19 } 20 20 }, 21 21 "node_modules/@hono/node-server": {