experiments in a post-browser web
10
fork

Configure Feed

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

feat: spaces feature, extension_settings rename, backend cleanup, theme fix

+2933 -796
+1 -1
app/datastore/viewer.js
··· 5 5 6 6 // No IZUI init needed — backend handles parent tracking and focus restoration via roles. 7 7 8 - const tables = ['items', 'item_visits', 'item_tags', 'content', 'tags', 'blobs', 'scripts_data', 'feeds', 'extensions', 'extension_settings', 'settings']; 8 + const tables = ['items', 'item_visits', 'item_tags', 'content', 'tags', 'blobs', 'scripts_data', 'feeds', 'extensions', 'feature_settings', 'settings']; 9 9 10 10 let currentTable = null; 11 11 let datastoreApi = null;
+6 -6
app/diagnostic.html
··· 21 21 22 22 <div class="section"> 23 23 <button onclick="dumpLocalStorage()">Dump localStorage</button> 24 - <button onclick="dumpDatastore()">Dump extension_settings from datastore</button> 24 + <button onclick="dumpDatastore()">Dump feature_settings from datastore</button> 25 25 <button onclick="dumpBoth()">Dump Both</button> 26 26 <button onclick="copyResults()">Copy Results</button> 27 27 </div> ··· 98 98 window.dumpDatastore = async function() { 99 99 clearOutput(); 100 100 try { 101 - const result = await api.datastore.getTable('extension_settings'); 101 + const result = await api.datastore.getTable('feature_settings'); 102 102 if (result.success) { 103 103 const data = result.data || {}; 104 104 const entries = Object.entries(data); ··· 114 114 displayValue = JSON.stringify(parsed, null, 2); 115 115 } 116 116 } catch {} 117 - html += `<span class="key">${rowId}</span> (${row.extensionId}:${row.key}):\n <span class="value">${displayValue}</span>\n\n`; 117 + html += `<span class="key">${rowId}</span> (${row.featureId}:${row.key}):\n <span class="value">${displayValue}</span>\n\n`; 118 118 }); 119 119 120 - log('extension_settings Table', html); 120 + log('feature_settings Table', html); 121 121 } else { 122 - log('extension_settings Table', `Error: ${result.error}`); 122 + log('feature_settings Table', `Error: ${result.error}`); 123 123 } 124 124 } catch (err) { 125 - log('extension_settings Table', `Error: ${err.message}`); 125 + log('feature_settings Table', `Error: ${err.message}`); 126 126 } 127 127 }; 128 128
+4 -4
app/migrations/extension-settings.js
··· 2 2 * Migration: Extension Settings localStorage -> Datastore 3 3 * 4 4 * One-time migration to move extension settings from localStorage 5 - * to the datastore extension_settings table for cross-origin access. 5 + * to the datastore feature_settings table for cross-origin access. 6 6 * 7 7 * Run this from core background context (peek://app/background.html) 8 8 */ ··· 62 62 const settings = JSON.parse(storedData); 63 63 console.log(`[migration] Found settings for ${oldId}:`, Object.keys(settings)); 64 64 65 - // Write each key to extension_settings table via IPC 65 + // Write each key to feature_settings table via IPC 66 66 // Note: We can't use api.settings here because we're in core context, not extension context 67 67 // So we use a direct datastore call 68 68 69 69 for (const [key, value] of Object.entries(settings)) { 70 70 const rowId = `${newId}:${key}`; 71 - const result = await api.datastore.setRow('extension_settings', rowId, { 72 - extensionId: newId, 71 + const result = await api.datastore.setRow('feature_settings', rowId, { 72 + featureId: newId, 73 73 key, 74 74 value: JSON.stringify(value), 75 75 updatedAt: Date.now()
+3 -3
app/migrations/localstorage-to-datastore.js
··· 2 2 * Migration: Core/Feature localStorage -> Datastore 3 3 * 4 4 * One-time migration to move core settings and feature configurations 5 - * from localStorage to the datastore extension_settings table. 5 + * from localStorage to the datastore feature_settings table. 6 6 * This enables syncing across backends (Electron, Tauri). 7 7 * 8 8 * Run this from core background context (peek://app/background.html) ··· 70 70 console.log(`[migration] Found ${storageKey}, migrating to ${namespace}:${key}`); 71 71 72 72 const rowId = `${namespace}:${key}`; 73 - const result = await api.datastore.setRow('extension_settings', rowId, { 74 - extensionId: namespace, 73 + const result = await api.datastore.setRow('feature_settings', rowId, { 74 + featureId: namespace, 75 75 key, 76 76 value: JSON.stringify(value), 77 77 updatedAt: Date.now()
+8 -8
app/settings/settings.js
··· 2271 2271 }; 2272 2272 2273 2273 // Render feature settings (Peeks, Slides, etc.) 2274 - // Reads/writes from datastore extension_settings table 2274 + // Reads/writes from datastore feature_settings table 2275 2275 const renderFeatureSettings = async (feature) => { 2276 2276 const { id, labels, schemas, storageKeys, defaults } = feature; 2277 2277 ··· 2843 2843 const tableGroups = [ 2844 2844 { label: 'Core Data', tables: ['items', 'tags', 'content', 'blobs'] }, 2845 2845 { label: 'Relationships', tables: ['item_tags', 'item_visits', 'item_events', 'item_groups', 'item_group_members'] }, 2846 - { label: 'Extensions', tables: ['extensions', 'extension_settings', 'scripts_data', 'feeds'] }, 2846 + { label: 'Extensions', tables: ['extensions', 'feature_settings', 'scripts_data', 'feeds'] }, 2847 2847 { label: 'System', tables: ['settings', 'migrations', 'themes'] }, 2848 2848 ]; 2849 2849 ··· 2860 2860 scripts_data: 'Script extension data', 2861 2861 feeds: 'Feed subscription data', 2862 2862 extensions: 'Installed extension metadata', 2863 - extension_settings: 'Per-extension settings and preferences', 2863 + feature_settings: 'Per-feature settings and preferences', 2864 2864 settings: 'Global application settings', 2865 2865 migrations: 'Schema migration history', 2866 2866 themes: 'Installed color themes', ··· 3103 3103 outputArea.textContent = text; 3104 3104 })); 3105 3105 3106 - // Dump extension_settings 3107 - btnRow.appendChild(makeBtn('Dump extension_settings', async () => { 3106 + // Dump feature_settings 3107 + btnRow.appendChild(makeBtn('Dump feature_settings', async () => { 3108 3108 try { 3109 - const result = await api.datastore.getTable('extension_settings'); 3109 + const result = await api.datastore.getTable('feature_settings'); 3110 3110 if (result.success) { 3111 3111 const data = result.data || {}; 3112 3112 const entries = Object.entries(data); 3113 - let text = `extension_settings: ${entries.length} rows\n\n`; 3113 + let text = `feature_settings: ${entries.length} rows\n\n`; 3114 3114 entries.forEach(([rowId, row]) => { 3115 3115 let displayValue = row.value; 3116 3116 try { 3117 3117 displayValue = JSON.stringify(JSON.parse(row.value), null, 2); 3118 3118 } catch {} 3119 - text += `${rowId} (${row.extensionId}:${row.key}):\n ${displayValue}\n\n`; 3119 + text += `${rowId} (${row.featureId}:${row.key}):\n ${displayValue}\n\n`; 3120 3120 }); 3121 3121 outputArea.textContent = text; 3122 3122 } else {
+5 -5
app/utils.js
··· 55 55 56 56 /** 57 57 * Create an async store backed by datastore instead of localStorage. 58 - * Uses the extension_settings table with extensionId as namespace. 58 + * Uses the feature_settings table with featureId as namespace. 59 59 * 60 - * @param {string} namespace - The extensionId (e.g., 'core', 'cmd', 'scripts') 60 + * @param {string} namespace - The featureId (e.g., 'core', 'cmd', 'scripts') 61 61 * @param {object} defaults - Default values for each key 62 62 * @returns {Promise<object>} Store with async get/set methods 63 63 */ 64 64 const createDatastoreStore = async (namespace, defaults = {}) => { 65 65 const api = window.app; 66 - const table = 'extension_settings'; 66 + const table = 'feature_settings'; 67 67 68 68 // Load all settings for this namespace once 69 69 let cache = {}; ··· 71 71 const result = await api.datastore.getTable(table); 72 72 if (result.success && result.data) { 73 73 Object.values(result.data).forEach(row => { 74 - if (row.extensionId === namespace && row.value) { 74 + if (row.featureId === namespace && row.value) { 75 75 try { 76 76 cache[row.key] = JSON.parse(row.value); 77 77 } catch (e) { ··· 101 101 const rowId = `${namespace}:${key}`; 102 102 try { 103 103 await api.datastore.setRow(table, rowId, { 104 - extensionId: namespace, 104 + featureId: namespace, 105 105 key, 106 106 value: JSON.stringify(value), 107 107 updatedAt: Date.now()
+4 -4
backend/electron/backup.ts
··· 54 54 const db = getDb(); 55 55 try { 56 56 const row = db.prepare( 57 - 'SELECT value FROM extension_settings WHERE extensionId = ? AND key = ?' 57 + 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 58 58 ).get(CORE_SETTINGS_KEY, 'prefs') as { value: string } | undefined; 59 59 60 60 if (!row?.value) return ''; ··· 78 78 79 79 const getKey = (key: string): string | null => { 80 80 const row = db.prepare( 81 - 'SELECT value FROM extension_settings WHERE extensionId = ? AND key = ?' 81 + 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 82 82 ).get(BACKUP_SETTINGS_KEY, key) as { value: string } | undefined; 83 83 if (!row?.value) return null; 84 84 try { ··· 107 107 const setKey = (key: string, value: string | number | boolean): void => { 108 108 const jsonValue = JSON.stringify(value); 109 109 db.prepare(` 110 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 110 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 111 111 VALUES (?, ?, ?, ?, ?) 112 112 `).run(`${BACKUP_SETTINGS_KEY}_${key}`, BACKUP_SETTINGS_KEY, key, jsonValue, timestamp); 113 113 }; ··· 155 155 const db = getDb(); 156 156 const tables = [ 157 157 'addresses', 'visits', 'content', 'tags', 'address_tags', 158 - 'blobs', 'scripts_data', 'feeds', 'extensions', 'extension_settings', 158 + 'blobs', 'scripts_data', 'feeds', 'extensions', 'feature_settings', 159 159 'migrations', 'items', 'item_tags' 160 160 ]; 161 161
+424
backend/electron/closed-window.test.ts
··· 1 + /** 2 + * Unit tests for closed window stack and reopen-last-closed logic 3 + * 4 + * Tests the pure logic extracted from main.ts: 5 + * - pushClosedWindow / popClosedWindow: LIFO stack with max capacity 6 + * - createClosedWindowEntry: determines whether a closed window should be saved 7 + * and builds the entry with correct spaceMode capture 8 + * 9 + * All functions are pure (no Electron dependencies) so they can run 10 + * under ELECTRON_RUN_AS_NODE=1 without a real display server. 11 + */ 12 + 13 + import { describe, it, beforeEach } from 'node:test'; 14 + import * as assert from 'node:assert'; 15 + 16 + // ============================================================================ 17 + // Types (mirrored from main.ts) 18 + // ============================================================================ 19 + 20 + interface ClosedWindowEntry { 21 + url: string; 22 + source: string; 23 + bounds: { x: number; y: number; width: number; height: number } | null; 24 + spaceMode: { spaceId: string; spaceName: string; color?: string } | null; 25 + timestamp: number; 26 + } 27 + 28 + interface WindowParams { 29 + address?: string; 30 + role?: string; 31 + keepLive?: boolean; 32 + modal?: boolean; 33 + } 34 + 35 + interface ContextEntry { 36 + value: string; 37 + metadata?: Record<string, unknown>; 38 + } 39 + 40 + // ============================================================================ 41 + // Pure logic functions (extracted from main.ts - identical implementations) 42 + // ============================================================================ 43 + 44 + const MAX_CLOSED_WINDOWS = 20; 45 + 46 + let closedWindowStack: ClosedWindowEntry[] = []; 47 + 48 + function pushClosedWindow(entry: ClosedWindowEntry): void { 49 + closedWindowStack.push(entry); 50 + if (closedWindowStack.length > MAX_CLOSED_WINDOWS) { 51 + closedWindowStack.shift(); 52 + } 53 + } 54 + 55 + function popClosedWindow(): ClosedWindowEntry | undefined { 56 + return closedWindowStack.pop(); 57 + } 58 + 59 + function getClosedWindowCount(): number { 60 + return closedWindowStack.length; 61 + } 62 + 63 + /** 64 + * Determine whether a closed window should be saved to the stack, 65 + * and if so, build the ClosedWindowEntry. 66 + * 67 + * Extracted from the window 'closed' handler in main.ts (~line 294-341). 68 + */ 69 + function createClosedWindowEntry( 70 + params: WindowParams, 71 + modeEntry: ContextEntry | null, 72 + lastBounds: { x: number; y: number; width: number; height: number } | null, 73 + source: string, 74 + ): ClosedWindowEntry | null { 75 + const address = params.address; 76 + const role = params.role; 77 + const isKeepLive = params.keepLive === true; 78 + const isModal = params.modal === true; 79 + const isWebUrl = address && (address.startsWith('http://') || address.startsWith('https://')); 80 + const isContentRole = !role || role === 'content' || role === 'child-content' || role === 'workspace'; 81 + 82 + if (!isWebUrl || !isContentRole || isKeepLive || isModal) { 83 + return null; 84 + } 85 + 86 + // Check for space mode context 87 + let spaceMode: ClosedWindowEntry['spaceMode'] = null; 88 + if (modeEntry && modeEntry.value === 'space' && modeEntry.metadata) { 89 + spaceMode = { 90 + spaceId: modeEntry.metadata.spaceId as string, 91 + spaceName: modeEntry.metadata.spaceName as string, 92 + color: modeEntry.metadata.color as string | undefined, 93 + }; 94 + } 95 + 96 + // For canvas pages, convert window bounds back to webview bounds 97 + let saveBounds = lastBounds; 98 + if (lastBounds && isWebUrl) { 99 + const CANVAS_MARGIN = 8; 100 + const CANVAS_TRIGGER_ZONE = 8; 101 + const CANVAS_NAVBAR_HEIGHT = 36; 102 + saveBounds = { 103 + x: lastBounds.x + CANVAS_MARGIN, 104 + y: lastBounds.y + CANVAS_TRIGGER_ZONE + CANVAS_NAVBAR_HEIGHT, 105 + width: lastBounds.width - CANVAS_MARGIN * 2, 106 + height: lastBounds.height - CANVAS_TRIGGER_ZONE - CANVAS_MARGIN - CANVAS_NAVBAR_HEIGHT, 107 + }; 108 + } 109 + 110 + return { 111 + url: address!, 112 + source, 113 + bounds: saveBounds, 114 + spaceMode, 115 + timestamp: Date.now(), 116 + }; 117 + } 118 + 119 + // ============================================================================ 120 + // Helper 121 + // ============================================================================ 122 + 123 + function makeEntry(overrides?: Partial<ClosedWindowEntry>): ClosedWindowEntry { 124 + return { 125 + url: 'https://example.com', 126 + source: 'test-ext', 127 + bounds: { x: 100, y: 100, width: 800, height: 600 }, 128 + spaceMode: null, 129 + timestamp: Date.now(), 130 + ...overrides, 131 + }; 132 + } 133 + 134 + // ============================================================================ 135 + // Tests 136 + // ============================================================================ 137 + 138 + describe('Closed window stack operations', () => { 139 + beforeEach(() => { 140 + closedWindowStack = []; 141 + }); 142 + 143 + it('push and pop returns the same entry', () => { 144 + const entry = makeEntry({ url: 'https://example.com/page1' }); 145 + pushClosedWindow(entry); 146 + const popped = popClosedWindow(); 147 + assert.deepStrictEqual(popped, entry); 148 + }); 149 + 150 + it('stack is LIFO (most recent first)', () => { 151 + const entry1 = makeEntry({ url: 'https://example.com/first' }); 152 + const entry2 = makeEntry({ url: 'https://example.com/second' }); 153 + const entry3 = makeEntry({ url: 'https://example.com/third' }); 154 + pushClosedWindow(entry1); 155 + pushClosedWindow(entry2); 156 + pushClosedWindow(entry3); 157 + 158 + assert.deepStrictEqual(popClosedWindow(), entry3); 159 + assert.deepStrictEqual(popClosedWindow(), entry2); 160 + assert.deepStrictEqual(popClosedWindow(), entry1); 161 + }); 162 + 163 + it('stack caps at MAX_CLOSED_WINDOWS (20) — pushing 21st drops the oldest', () => { 164 + for (let i = 0; i < 21; i++) { 165 + pushClosedWindow(makeEntry({ url: `https://example.com/page${i}` })); 166 + } 167 + assert.strictEqual(getClosedWindowCount(), 20); 168 + // The oldest (page0) should have been dropped; most recent pop is page20 169 + const newest = popClosedWindow(); 170 + assert.strictEqual(newest?.url, 'https://example.com/page20'); 171 + // After popping all 19 remaining, the oldest should be page1 (page0 was evicted) 172 + for (let i = 0; i < 18; i++) { 173 + popClosedWindow(); 174 + } 175 + const oldest = popClosedWindow(); 176 + assert.strictEqual(oldest?.url, 'https://example.com/page1'); 177 + }); 178 + 179 + it('pop on empty stack returns undefined', () => { 180 + assert.strictEqual(popClosedWindow(), undefined); 181 + }); 182 + 183 + it('multiple pushes and pops maintain order', () => { 184 + pushClosedWindow(makeEntry({ url: 'https://a.com' })); 185 + pushClosedWindow(makeEntry({ url: 'https://b.com' })); 186 + assert.strictEqual(popClosedWindow()?.url, 'https://b.com'); 187 + pushClosedWindow(makeEntry({ url: 'https://c.com' })); 188 + assert.strictEqual(popClosedWindow()?.url, 'https://c.com'); 189 + assert.strictEqual(popClosedWindow()?.url, 'https://a.com'); 190 + assert.strictEqual(popClosedWindow(), undefined); 191 + }); 192 + }); 193 + 194 + describe('Space mode capture', () => { 195 + it('captures spaceMode when context.mode is space with spaceId/spaceName', () => { 196 + const modeEntry: ContextEntry = { 197 + value: 'space', 198 + metadata: { spaceId: 'sp-123', spaceName: 'Work' }, 199 + }; 200 + const entry = createClosedWindowEntry( 201 + { address: 'https://example.com' }, 202 + modeEntry, 203 + null, 204 + 'test-ext', 205 + ); 206 + assert.notStrictEqual(entry, null); 207 + assert.deepStrictEqual(entry!.spaceMode, { 208 + spaceId: 'sp-123', 209 + spaceName: 'Work', 210 + color: undefined, 211 + }); 212 + }); 213 + 214 + it('captures space mode color when present', () => { 215 + const modeEntry: ContextEntry = { 216 + value: 'space', 217 + metadata: { spaceId: 'sp-456', spaceName: 'Personal', color: '#ff0000' }, 218 + }; 219 + const entry = createClosedWindowEntry( 220 + { address: 'https://example.com' }, 221 + modeEntry, 222 + null, 223 + 'test-ext', 224 + ); 225 + assert.deepStrictEqual(entry!.spaceMode, { 226 + spaceId: 'sp-456', 227 + spaceName: 'Personal', 228 + color: '#ff0000', 229 + }); 230 + }); 231 + 232 + it('spaceMode is null when context.mode is page', () => { 233 + const modeEntry: ContextEntry = { value: 'page', metadata: {} }; 234 + const entry = createClosedWindowEntry( 235 + { address: 'https://example.com' }, 236 + modeEntry, 237 + null, 238 + 'test-ext', 239 + ); 240 + assert.strictEqual(entry!.spaceMode, null); 241 + }); 242 + 243 + it('spaceMode is null when context is null', () => { 244 + const entry = createClosedWindowEntry( 245 + { address: 'https://example.com' }, 246 + null, 247 + null, 248 + 'test-ext', 249 + ); 250 + assert.strictEqual(entry!.spaceMode, null); 251 + }); 252 + }); 253 + 254 + describe('Entry creation logic', () => { 255 + it('saves entries with http URLs', () => { 256 + const entry = createClosedWindowEntry( 257 + { address: 'http://example.com' }, 258 + null, 259 + null, 260 + 'test-ext', 261 + ); 262 + assert.notStrictEqual(entry, null); 263 + assert.strictEqual(entry!.url, 'http://example.com'); 264 + }); 265 + 266 + it('saves entries with https URLs', () => { 267 + const entry = createClosedWindowEntry( 268 + { address: 'https://example.com' }, 269 + null, 270 + null, 271 + 'test-ext', 272 + ); 273 + assert.notStrictEqual(entry, null); 274 + }); 275 + 276 + it('does NOT save non-web URLs (peek://)', () => { 277 + const entry = createClosedWindowEntry( 278 + { address: 'peek://app/page/index.html' }, 279 + null, 280 + null, 281 + 'test-ext', 282 + ); 283 + assert.strictEqual(entry, null); 284 + }); 285 + 286 + it('does NOT save file:// URLs', () => { 287 + const entry = createClosedWindowEntry( 288 + { address: 'file:///tmp/test.html' }, 289 + null, 290 + null, 291 + 'test-ext', 292 + ); 293 + assert.strictEqual(entry, null); 294 + }); 295 + 296 + it('does NOT save when address is undefined', () => { 297 + const entry = createClosedWindowEntry( 298 + {}, 299 + null, 300 + null, 301 + 'test-ext', 302 + ); 303 + assert.strictEqual(entry, null); 304 + }); 305 + 306 + it('saves content role windows (role=content)', () => { 307 + const entry = createClosedWindowEntry( 308 + { address: 'https://example.com', role: 'content' }, 309 + null, 310 + null, 311 + 'test-ext', 312 + ); 313 + assert.notStrictEqual(entry, null); 314 + }); 315 + 316 + it('saves child-content role windows', () => { 317 + const entry = createClosedWindowEntry( 318 + { address: 'https://example.com', role: 'child-content' }, 319 + null, 320 + null, 321 + 'test-ext', 322 + ); 323 + assert.notStrictEqual(entry, null); 324 + }); 325 + 326 + it('saves workspace role windows', () => { 327 + const entry = createClosedWindowEntry( 328 + { address: 'https://example.com', role: 'workspace' }, 329 + null, 330 + null, 331 + 'test-ext', 332 + ); 333 + assert.notStrictEqual(entry, null); 334 + }); 335 + 336 + it('saves windows with no role (default content)', () => { 337 + const entry = createClosedWindowEntry( 338 + { address: 'https://example.com' }, 339 + null, 340 + null, 341 + 'test-ext', 342 + ); 343 + assert.notStrictEqual(entry, null); 344 + }); 345 + 346 + it('does NOT save modal windows', () => { 347 + const entry = createClosedWindowEntry( 348 + { address: 'https://example.com', modal: true }, 349 + null, 350 + null, 351 + 'test-ext', 352 + ); 353 + assert.strictEqual(entry, null); 354 + }); 355 + 356 + it('does NOT save keepLive windows', () => { 357 + const entry = createClosedWindowEntry( 358 + { address: 'https://example.com', keepLive: true }, 359 + null, 360 + null, 361 + 'test-ext', 362 + ); 363 + assert.strictEqual(entry, null); 364 + }); 365 + 366 + it('does NOT save non-content roles (e.g. palette)', () => { 367 + const entry = createClosedWindowEntry( 368 + { address: 'https://example.com', role: 'palette' }, 369 + null, 370 + null, 371 + 'test-ext', 372 + ); 373 + assert.strictEqual(entry, null); 374 + }); 375 + 376 + it('captures bounds from the window', () => { 377 + const bounds = { x: 100, y: 200, width: 800, height: 600 }; 378 + const entry = createClosedWindowEntry( 379 + { address: 'https://example.com' }, 380 + null, 381 + bounds, 382 + 'test-ext', 383 + ); 384 + assert.notStrictEqual(entry, null); 385 + // Bounds are adjusted for canvas margins 386 + assert.notStrictEqual(entry!.bounds, null); 387 + assert.strictEqual(entry!.bounds!.x, 108); // 100 + CANVAS_MARGIN(8) 388 + assert.strictEqual(entry!.bounds!.y, 244); // 200 + TRIGGER_ZONE(8) + NAVBAR(36) 389 + assert.strictEqual(entry!.bounds!.width, 784); // 800 - MARGIN*2(16) 390 + assert.strictEqual(entry!.bounds!.height, 548); // 600 - TRIGGER(8) - MARGIN(8) - NAVBAR(36) 391 + }); 392 + 393 + it('entry uses spaceMode field (not groupMode)', () => { 394 + const modeEntry: ContextEntry = { 395 + value: 'space', 396 + metadata: { spaceId: 'sp-1', spaceName: 'TestSpace' }, 397 + }; 398 + const entry = createClosedWindowEntry( 399 + { address: 'https://example.com' }, 400 + modeEntry, 401 + null, 402 + 'test-ext', 403 + ); 404 + assert.notStrictEqual(entry, null); 405 + // Verify spaceMode is present (not groupMode) 406 + assert.ok('spaceMode' in entry!); 407 + assert.strictEqual((entry as any).groupMode, undefined); 408 + // Verify spaceId/spaceName (not groupId/groupName) 409 + assert.strictEqual(entry!.spaceMode!.spaceId, 'sp-1'); 410 + assert.strictEqual(entry!.spaceMode!.spaceName, 'TestSpace'); 411 + assert.strictEqual((entry!.spaceMode as any).groupId, undefined); 412 + assert.strictEqual((entry!.spaceMode as any).groupName, undefined); 413 + }); 414 + 415 + it('null bounds are preserved as null', () => { 416 + const entry = createClosedWindowEntry( 417 + { address: 'https://example.com' }, 418 + null, 419 + null, 420 + 'test-ext', 421 + ); 422 + assert.strictEqual(entry!.bounds, null); 423 + }); 424 + });
+40 -11
backend/electron/datastore.ts
··· 174 174 CREATE INDEX IF NOT EXISTS idx_extensions_status ON extensions(status); 175 175 CREATE INDEX IF NOT EXISTS idx_extensions_builtin ON extensions(builtin); 176 176 177 - CREATE TABLE IF NOT EXISTS extension_settings ( 177 + CREATE TABLE IF NOT EXISTS feature_settings ( 178 178 id TEXT PRIMARY KEY, 179 - extensionId TEXT NOT NULL, 179 + featureId TEXT NOT NULL, 180 180 key TEXT NOT NULL, 181 181 value TEXT, 182 182 updatedAt INTEGER 183 183 ); 184 - CREATE INDEX IF NOT EXISTS idx_extension_settings_extensionId ON extension_settings(extensionId); 185 - CREATE UNIQUE INDEX IF NOT EXISTS idx_extension_settings_unique ON extension_settings(extensionId, key); 184 + CREATE INDEX IF NOT EXISTS idx_feature_settings_featureId ON feature_settings(featureId); 185 + CREATE UNIQUE INDEX IF NOT EXISTS idx_feature_settings_unique ON feature_settings(featureId, key); 186 186 187 187 CREATE TABLE IF NOT EXISTS migrations ( 188 188 id TEXT PRIMARY KEY, ··· 375 375 migrateInvalidPersonEntities(); 376 376 migrateInvalidPlaceEntities(); 377 377 migrateTagNavigationHistory(); 378 + migrateExtensionSettingsToFeatureSettings(); 378 379 dropLegacyAddressTables(); 379 380 380 381 // Validate schema against canonical definition ··· 518 519 } 519 520 520 521 const tablesToMigrate = [ 521 - 'addresses', 'visits', 'tags', 'address_tags', 'extension_settings', 522 + 'addresses', 'visits', 'tags', 'address_tags', 'feature_settings', 522 523 'extensions', 'content', 'blobs', 'scripts_data', 'feeds', 523 524 ]; 524 525 ··· 2164 2165 } 2165 2166 } 2166 2167 2168 + // ==================== Rename extension_settings -> feature_settings ==================== 2169 + 2170 + function migrateExtensionSettingsToFeatureSettings(): void { 2171 + if (!db) return; 2172 + 2173 + // Check if old table exists 2174 + const oldTableExists = db.prepare( 2175 + `SELECT name FROM sqlite_master WHERE type='table' AND name='extension_settings'` 2176 + ).get(); 2177 + 2178 + if (!oldTableExists) return; 2179 + 2180 + DEBUG && console.log('main', 'Migrating extension_settings -> feature_settings'); 2181 + 2182 + try { 2183 + // Copy data from old table to new table (created by CREATE TABLE IF NOT EXISTS above) 2184 + db.exec(` 2185 + INSERT OR IGNORE INTO feature_settings (id, featureId, key, value, updatedAt) 2186 + SELECT id, extensionId, key, value, updatedAt FROM extension_settings; 2187 + `); 2188 + // Drop old table and its indexes 2189 + db.exec(`DROP TABLE IF EXISTS extension_settings`); 2190 + DEBUG && console.log('main', 'extension_settings -> feature_settings migration complete'); 2191 + } catch (error) { 2192 + console.error('Failed to migrate extension_settings:', error); 2193 + } 2194 + } 2195 + 2167 2196 // ==================== Context History Operations ==================== 2168 2197 2169 2198 export interface ContextEntry { ··· 2421 2450 2422 2451 // ==================== Mode Helpers (Context-based replacements for modes.ts) ==================== 2423 2452 2424 - export type MajorModeId = 'page' | 'group' | 'default'; 2453 + export type MajorModeId = 'page' | 'space' | 'default'; 2425 2454 2426 2455 /** 2427 2456 * Detect the appropriate major mode based on URL ··· 2450 2479 const currentEntry = getContextEntry('mode', windowId); 2451 2480 2452 2481 // Don't override group mode with page mode (group mode persists) 2453 - if (currentEntry?.value === 'group' && detectedMode === 'page') { 2454 - DEBUG && console.log('main', `Preserving group mode for window ${windowId} during navigation`); 2482 + if (currentEntry?.value === 'space' && detectedMode === 'page') { 2483 + DEBUG && console.log('main', `Preserving space mode for window ${windowId} during navigation`); 2455 2484 return; 2456 2485 } 2457 2486 ··· 2500 2529 if (!db) return; 2501 2530 2502 2531 const row = db.prepare(` 2503 - SELECT value FROM extension_settings 2504 - WHERE extensionId = 'system' AND key = 'datastore_version' 2532 + SELECT value FROM feature_settings 2533 + WHERE featureId = 'system' AND key = 'datastore_version' 2505 2534 `).get() as { value: string } | undefined; 2506 2535 2507 2536 if (row) { ··· 2530 2559 2531 2560 // Write current version 2532 2561 db.prepare(` 2533 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 2562 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 2534 2563 VALUES (?, 'system', 'datastore_version', ?, ?) 2535 2564 `).run('system-datastore_version', JSON.stringify(DATASTORE_VERSION), Date.now()); 2536 2565
+4 -4
backend/electron/device.ts
··· 13 13 14 14 /** 15 15 * Get or generate device ID 16 - * Stored in extension_settings table for persistence 16 + * Stored in feature_settings table for persistence 17 17 */ 18 18 export function getDeviceId(): string { 19 19 if (deviceId) { ··· 25 25 try { 26 26 // Try to get existing device ID 27 27 const row = db.prepare(` 28 - SELECT value FROM extension_settings 29 - WHERE extensionId = 'system' AND key = 'deviceId' 28 + SELECT value FROM feature_settings 29 + WHERE featureId = 'system' AND key = 'deviceId' 30 30 `).get() as { value: string } | undefined; 31 31 32 32 if (row && row.value) { ··· 50 50 try { 51 51 const jsonValue = JSON.stringify(deviceId); 52 52 db.prepare(` 53 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 53 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 54 54 VALUES (?, 'system', 'deviceId', ?, ?) 55 55 `).run('system-deviceId', jsonValue, Date.now()); 56 56 } catch (error) {
+2 -4
backend/electron/entry.ts
··· 54 54 registerAllHandlers, 55 55 restoreSavedTheme, 56 56 reopenLastClosedWindow, 57 - cleanupGroupScreenBorder, 58 57 // Database 59 58 getDb, 60 59 // Config ··· 358 357 _beforeQuitCleanupDone = true; 359 358 DEBUG && console.log('[lifecycle] before-quit: running cleanup'); 360 359 try { stopAutosaveTimer(); } catch (e) { DEBUG && console.error('[lifecycle] stopAutosaveTimer error:', e); } 361 - try { cleanupGroupScreenBorder(); } catch (e) { DEBUG && console.error('[lifecycle] cleanupGroupScreenBorder error:', e); } 362 360 try { cleanupDisplayWatcher(); } catch (e) { DEBUG && console.error('[lifecycle] cleanupDisplayWatcher error:', e); } 363 361 try { saveSessionSnapshot('before-quit'); } catch (e) { DEBUG && console.error('[lifecycle] saveSessionSnapshot error:', e); } 364 362 try { cleanupDevExtensions(); } catch (e) { DEBUG && console.error('[lifecycle] cleanupDevExtensions error:', e); } ··· 519 517 try { 520 518 const db = getDb(); 521 519 const snapshotRow = db.prepare( 522 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'snapshot'` 520 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'snapshot'` 523 521 ).get() as { value: string } | undefined; 524 522 if (snapshotRow?.value) { 525 523 const snapshot = JSON.parse(snapshotRow.value); ··· 797 795 try { 798 796 const earlyDb = new Database(dbPath, { readonly: true, fileMustExist: true }); 799 797 const row = earlyDb.prepare( 800 - "SELECT value FROM extension_settings WHERE extensionId = 'core' AND key = 'darkMode'" 798 + "SELECT value FROM feature_settings WHERE featureId = 'core' AND key = 'darkMode'" 801 799 ).get() as { value: string } | undefined; 802 800 if (row?.value) { 803 801 // Value is JSON-encoded (stored via setThemeSetting which JSON.stringify's)
+1 -1
backend/electron/extensions.ts
··· 167 167 // Extensions may use 'enabled' for their own purposes (e.g., HUD overlay toggle), 168 168 // so we use a distinct key that only the extension manager writes. 169 169 const setting = db.prepare( 170 - 'SELECT * FROM extension_settings WHERE extensionId = ? AND key = ?' 170 + 'SELECT * FROM feature_settings WHERE featureId = ? AND key = ?' 171 171 ).get(extId, 'ext_disabled') as { value?: string } | undefined; 172 172 173 173 if (setting) {
-1
backend/electron/index.ts
··· 200 200 getDarkModeSetting, 201 201 applyDarkModeSetting, 202 202 reopenLastClosedWindow, 203 - cleanupGroupScreenBorder, 204 203 } from './ipc.js'; 205 204 206 205 // Window helpers
+94 -454
backend/electron/ipc.ts
··· 326 326 } 327 327 } 328 328 329 - function autoTagIfGroupMode(ev: Electron.IpcMainInvokeEvent, itemId: string): boolean { 330 - try { 331 - const callingWin = BrowserWindow.fromWebContents(ev.sender); 332 - const callerWinId = callingWin && !callingWin.isDestroyed() ? callingWin.id : null; 333 - const callerMode = callerWinId ? getContextEntry('mode', callerWinId) : null; 334 - 335 - // Only tag if the calling window itself is in group mode. 336 - // Do NOT fall back to lastFocusedVisibleWindowId — that causes group leakage 337 - // where items from external apps, cmd palette, etc. get tagged with whatever 338 - // group happened to be active on the last focused window. 339 - if (callerMode && callerMode.value === 'group' && callerMode.metadata?.groupId) { 340 - const groupId = callerMode.metadata.groupId as string; 341 - tagItemAndPublish(itemId, groupId); 342 - console.log('[ipc] Auto-tagged item', itemId, 'with group', groupId, '(from caller)'); 343 - return true; 344 - } 345 - } catch (e) { 346 - console.log('[ipc] autoTagIfGroupMode error:', e); 347 - } 348 - return false; 349 - } 350 - 351 - // Group mode screen border — transparent overlay window showing a solid colored 352 - // border around the entire screen when any window is in group mode. 353 - let groupScreenBorderWin: BrowserWindow | null = null; 354 - let groupScreenBorderColor: string | null = null; 355 - // Shutdown flag — prevents updateGroupScreenBorder from recreating the overlay 356 - // after cleanupGroupScreenBorder destroys it during quit (window:closed events 357 - // fire during shutdown and would otherwise trigger recreation). 358 - let groupScreenBorderShuttingDown = false; 359 - 360 - // Strong, vivid colors derived from iOS theme palette for group screen border. 361 - // Used when the group's own color is too pale or missing. 362 - const VIVID_GROUP_COLORS = [ 363 - '#ff3b30', // red 364 - '#ff9500', // orange 365 - '#34c759', // green 366 - '#007aff', // blue 367 - '#af52de', // purple 368 - '#5ac8fa', // cyan 369 - '#ff2d55', // pink 370 - '#ff9f0a', // amber 371 - ]; 372 - 373 - /** 374 - * Pick a vivid border color: use the group's color if it has enough saturation, 375 - * otherwise deterministically pick a vivid color from the palette based on the 376 - * group ID hash. 377 - */ 378 - function resolveGroupBorderColor(color: string | undefined, groupId: string | undefined): string { 379 - if (color && color !== '#999' && color !== '#999999') { 380 - // Check if the color has enough saturation/chroma to be visually distinctive. 381 - // Parse hex and check if it's not too grey. 382 - const hex = color.replace('#', ''); 383 - if (hex.length >= 6) { 384 - const r = parseInt(hex.substring(0, 2), 16); 385 - const g = parseInt(hex.substring(2, 4), 16); 386 - const b = parseInt(hex.substring(4, 6), 16); 387 - const max = Math.max(r, g, b); 388 - const min = Math.min(r, g, b); 389 - const chroma = max - min; 390 - // If chroma > 40 the color is vivid enough to use directly 391 - if (chroma > 40) return color; 392 - } 393 - } 394 - // Deterministic pick from vivid palette based on groupId 395 - if (groupId) { 396 - let hash = 0; 397 - for (let i = 0; i < groupId.length; i++) { 398 - hash = ((hash << 5) - hash + groupId.charCodeAt(i)) | 0; 399 - } 400 - return VIVID_GROUP_COLORS[Math.abs(hash) % VIVID_GROUP_COLORS.length]; 401 - } 402 - return '#007aff'; 403 - } 404 - 405 - function showGroupScreenBorder(color: string, name: string): void { 406 - // Sanitize color for CSS injection 407 - const safeColor = /^[#a-zA-Z0-9(),%\s.]+$/.test(color) ? color : '#007aff'; 408 - // Sanitize name for HTML injection 409 - const safeName = name.replace(/[<>"&'\\]/g, ''); 410 - 411 - if (groupScreenBorderWin && !groupScreenBorderWin.isDestroyed()) { 412 - // Update color and name if changed 413 - if (groupScreenBorderColor !== safeColor) { 414 - groupScreenBorderColor = safeColor; 415 - groupScreenBorderWin.webContents.executeJavaScript( 416 - `document.documentElement.style.setProperty('--group-color', '${safeColor}')` 417 - ); 418 - } 419 - groupScreenBorderWin.webContents.executeJavaScript( 420 - `document.getElementById('group-label').textContent = ${JSON.stringify(safeName)}` 421 - ); 422 - if (!groupScreenBorderWin.isVisible()) { 423 - groupScreenBorderWin.showInactive(); 424 - } 425 - return; 426 - } 427 - 428 - groupScreenBorderColor = safeColor; 429 - const primaryDisplay = screen.getPrimaryDisplay(); 430 - const { x, y, width, height } = primaryDisplay.bounds; 431 - 432 - groupScreenBorderWin = new BrowserWindow({ 433 - x, y, width, height, 434 - frame: false, 435 - transparent: true, 436 - hasShadow: false, 437 - alwaysOnTop: true, 438 - focusable: false, 439 - skipTaskbar: true, 440 - roundedCorners: false, 441 - resizable: false, 442 - movable: false, 443 - webPreferences: { 444 - nodeIntegration: false, 445 - contextIsolation: true, 446 - } 447 - }); 448 - 449 - groupScreenBorderWin.setIgnoreMouseEvents(true); 450 - groupScreenBorderWin.setVisibleOnAllWorkspaces(true); 451 - groupScreenBorderWin.setAlwaysOnTop(true, 'screen-saver'); 452 - 453 - const html = `<!DOCTYPE html> 454 - <html style="--group-color: ${safeColor}"> 455 - <head><style> 456 - * { margin: 0; padding: 0; } 457 - html, body { width: 100vw; height: 100vh; background: transparent; overflow: hidden; } 458 - html::after { 459 - content: ''; 460 - position: fixed; 461 - top: 0; left: 0; right: 0; bottom: 0; 462 - border: 4px solid var(--group-color); 463 - border-radius: 10px; 464 - pointer-events: none; 465 - } 466 - #group-label { 467 - position: fixed; 468 - bottom: 8px; 469 - right: 12px; 470 - color: var(--group-color); 471 - font-family: -apple-system, BlinkMacSystemFont, system-ui, sans-serif; 472 - font-size: 11px; 473 - font-weight: 600; 474 - letter-spacing: 0.5px; 475 - text-transform: uppercase; 476 - pointer-events: none; 477 - } 478 - </style></head> 479 - <body> 480 - <div id="group-label">${safeName}</div> 481 - </body> 482 - </html>`; 483 - 484 - groupScreenBorderWin.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`); 485 - groupScreenBorderWin.showInactive(); 486 - 487 - groupScreenBorderWin.on('closed', () => { 488 - groupScreenBorderWin = null; 489 - groupScreenBorderColor = null; 490 - }); 491 - 492 - DEBUG && console.log('[group-screen-border] Created overlay with color:', safeColor, 'name:', safeName); 493 - } 494 - 495 - function hideGroupScreenBorder(): void { 496 - if (groupScreenBorderWin && !groupScreenBorderWin.isDestroyed()) { 497 - groupScreenBorderWin.hide(); 498 - DEBUG && console.log('[group-screen-border] Hidden'); 499 - } 500 - } 501 - 502 - /** 503 - * Destroy the group screen border overlay and clear any pending timers. 504 - * Called during app shutdown to ensure Electron can quit cleanly. 505 - */ 506 - export function cleanupGroupScreenBorder(): void { 507 - groupScreenBorderShuttingDown = true; 508 - if (groupBorderHideTimer) { 509 - clearTimeout(groupBorderHideTimer); 510 - groupBorderHideTimer = null; 511 - } 512 - if (groupScreenBorderWin && !groupScreenBorderWin.isDestroyed()) { 513 - groupScreenBorderWin.destroy(); 514 - DEBUG && console.log('[group-screen-border] Destroyed during shutdown'); 515 - } 516 - groupScreenBorderWin = null; 517 - groupScreenBorderColor = null; 518 - } 519 - 520 - // Debounce timer for hiding the screen border — prevents flicker during 521 - // navigation transitions where group mode is briefly absent. 522 - let groupBorderHideTimer: ReturnType<typeof setTimeout> | null = null; 523 - const GROUP_BORDER_HIDE_DELAY_MS = 600; 524 - 525 - /** 526 - * Debounce the hide — a new group window may be about to gain focus. 527 - * Prevents flicker during window transitions. 528 - */ 529 - function scheduleHideGroupScreenBorder(): void { 530 - if (!groupBorderHideTimer) { 531 - groupBorderHideTimer = setTimeout(() => { 532 - groupBorderHideTimer = null; 533 - hideGroupScreenBorder(); 534 - }, GROUP_BORDER_HIDE_DELAY_MS); 535 - } 536 - } 537 - 538 - /** 539 - * Check if the currently focused visible window is in group mode and show/hide the screen border. 540 - * The border is a per-focused-window indicator, NOT a global "any window has group mode" indicator. 541 - * Showing is immediate; hiding is debounced to avoid flicker during window transitions. 542 - * 543 - * Special case: modal windows (cmd palette, etc.) are "transparent" to the border — focusing 544 - * a modal does not hide the border, since it would flicker constantly during normal group work. 545 - */ 546 - export function updateGroupScreenBorder(): void { 547 - if (groupScreenBorderShuttingDown) return; 548 - 549 - // Don't show the border when Peek is not the active/foreground app. 550 - // Without this guard, background events (sync, context changes, window management) 551 - // can re-show the border overlay even though the user switched to another app. 552 - if (!getIzuiCoordinator().isAppFocused()) { 553 - scheduleHideGroupScreenBorder(); 554 - return; 555 - } 556 - 557 - if (!lastFocusedVisibleWindowId) { 558 - scheduleHideGroupScreenBorder(); 559 - return; 560 - } 561 - const win = BrowserWindow.fromId(lastFocusedVisibleWindowId); 562 - if (!win || win.isDestroyed()) { 563 - scheduleHideGroupScreenBorder(); 564 - return; 565 - } 566 - 567 - // Skip modal/transient windows (cmd palette, etc.) — they're "transparent" to border state. 568 - // When a modal is focused, keep the border in its current state (don't show or hide). 569 - const winInfo = getWindowInfo(lastFocusedVisibleWindowId); 570 - if (winInfo?.params?.modal) return; 571 - 572 - const entry = getContextEntry('mode', lastFocusedVisibleWindowId); 573 - if (entry && entry.value === 'group' && entry.metadata?.groupId) { 574 - // Cancel any pending hide — focused window is in group mode 575 - if (groupBorderHideTimer) { 576 - clearTimeout(groupBorderHideTimer); 577 - groupBorderHideTimer = null; 578 - } 579 - const rawColor = entry.metadata.color as string | undefined; 580 - const groupId = entry.metadata.groupId as string | undefined; 581 - const color = resolveGroupBorderColor(rawColor, groupId); 582 - const groupName = (entry.metadata.groupName as string) || 'group'; 583 - showGroupScreenBorder(color, groupName); 584 - } else { 585 - scheduleHideGroupScreenBorder(); 586 - } 587 - } 588 329 589 330 /** 590 331 * Register datastore IPC handlers ··· 613 354 } 614 355 615 356 // Emit item:created event for consistency 357 + const callingWinId1 = BrowserWindow.fromWebContents(ev.sender)?.id ?? null; 616 358 publish('system', PubSubScopes.GLOBAL, 'item:created', { 617 359 itemId: result.id, 618 360 itemType: 'url', 619 - content: normalizedUri 361 + content: normalizedUri, 362 + windowId: callingWinId1 620 363 }); 621 - 622 - // Auto-tag with group if calling window is in group mode 623 - autoTagIfGroupMode(ev, result.id); 624 364 625 365 return { success: true, data: result, id: result.id }; 626 366 } catch (error) { ··· 765 505 return { success: false, error: `Invalid table: ${tableName}` }; 766 506 } 767 507 768 - // When promoting a tag to a group, ensure it gets a vivid color 769 - let assignedVividColor = false; 770 - if (tableName === 'tags' && rowData) { 771 - let tagMeta: Record<string, unknown> | null = null; 772 - try { 773 - tagMeta = rowData.metadata 774 - ? (typeof rowData.metadata === 'string' ? JSON.parse(rowData.metadata) : rowData.metadata) 775 - : null; 776 - } catch { /* ignore parse errors */ } 777 - 778 - if (tagMeta?.isGroup && (!rowData.color || rowData.color === '#999' || rowData.color === '#999999')) { 779 - const vividColor = resolveGroupBorderColor(rowData.color, rowId); 780 - rowData.color = vividColor; 781 - assignedVividColor = true; 782 - DEBUG && console.log('[ipc] Auto-assigned vivid color to group:', rowId, vividColor); 783 - } 784 - } 785 - 786 508 const result = setRow(tableName, rowId, rowData); 787 - 788 - // Publish color-changed event so UI dots update immediately 789 - if (assignedVividColor) { 790 - publish('system', PubSubScopes.GLOBAL, 'tag:color-changed', { 791 - tagId: rowId, 792 - color: rowData.color 793 - }); 794 - } 795 509 796 510 return { success: true, data: result }; 797 511 } catch (error) { ··· 1005 719 const result = addItem(data.type, data.options); 1006 720 1007 721 // Emit item:created event 722 + const callingWinId2 = BrowserWindow.fromWebContents(ev.sender)?.id ?? null; 1008 723 publish('system', PubSubScopes.GLOBAL, 'item:created', { 1009 724 itemId: result.id, 1010 725 itemType: data.type, 1011 - content: data.options?.content 726 + content: data.options?.content, 727 + windowId: callingWinId2 1012 728 }); 1013 729 if (DEBUG) console.log('[ipc] item:created', result.id, data.type); 1014 - 1015 - // Auto-tag with group if calling window is in group mode 1016 - autoTagIfGroupMode(ev, result.id); 1017 730 1018 731 return { success: true, data: result }; 1019 732 } catch (error) { ··· 1248 961 try { 1249 962 const result = trackNavigation(data.uri, data.options); 1250 963 1251 - // Auto-tag with group if calling window is in group mode 1252 - autoTagIfGroupMode(ev, result.itemId); 1253 - 1254 964 return { success: true, data: result }; 1255 965 } catch (error) { 1256 966 const message = error instanceof Error ? error.message : String(error); ··· 1534 1244 1535 1245 // Remove from database 1536 1246 db.prepare('DELETE FROM extensions WHERE id = ?').run(extId); 1537 - db.prepare('DELETE FROM extension_settings WHERE extensionId = ?').run(extId); 1247 + db.prepare('DELETE FROM feature_settings WHERE featureId = ?').run(extId); 1538 1248 1539 1249 return { success: true, data: { id: extId } }; 1540 1250 } catch (error) { ··· 1723 1433 } 1724 1434 }); 1725 1435 1726 - // Extension settings handlers 1436 + // Feature settings handlers (renamed from extension-settings) 1727 1437 // Note: preload sends { extId } but we accept both extId and id for compatibility 1728 - ipcMain.handle('extension-settings-get', async (ev, data) => { 1438 + ipcMain.handle('feature-settings-get', async (ev, data) => { 1729 1439 try { 1730 1440 const db = getDb(); 1731 1441 const extId = data.extId || data.id; 1732 1442 const settings = db.prepare( 1733 - 'SELECT * FROM extension_settings WHERE extensionId = ?' 1443 + 'SELECT * FROM feature_settings WHERE featureId = ?' 1734 1444 ).all(extId) as Array<{ key: string; value: string }>; 1735 1445 1736 1446 const result: Record<string, unknown> = {}; ··· 1748 1458 } 1749 1459 }); 1750 1460 1751 - ipcMain.handle('extension-settings-set', async (ev, data) => { 1461 + ipcMain.handle('feature-settings-set', async (ev, data) => { 1752 1462 try { 1753 1463 const db = getDb(); 1754 1464 const extId = data.extId || data.id; ··· 1757 1467 for (const [key, value] of Object.entries(settings)) { 1758 1468 const jsonValue = JSON.stringify(value); 1759 1469 db.prepare(` 1760 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 1470 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 1761 1471 VALUES (?, ?, ?, ?, ?) 1762 1472 `).run(`${extId}_${key}`, extId, key, jsonValue, Date.now()); 1763 1473 } ··· 1769 1479 } 1770 1480 }); 1771 1481 1772 - ipcMain.handle('extension-settings-get-key', async (ev, data) => { 1482 + ipcMain.handle('feature-settings-get-key', async (ev, data) => { 1773 1483 try { 1774 1484 const db = getDb(); 1775 1485 const extId = data.extId || data.id; 1776 1486 const setting = db.prepare( 1777 - 'SELECT * FROM extension_settings WHERE extensionId = ? AND key = ?' 1487 + 'SELECT * FROM feature_settings WHERE featureId = ? AND key = ?' 1778 1488 ).get(extId, data.key) as { value: string } | undefined; 1779 1489 1780 1490 if (!setting) { ··· 1792 1502 } 1793 1503 }); 1794 1504 1795 - ipcMain.handle('extension-settings-set-key', async (ev, data) => { 1505 + ipcMain.handle('feature-settings-set-key', async (ev, data) => { 1796 1506 try { 1797 1507 const db = getDb(); 1798 1508 const extId = data.extId || data.id; 1799 1509 const jsonValue = JSON.stringify(data.value); 1800 1510 db.prepare(` 1801 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 1511 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 1802 1512 VALUES (?, ?, ?, ?, ?) 1803 1513 `).run(`${extId}_${data.key}`, extId, data.key, jsonValue, Date.now()); 1804 1514 ··· 1830 1540 } 1831 1541 }); 1832 1542 1833 - ipcMain.handle('extension-settings-schema', async (ev, data) => { 1543 + ipcMain.handle('feature-settings-schema', async (ev, data) => { 1834 1544 try { 1835 1545 const extPath = getExtensionPath(data.id); 1836 1546 if (!extPath) { ··· 1872 1582 try { 1873 1583 const db = getDb(); 1874 1584 const row = db.prepare( 1875 - 'SELECT value FROM extension_settings WHERE extensionId = ? AND key = ?' 1585 + 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 1876 1586 ).get(THEME_SETTINGS_KEY, key) as { value: string } | undefined; 1877 1587 if (!row?.value) return null; 1878 1588 // Parse JSON-encoded value, fall back to raw value for backwards compatibility ··· 1894 1604 const db = getDb(); 1895 1605 const timestamp = Date.now(); 1896 1606 db.prepare(` 1897 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 1607 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 1898 1608 VALUES (?, ?, ?, ?, ?) 1899 1609 `).run(`${THEME_SETTINGS_KEY}_${key}`, THEME_SETTINGS_KEY, key, JSON.stringify(value), timestamp); 1900 1610 } ··· 2420 2130 options.height = entry.bounds.height; 2421 2131 } 2422 2132 2423 - if (entry.groupMode) { 2424 - options.groupMode = entry.groupMode; 2133 + if (entry.spaceMode) { 2134 + options.spaceMode = entry.spaceMode; 2425 2135 } 2426 2136 2427 2137 // Publish event for the background window to pick up and open ··· 2809 2519 } 2810 2520 } 2811 2521 2812 - // Check for group mode inheritance from options 2813 - // If opener passed groupMode, inherit it instead of URL-based detection 2814 - if (options.groupMode) { 2815 - const { groupId, groupName, color } = options.groupMode; 2816 - // Set group mode via context API 2817 - addContextEntry('mode', 'group', { 2522 + // Check for space mode inheritance from options 2523 + // If opener passed spaceMode, inherit it instead of URL-based detection 2524 + if (options.spaceMode) { 2525 + const { spaceId, spaceName, color } = options.spaceMode; 2526 + // Set space mode via context API 2527 + addContextEntry('mode', 'space', { 2818 2528 windowId: win.id, 2819 2529 source: msg.source, 2820 - metadata: { groupId, groupName, color, url: modeUrl } 2530 + metadata: { spaceId, spaceName, color, url: modeUrl } 2821 2531 }); 2822 - DEBUG && console.log('Inherited group mode for window:', win.id, 'group:', groupName); 2532 + DEBUG && console.log('Inherited space mode for window:', win.id, 'space:', spaceName); 2823 2533 } else { 2824 - // Check if opener is in group mode - inherit if so 2825 - let openerGroupMode = null; 2534 + // Check if opener is in space mode - inherit if so 2535 + let openerSpaceMode = null; 2826 2536 const callingWin = BrowserWindow.fromWebContents(ev.sender); 2827 2537 if (callingWin && !callingWin.isDestroyed()) { 2828 2538 const openerContext = getContextEntry('mode', callingWin.id); 2829 - if (openerContext && openerContext.value === 'group' && openerContext.metadata) { 2830 - openerGroupMode = openerContext; 2539 + if (openerContext && openerContext.value === 'space' && openerContext.metadata) { 2540 + openerSpaceMode = openerContext; 2831 2541 } 2832 2542 } 2833 2543 2834 - if (openerGroupMode) { 2835 - // Inherit group mode from opener (window lineage) 2836 - addContextEntry('mode', 'group', { 2544 + if (openerSpaceMode) { 2545 + // Inherit space mode from opener (window lineage) 2546 + addContextEntry('mode', 'space', { 2837 2547 windowId: win.id, 2838 2548 source: msg.source, 2839 2549 metadata: { 2840 - ...openerGroupMode.metadata, 2550 + ...openerSpaceMode.metadata, 2841 2551 url: modeUrl, 2842 - inheritedFrom: openerGroupMode.windowId 2552 + inheritedFrom: openerSpaceMode.windowId 2843 2553 } 2844 2554 }); 2845 - DEBUG && console.log('Inherited group mode from opener:', openerGroupMode.metadata?.groupName); 2555 + DEBUG && console.log('Inherited space mode from opener:', openerSpaceMode.metadata?.spaceName); 2846 2556 } else { 2847 - // No group mode inheritance — only explicit groupMode option or direct 2848 - // opener lineage propagates group mode. Do NOT fall back to 2849 - // lastFocusedVisibleWindowId, as that causes group leakage to unrelated 2557 + // No space mode inheritance — only explicit spaceMode option or direct 2558 + // opener lineage propagates space mode. Do NOT fall back to 2559 + // lastFocusedVisibleWindowId, as that causes space leakage to unrelated 2850 2560 // windows (external app opens, peeks, slides, etc.) 2851 2561 const detectedMode = detectModeFromUrl(modeUrl); 2852 2562 addContextEntry('mode', detectedMode, { ··· 2958 2668 publish('system', PubSubScopes.GLOBAL, 'item:created', { 2959 2669 itemId: popupTrack.itemId, 2960 2670 itemType: 'url', 2961 - content: popupUrl 2671 + content: popupUrl, 2672 + windowId: win.id 2962 2673 }); 2963 2674 } 2964 2675 } catch (e) { 2965 2676 DEBUG && console.log('Failed to track webview popup:', e); 2966 2677 } 2967 2678 2968 - // Inherit group mode from parent window if applicable 2969 - let groupMode: { groupId: string; groupName: string; color: string } | undefined = undefined; 2679 + // Inherit space mode from parent window if applicable 2680 + let spaceMode: { spaceId: string; spaceName: string; color: string } | undefined = undefined; 2970 2681 const parentContext = getContextEntry('mode', win.id); 2971 - if (parentContext && parentContext.value === 'group' && parentContext.metadata) { 2972 - groupMode = { 2973 - groupId: parentContext.metadata.groupId as string, 2974 - groupName: parentContext.metadata.groupName as string, 2682 + if (parentContext && parentContext.value === 'space' && parentContext.metadata) { 2683 + spaceMode = { 2684 + spaceId: parentContext.metadata.spaceId as string, 2685 + spaceName: parentContext.metadata.spaceName as string, 2975 2686 color: parentContext.metadata.color as string, 2976 2687 }; 2977 - // Auto-tag popup item with the group tag 2978 - if (popupItemId) { 2979 - try { 2980 - tagItemAndPublish(popupItemId, groupMode.groupId); 2981 - DEBUG && console.log('[webview-popup] Auto-tagged popup item', popupItemId, 'with group', groupMode.groupId); 2982 - } catch (e) { 2983 - DEBUG && console.log('[webview-popup] Failed to auto-tag popup:', e); 2984 - } 2985 - } 2986 2688 } 2987 2689 2988 2690 // Get the source address from the parent window's registration ··· 3050 2752 parentWindowId: win.id, 3051 2753 role: 'child-content', 3052 2754 }; 3053 - if (groupMode) { 3054 - popupParams.groupMode = groupMode; 2755 + if (spaceMode) { 2756 + popupParams.spaceMode = spaceMode; 3055 2757 } 3056 2758 registerWindow(popupWin.id, source, popupParams); 3057 2759 trackWindow(popupWin); 3058 2760 coordinator.pushWindow(popupWin.id); 3059 2761 3060 2762 // Set mode context (inherit group mode or detect from URL) 3061 - if (groupMode) { 3062 - addContextEntry('mode', 'group', { 2763 + if (spaceMode) { 2764 + addContextEntry('mode', 'space', { 3063 2765 windowId: popupWin.id, 3064 2766 source, 3065 - metadata: { ...groupMode, url: popupUrl, inheritedFrom: win.id }, 2767 + metadata: { ...spaceMode, url: popupUrl, inheritedFrom: win.id }, 3066 2768 }); 3067 2769 } else { 3068 2770 const detectedMode = detectModeFromUrl(popupUrl); ··· 3133 2835 console.log(`[page-host:${win.id}] Loading: ${loadUrl.substring(0, 120)}`); 3134 2836 await win.loadURL(loadUrl); 3135 2837 console.log(`[page-host:${win.id}] loadURL resolved`); 3136 - 3137 - // Update group screen border if this window entered group mode 3138 - updateGroupScreenBorder(); 3139 2838 3140 2839 // Background detection for non-canvas web pages (slides, modals, quick-views). 3141 2840 // Canvas pages get this via the webview dom-ready handler in page.js. ··· 3233 2932 publish('system', PubSubScopes.GLOBAL, 'item:created', { 3234 2933 itemId: trackResult.itemId, 3235 2934 itemType: 'url', 3236 - content: url 2935 + content: url, 2936 + windowId: win.id 3237 2937 }); 3238 - } 3239 - // Auto-tag with group if this window is in group mode 3240 - const loadModeEntry = getContextEntry('mode', win.id); 3241 - console.log('[openWindow] trackWindowLoad auto-tag check:', { winId: win.id, mode: loadModeEntry?.value, groupId: loadModeEntry?.metadata?.groupId, itemId: trackResult.itemId }); 3242 - if (loadModeEntry && loadModeEntry.value === 'group' && loadModeEntry.metadata?.groupId) { 3243 - tagItemAndPublish(trackResult.itemId, loadModeEntry.metadata.groupId as string); 3244 - console.log('[openWindow] Auto-tagged item', trackResult.itemId, 'with group', loadModeEntry.metadata.groupId); 3245 2938 } 3246 2939 } catch (e) { 3247 2940 DEBUG && console.log('Failed to track window load:', e); ··· 3267 2960 publish('system', PubSubScopes.GLOBAL, 'item:created', { 3268 2961 itemId: navTrack.itemId, 3269 2962 itemType: 'url', 3270 - content: navUrl 2963 + content: navUrl, 2964 + windowId: win.id 3271 2965 }); 3272 - } 3273 - // Auto-tag with group if this window is in group mode 3274 - const navModeEntry = getContextEntry('mode', win.id); 3275 - if (navModeEntry && navModeEntry.value === 'group' && navModeEntry.metadata?.groupId) { 3276 - tagItemAndPublish(navTrack.itemId, navModeEntry.metadata.groupId as string); 3277 - DEBUG && console.log('[did-navigate] Auto-tagged item', navTrack.itemId, 'with group', navModeEntry.metadata.groupId); 3278 2966 } 3279 2967 } catch (e) { 3280 2968 DEBUG && console.log('Failed to track did-navigate:', e); ··· 3404 3092 if (!win || win.isDestroyed()) return { success: false }; 3405 3093 win.setIgnoreMouseEvents(msg.ignore, msg.forward ? { forward: true } : undefined); 3406 3094 return { success: true }; 3095 + }); 3096 + 3097 + // Set visible on all workspaces (macOS Spaces) 3098 + ipcMain.handle('window-set-visible-on-all-workspaces', (ev, msg) => { 3099 + const win = msg?.id ? BrowserWindow.fromId(msg.id) : BrowserWindow.fromWebContents(ev.sender); 3100 + if (!win || win.isDestroyed()) return { success: false }; 3101 + win.setVisibleOnAllWorkspaces(msg.visible, msg.options || {}); 3102 + return { success: true }; 3103 + }); 3104 + 3105 + // Get primary display info 3106 + ipcMain.handle('screen-get-primary-display', () => { 3107 + const display = screen.getPrimaryDisplay(); 3108 + return { 3109 + success: true, 3110 + data: { 3111 + bounds: display.bounds, 3112 + workArea: display.workArea, 3113 + scaleFactor: display.scaleFactor, 3114 + size: display.size, 3115 + } 3116 + }; 3407 3117 }); 3408 3118 3409 3119 // Return full bounds (position + size) for any window ··· 5046 4756 * New code should use the context API (api.context) instead. 5047 4757 */ 5048 4758 export function registerModesHandlers(): void { 5049 - // Update group screen border when focus changes — border tracks the focused window's mode 5050 - subscribe(getSystemAddress(), PubSubScopes.GLOBAL, 'window:focused', () => { 5051 - updateGroupScreenBorder(); 5052 - }); 5053 - 5054 - // Update group screen border when windows close (focused window may have closed) 5055 - subscribe(getSystemAddress(), PubSubScopes.GLOBAL, 'window:closed', () => { 5056 - updateGroupScreenBorder(); 5057 - }); 5058 - 5059 - // Hide group screen border when app loses focus (no window is active) 5060 - subscribe(getSystemAddress(), PubSubScopes.GLOBAL, 'app:focus-changed', (msg: unknown) => { 5061 - const data = msg as { focused: boolean }; 5062 - if (!data.focused) { 5063 - scheduleHideGroupScreenBorder(); 5064 - } else { 5065 - updateGroupScreenBorder(); 5066 - } 5067 - }); 5068 - 5069 - // Reset group mode when the group tag is removed from a page 5070 - subscribe(getSystemAddress(), PubSubScopes.GLOBAL, 'tag:item-removed', (msg: unknown) => { 5071 - const data = msg as { tagId: string; itemId: string }; 5072 - if (!data.tagId || !data.itemId) return; 5073 - 5074 - // Find all windows in group mode for this specific tag 5075 - const groupWindowIds = getWindowsMatchingContext('mode', (entry) => { 5076 - return entry.value === 'group' && entry.metadata?.groupId === data.tagId; 5077 - }); 5078 - if (groupWindowIds.length === 0) return; 5079 - 5080 - // Get the item to find its URL 5081 - const item = getItem(data.itemId); 5082 - if (!item) return; 5083 - const itemUrl = item.type === 'url' && item.content ? normalizeUrl(item.content) : null; 5084 - if (!itemUrl) return; 5085 - 5086 - // Check each group window — if its URL matches the removed item, reset to page mode 5087 - for (const windowId of groupWindowIds) { 5088 - const entry = getContextEntry('mode', windowId); 5089 - const windowUrl = entry?.metadata?.url as string | undefined; 5090 - if (windowUrl && normalizeUrl(windowUrl) === itemUrl) { 5091 - const result = addContextEntry('mode', 'page', { 5092 - windowId, 5093 - source: 'tag-removed', 5094 - metadata: { url: windowUrl } 5095 - }); 5096 - publish(getSystemAddress(), PubSubScopes.GLOBAL, 'context:changed', { 5097 - key: 'mode', 5098 - value: 'page', 5099 - metadata: { url: windowUrl }, 5100 - windowId, 5101 - source: 'tag-removed', 5102 - entryId: result.id 5103 - }); 5104 - publish(getSystemAddress(), PubSubScopes.GLOBAL, 'modes:changed', { 5105 - windowId, 5106 - major: 'page', 5107 - }); 5108 - DEBUG && console.log('[modes] Reset window', windowId, 'from group to page after tag removal'); 5109 - } 5110 - } 5111 - updateGroupScreenBorder(); 5112 - }); 5113 - 5114 4759 // Mode definitions 5115 4760 const MODES = [ 5116 4761 { id: 'default', name: 'Default', description: 'Standard browsing mode' }, 5117 4762 { id: 'page', name: 'Page', description: 'Viewing a web page' }, 5118 - { id: 'group', name: 'Group', description: 'Managing tab groups' }, 4763 + { id: 'space', name: 'Space', description: 'Working in a space' }, 5119 4764 ]; 5120 4765 5121 4766 // Get mode state for a window (uses context API) ··· 5268 4913 windowId, 5269 4914 source 5270 4915 }); 5271 - 5272 - // Update group screen border when mode changes 5273 - if (data.key === 'mode') { 5274 - updateGroupScreenBorder(); 5275 - } 5276 4916 5277 4917 // Publish context change event for watchers 5278 4918 if (publish && PubSubScopes && getSystemAddress) { ··· 5339 4979 } 5340 4980 }); 5341 4981 5342 - // Get windows in a specific group (convenience method for mode='group' with groupId) 5343 - ipcMain.handle('context-windows-in-group', async (ev, data: { groupId: string }) => { 4982 + // Get windows in a specific space (convenience method for mode='space' with spaceId) 4983 + ipcMain.handle('context-windows-in-space', async (ev, data: { spaceId: string }) => { 5344 4984 try { 5345 4985 const windowIds = getWindowsMatchingContext('mode', (entry) => { 5346 - return entry.value === 'group' && entry.metadata?.groupId === data.groupId; 4986 + return entry.value === 'space' && entry.metadata?.spaceId === data.spaceId; 5347 4987 }); 5348 4988 return { success: true, data: windowIds }; 5349 4989 } catch (error) { ··· 5365 5005 const CORE_EXT_ID = 'core'; 5366 5006 const PREFS_KEY = 'prefs'; 5367 5007 const row = db.prepare( 5368 - 'SELECT value FROM extension_settings WHERE extensionId = ? AND key = ?' 5008 + 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 5369 5009 ).get(CORE_EXT_ID, PREFS_KEY) as { value: string } | undefined; 5370 5010 let prefs: Record<string, unknown> = {}; 5371 5011 if (row?.value) { ··· 5374 5014 prefs.adBlockerEnabled = enabled; 5375 5015 const timestamp = Date.now(); 5376 5016 db.prepare(` 5377 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 5017 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 5378 5018 VALUES (?, ?, ?, ?, ?) 5379 5019 `).run(`${CORE_EXT_ID}_${PREFS_KEY}`, CORE_EXT_ID, PREFS_KEY, JSON.stringify(prefs), timestamp); 5380 5020 } catch (error) { ··· 5526 5166 const CORE_EXT_ID = 'core'; 5527 5167 const KEY = 'adblocker_allowlist'; 5528 5168 const row = db.prepare( 5529 - 'SELECT value FROM extension_settings WHERE extensionId = ? AND key = ?' 5169 + 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 5530 5170 ).get(CORE_EXT_ID, KEY) as { value: string } | undefined; 5531 5171 const allowlist: string[] = row?.value ? JSON.parse(row.value) : []; 5532 5172 return { success: true, data: allowlist }; ··· 5543 5183 const CORE_EXT_ID = 'core'; 5544 5184 const KEY = 'adblocker_allowlist'; 5545 5185 const row = db.prepare( 5546 - 'SELECT value FROM extension_settings WHERE extensionId = ? AND key = ?' 5186 + 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 5547 5187 ).get(CORE_EXT_ID, KEY) as { value: string } | undefined; 5548 5188 const allowlist: string[] = row?.value ? JSON.parse(row.value) : []; 5549 5189 return { success: true, data: allowlist.includes(data.hostname) }; ··· 5560 5200 const CORE_EXT_ID = 'core'; 5561 5201 const KEY = 'adblocker_allowlist'; 5562 5202 const row = db.prepare( 5563 - 'SELECT value FROM extension_settings WHERE extensionId = ? AND key = ?' 5203 + 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 5564 5204 ).get(CORE_EXT_ID, KEY) as { value: string } | undefined; 5565 5205 let allowlist: string[] = row?.value ? JSON.parse(row.value) : []; 5566 5206 if (!allowlist.includes(data.hostname)) { ··· 5568 5208 } 5569 5209 const timestamp = Date.now(); 5570 5210 db.prepare(` 5571 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 5211 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 5572 5212 VALUES (?, ?, ?, ?, ?) 5573 5213 `).run(`${CORE_EXT_ID}_${KEY}`, CORE_EXT_ID, KEY, JSON.stringify(allowlist), timestamp); 5574 5214 return { success: true }; ··· 5585 5225 const CORE_EXT_ID = 'core'; 5586 5226 const KEY = 'adblocker_allowlist'; 5587 5227 const row = db.prepare( 5588 - 'SELECT value FROM extension_settings WHERE extensionId = ? AND key = ?' 5228 + 'SELECT value FROM feature_settings WHERE featureId = ? AND key = ?' 5589 5229 ).get(CORE_EXT_ID, KEY) as { value: string } | undefined; 5590 5230 let allowlist: string[] = row?.value ? JSON.parse(row.value) : []; 5591 5231 allowlist = allowlist.filter(h => h !== data.hostname); 5592 5232 const timestamp = Date.now(); 5593 5233 db.prepare(` 5594 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 5234 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 5595 5235 VALUES (?, ?, ?, ?, ?) 5596 5236 `).run(`${CORE_EXT_ID}_${KEY}`, CORE_EXT_ID, KEY, JSON.stringify(allowlist), timestamp); 5597 5237 return { success: true }; ··· 5686 5326 }); 5687 5327 5688 5328 // Save group workspace layouts on demand (called from groups extension) 5689 - ipcMain.handle('save-group-workspaces', async () => { 5329 + ipcMain.handle('save-space-workspaces', async () => { 5690 5330 try { 5691 5331 const { saveGroupWorkspaces } = await import('./session.js'); 5692 5332 saveGroupWorkspaces();
+9 -9
backend/electron/main.ts
··· 74 74 75 75 // Built-in extensions that load in consolidated mode (iframes) 76 76 // External extensions (including 'example') load in separate windows 77 - const CONSOLIDATED_EXTENSION_IDS = ['cmd', 'editor', 'groups', 'hud', 'lex', 'lists', 'peeks', 'search', 'slides', 'websearch', 'windows', 'page', 'files', 'pagestream', 'sheets', 'tags', 'feeds', 'helpdocs', 'entities', 'scripts', 'timers', 'wonderwall']; 77 + const CONSOLIDATED_EXTENSION_IDS = ['cmd', 'editor', 'groups', 'hud', 'lex', 'lists', 'peeks', 'search', 'slides', 'spaces', 'websearch', 'windows', 'page', 'files', 'pagestream', 'sheets', 'tags', 'feeds', 'helpdocs', 'entities', 'scripts', 'timers', 'wonderwall']; 78 78 79 79 // Extensions that must load eagerly (not lazy) — needed at startup 80 80 const EAGER_EXTENSION_IDS = new Set(['cmd', 'hud', 'entities']); ··· 103 103 url: string; // Original URL (not the peek:// rewritten one) 104 104 source: string; 105 105 bounds: { x: number; y: number; width: number; height: number } | null; 106 - groupMode: { groupId: string; groupName: string; color?: string } | null; 106 + spaceMode: { spaceId: string; spaceName: string; color?: string } | null; 107 107 timestamp: number; 108 108 } 109 109 ··· 301 301 const isContentRole = !role || role === 'content' || role === 'child-content' || role === 'workspace'; 302 302 303 303 if (isWebUrl && isContentRole && !isKeepLive && !isModal) { 304 - // Check for group mode context 305 - let groupMode: ClosedWindowEntry['groupMode'] = null; 304 + // Check for space mode context 305 + let spaceMode: ClosedWindowEntry['spaceMode'] = null; 306 306 try { 307 307 const modeEntry = getContextEntry('mode', windowId); 308 - if (modeEntry && modeEntry.value === 'group' && modeEntry.metadata) { 309 - groupMode = { 310 - groupId: modeEntry.metadata.groupId as string, 311 - groupName: modeEntry.metadata.groupName as string, 308 + if (modeEntry && modeEntry.value === 'space' && modeEntry.metadata) { 309 + spaceMode = { 310 + spaceId: modeEntry.metadata.spaceId as string, 311 + spaceName: modeEntry.metadata.spaceName as string, 312 312 color: modeEntry.metadata.color as string | undefined, 313 313 }; 314 314 } ··· 335 335 url: address, 336 336 source: windowData.source, 337 337 bounds: saveBounds, 338 - groupMode, 338 + spaceMode, 339 339 timestamp: Date.now(), 340 340 }); 341 341 }
+23 -23
backend/electron/session.test.ts
··· 2 2 * Integration tests for session save/restore 3 3 * 4 4 * These tests use a REAL better-sqlite3 database with the same schema 5 - * as the production extension_settings table. They test the actual SQL 5 + * as the production feature_settings table. They test the actual SQL 6 6 * operations, data structures, and logic flows from session.ts. 7 7 * 8 8 * Why these tests exist: ··· 143 143 function createTestDb(): InstanceType<typeof Database> { 144 144 const db = new Database(':memory:'); 145 145 db.exec(` 146 - CREATE TABLE IF NOT EXISTS extension_settings ( 146 + CREATE TABLE IF NOT EXISTS feature_settings ( 147 147 id TEXT PRIMARY KEY, 148 - extensionId TEXT NOT NULL, 148 + featureId TEXT NOT NULL, 149 149 key TEXT NOT NULL, 150 150 value TEXT, 151 151 updatedAt INTEGER 152 152 ); 153 - CREATE UNIQUE INDEX IF NOT EXISTS idx_extension_settings_unique 154 - ON extension_settings(extensionId, key); 153 + CREATE UNIQUE INDEX IF NOT EXISTS idx_feature_settings_unique 154 + ON feature_settings(featureId, key); 155 155 `); 156 156 return db; 157 157 } ··· 162 162 */ 163 163 function saveSnapshot(db: InstanceType<typeof Database>, snapshot: SessionSnapshot, reason: string): void { 164 164 db.prepare(` 165 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 165 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 166 166 VALUES (?, 'session', 'snapshot', ?, ?) 167 167 `).run('session-snapshot', JSON.stringify(snapshot), Date.now()); 168 168 ··· 170 170 let existingMetadata: Partial<SessionMetadata> = {}; 171 171 try { 172 172 const metaRow = db.prepare( 173 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'metadata'` 173 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'metadata'` 174 174 ).get() as { value: string } | undefined; 175 175 if (metaRow?.value) { 176 176 existingMetadata = JSON.parse(metaRow.value); ··· 189 189 restoreCount: existingMetadata.restoreCount, 190 190 }; 191 191 db.prepare(` 192 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 192 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 193 193 VALUES (?, 'session', 'metadata', ?, ?) 194 194 `).run('session-metadata', JSON.stringify(metadata), Date.now()); 195 195 } ··· 200 200 function readCrashState(db: InstanceType<typeof Database>): { cleanShutdown: boolean; crashCount: number } { 201 201 try { 202 202 const metaRow = db.prepare( 203 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'metadata'` 203 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'metadata'` 204 204 ).get() as { value: string } | undefined; 205 205 if (metaRow?.value) { 206 206 const metadata = JSON.parse(metaRow.value); ··· 220 220 */ 221 221 function markDirty(db: InstanceType<typeof Database>): void { 222 222 const metaRow = db.prepare( 223 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'metadata'` 223 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'metadata'` 224 224 ).get() as { value: string } | undefined; 225 225 226 226 let metadata: Partial<SessionMetadata> = {}; ··· 237 237 metadata.cleanShutdown = false; 238 238 239 239 db.prepare(` 240 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 240 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 241 241 VALUES (?, 'session', 'metadata', ?, ?) 242 242 `).run('session-metadata', JSON.stringify(metadata), Date.now()); 243 243 } ··· 247 247 */ 248 248 function readSnapshot(db: InstanceType<typeof Database>): SessionSnapshot | null { 249 249 const row = db.prepare( 250 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'snapshot'` 250 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'snapshot'` 251 251 ).get() as { value: string } | undefined; 252 252 if (!row?.value) return null; 253 253 return JSON.parse(row.value); ··· 258 258 */ 259 259 function getSnapshotInfo(db: InstanceType<typeof Database>): { windowCount: number; savedAt: number; reason: string } | null { 260 260 const row = db.prepare( 261 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'snapshot'` 261 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'snapshot'` 262 262 ).get() as { value: string } | undefined; 263 263 if (!row?.value) return null; 264 264 ··· 815 815 816 816 // Verify by reading raw metadata from DB 817 817 const row = db.prepare( 818 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'metadata'` 818 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'metadata'` 819 819 ).get() as { value: string }; 820 820 const metadata = JSON.parse(row.value); 821 821 assert.strictEqual(metadata.cleanShutdown, true, ··· 829 829 markDirty(db); 830 830 831 831 const row = db.prepare( 832 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'metadata'` 832 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'metadata'` 833 833 ).get() as { value: string }; 834 834 const metadata = JSON.parse(row.value); 835 835 assert.strictEqual(metadata.cleanShutdown, false); ··· 851 851 saveSnapshot(db, snapshot, 'before-quit'); 852 852 853 853 const row = db.prepare( 854 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'metadata'` 854 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'metadata'` 855 855 ).get() as { value: string }; 856 856 const metadata = JSON.parse(row.value); 857 857 assert.strictEqual(metadata.crashCount, 2, 'Save should preserve crashCount from existing metadata'); ··· 861 861 it('save preserves restoreCount from existing metadata', () => { 862 862 // Manually set some restore history 863 863 db.prepare(` 864 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 864 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 865 865 VALUES (?, 'session', 'metadata', ?, ?) 866 866 `).run('session-metadata', JSON.stringify({ 867 867 cleanShutdown: true, ··· 879 879 saveSnapshot(db, snapshot, 'before-quit'); 880 880 881 881 const row = db.prepare( 882 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'metadata'` 882 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'metadata'` 883 883 ).get() as { value: string }; 884 884 const metadata = JSON.parse(row.value); 885 885 assert.strictEqual(metadata.restoreCount, 5, 'Save should preserve restoreCount'); ··· 1000 1000 1001 1001 // Write snapshot directly (bypassing saveSnapshot to include invalid entries) 1002 1002 db.prepare(` 1003 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 1003 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 1004 1004 VALUES (?, 'session', 'snapshot', ?, ?) 1005 1005 `).run('session-snapshot', JSON.stringify(snapshot), Date.now()); 1006 1006 ··· 1021 1021 }; 1022 1022 1023 1023 db.prepare(` 1024 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 1024 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 1025 1025 VALUES (?, 'session', 'snapshot', ?, ?) 1026 1026 `).run('session-snapshot', JSON.stringify(snapshot), Date.now()); 1027 1027 ··· 1037 1037 }; 1038 1038 1039 1039 db.prepare(` 1040 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 1040 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 1041 1041 VALUES (?, 'session', 'snapshot', ?, ?) 1042 1042 `).run('session-snapshot', JSON.stringify(snapshot), Date.now()); 1043 1043 ··· 1194 1194 }; 1195 1195 1196 1196 db.prepare(` 1197 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 1197 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 1198 1198 VALUES (?, 'session', 'snapshot', ?, ?) 1199 1199 `).run('session-snapshot', JSON.stringify(snapshot), Date.now()); 1200 1200 ··· 1318 1318 1319 1319 // Only one snapshot row should exist (INSERT OR REPLACE) 1320 1320 const count = db.prepare( 1321 - `SELECT COUNT(*) as count FROM extension_settings WHERE extensionId = 'session' AND key = 'snapshot'` 1321 + `SELECT COUNT(*) as count FROM feature_settings WHERE featureId = 'session' AND key = 'snapshot'` 1322 1322 ).get() as { count: number }; 1323 1323 assert.strictEqual(count.count, 1, 'Should have exactly one snapshot row'); 1324 1324 });
+16 -16
backend/electron/session.ts
··· 6 6 * Phase 3: Periodic autosave timer + manual save/restore commands. 7 7 * Phase 4: Crash recovery dialog, snapshot validation, error handling. 8 8 * 9 - * Captures all visible user windows and writes to extension_settings 9 + * Captures all visible user windows and writes to feature_settings 10 10 * using synchronous better-sqlite3 APIs (safe for before-quit handler). 11 11 * On startup, reads the snapshot and recreates windows. 12 12 * ··· 248 248 DEBUG && console.log(`[session] Captured ${windowDescriptors.length} window(s)`); 249 249 250 250 try { 251 - // Write snapshot to extension_settings table 251 + // Write snapshot to feature_settings table 252 252 db.prepare(` 253 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 253 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 254 254 VALUES (?, 'session', 'snapshot', ?, ?) 255 255 `).run('session-snapshot', JSON.stringify(snapshot), Date.now()); 256 256 ··· 258 258 let existingMetadata: Partial<SessionMetadata> = {}; 259 259 try { 260 260 const metaRow = db.prepare( 261 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'metadata'` 261 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'metadata'` 262 262 ).get() as { value: string } | undefined; 263 263 if (metaRow?.value) { 264 264 existingMetadata = JSON.parse(metaRow.value); ··· 280 280 restoreCount: existingMetadata.restoreCount, 281 281 }; 282 282 db.prepare(` 283 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 283 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 284 284 VALUES (?, 'session', 'metadata', ?, ?) 285 285 `).run('session-metadata', JSON.stringify(metadata), Date.now()); 286 286 ··· 310 310 311 311 try { 312 312 const metaRow = db.prepare( 313 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'metadata'` 313 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'metadata'` 314 314 ).get() as { value: string } | undefined; 315 315 316 316 let metadata: Partial<SessionMetadata> = {}; ··· 328 328 metadata.cleanShutdown = false; 329 329 330 330 db.prepare(` 331 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 331 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 332 332 VALUES (?, 'session', 'metadata', ?, ?) 333 333 `).run('session-metadata', JSON.stringify(metadata), Date.now()); 334 334 ··· 405 405 /** 406 406 * Restore session snapshot on startup. 407 407 * 408 - * Reads the saved snapshot from extension_settings, validates it, 408 + * Reads the saved snapshot from feature_settings, validates it, 409 409 * and recreates windows using the background window's IPC bridge. 410 410 * 411 411 * Returns counts of restored/failed/total windows. ··· 419 419 try { 420 420 const db = getDb(); 421 421 const metaRow = db.prepare( 422 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'metadata'` 422 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'metadata'` 423 423 ).get() as { value: string } | undefined; 424 424 425 425 if (metaRow?.value) { ··· 475 475 } else { 476 476 // Fallback: read current metadata (manual restore path) 477 477 const metaRow = db.prepare( 478 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'metadata'` 478 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'metadata'` 479 479 ).get() as { value: string } | undefined; 480 480 if (metaRow?.value) { 481 481 const metadata: Partial<SessionMetadata> = JSON.parse(metaRow.value); ··· 503 503 let snapshot: SessionSnapshot; 504 504 try { 505 505 const row = db.prepare( 506 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'snapshot'` 506 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'snapshot'` 507 507 ).get() as { value: string } | undefined; 508 508 509 509 if (!row?.value) { ··· 696 696 // Update session metadata with restore info and reset crashCount 697 697 try { 698 698 const metaRow = db.prepare( 699 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'metadata'` 699 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'metadata'` 700 700 ).get() as { value: string } | undefined; 701 701 702 702 let metadata: Partial<SessionMetadata> = {}; ··· 710 710 metadata.crashCount = 0; 711 711 712 712 db.prepare(` 713 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 713 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 714 714 VALUES (?, 'session', 'metadata', ?, ?) 715 715 `).run('session-metadata', JSON.stringify(metadata), Date.now()); 716 716 } catch { ··· 779 779 } 780 780 781 781 /** 782 - * Save per-group workspace snapshots to extension_settings. 782 + * Save per-group workspace snapshots to feature_settings. 783 783 * 784 784 * Groups windows by their group mode context and writes a layout snapshot 785 785 * for each group. Used by openGroup() to restore window positions. ··· 843 843 844 844 // Write each group's workspace snapshot 845 845 const upsert = db.prepare(` 846 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 846 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 847 847 VALUES (?, 'group-workspaces', ?, ?, ?) 848 848 `); 849 849 ··· 870 870 try { 871 871 const db = getDb(); 872 872 const row = db.prepare( 873 - `SELECT value FROM extension_settings WHERE extensionId = 'session' AND key = 'snapshot'` 873 + `SELECT value FROM feature_settings WHERE featureId = 'session' AND key = 'snapshot'` 874 874 ).get() as { value: string } | undefined; 875 875 876 876 if (!row?.value) return null;
+8 -8
backend/electron/shortcuts.test.ts
··· 225 225 afterEach(() => { 226 226 // Cleanup shortcuts 227 227 shortcuts.unregisterLocalShortcut('Ctrl+M', 'test-mode-page', { majorMode: 'page' }); 228 - shortcuts.unregisterLocalShortcut('Ctrl+M', 'test-mode-group', { majorMode: 'group' }); 228 + shortcuts.unregisterLocalShortcut('Ctrl+M', 'test-mode-group', { majorMode: 'space' }); 229 229 shortcuts.unregisterLocalShortcut('Ctrl+M', 'test-mode-default'); 230 230 datastore.cleanupWindowContext(10001); 231 231 }); ··· 253 253 it('should not trigger mode-conditional shortcut when mode does not match', () => { 254 254 shortcuts.registerLocalShortcut('Ctrl+M', 'test-mode-group', () => { 255 255 groupCallCount++; 256 - }, { majorMode: 'group' }); 256 + }, { majorMode: 'space' }); 257 257 258 258 const input: InputEvent = { 259 259 type: 'keyDown', ··· 264 264 code: 'KeyM' 265 265 }; 266 266 267 - // Window 10001 is in 'page' mode, not 'group' 267 + // Window 10001 is in 'page' mode, not 'space' 268 268 const handled = shortcuts.handleLocalShortcut(input, 10001); 269 269 assert.strictEqual(handled, false); 270 270 assert.strictEqual(groupCallCount, 0); ··· 278 278 279 279 shortcuts.registerLocalShortcut('Ctrl+M', 'test-mode-group', () => { 280 280 groupCallCount++; 281 - }, { majorMode: 'group' }); 281 + }, { majorMode: 'space' }); 282 282 283 283 const input: InputEvent = { 284 284 type: 'keyDown', ··· 296 296 assert.strictEqual(pageCallCount, 1); 297 297 assert.strictEqual(groupCallCount, 0); 298 298 299 - // Switch to group mode - should trigger group handler 300 - datastore.addContextEntry('mode', 'group', { windowId: 10001 }); 299 + // Switch to space mode - should trigger space handler 300 + datastore.addContextEntry('mode', 'space', { windowId: 10001 }); 301 301 handled = shortcuts.handleLocalShortcut(input, 10001); 302 302 assert.strictEqual(handled, true); 303 303 assert.strictEqual(pageCallCount, 1); // No change ··· 305 305 }); 306 306 307 307 it('should fall back to non-conditional shortcut when no mode match', () => { 308 - // Register mode-conditional for 'group' 308 + // Register mode-conditional for 'space' 309 309 shortcuts.registerLocalShortcut('Ctrl+M', 'test-mode-group', () => { 310 310 groupCallCount++; 311 - }, { majorMode: 'group' }); 311 + }, { majorMode: 'space' }); 312 312 313 313 // Register non-conditional fallback 314 314 shortcuts.registerLocalShortcut('Ctrl+M', 'test-mode-default', () => {
+19 -19
backend/electron/sync.ts
··· 40 40 // ==================== Settings Storage ==================== 41 41 42 42 // Note: Sync configuration is now stored per-profile in profiles.db 43 - // Legacy extension_settings storage is deprecated 43 + // Legacy feature_settings storage is deprecated 44 44 45 45 /** 46 46 * Get sync configuration for the active profile ··· 80 80 81 81 /** 82 82 * Get server URL from settings or environment 83 - * Priority: 1. User-configured (extension_settings), 2. Env var, 3. Default 83 + * Priority: 1. User-configured (feature_settings), 2. Env var, 3. Default 84 84 */ 85 85 function getServerUrl(): string { 86 86 const db = getDb(); 87 87 88 88 try { 89 - // Try to get from extension_settings 89 + // Try to get from feature_settings 90 90 const row = db.prepare(` 91 - SELECT value FROM extension_settings 92 - WHERE extensionId = 'sync' AND key = 'serverUrl' 91 + SELECT value FROM feature_settings 92 + WHERE featureId = 'sync' AND key = 'serverUrl' 93 93 `).get() as { value: string } | undefined; 94 94 95 95 if (row && row.value) { 96 - // Values in extension_settings are JSON-stringified 96 + // Values in feature_settings are JSON-stringified 97 97 try { 98 98 return JSON.parse(row.value); 99 99 } catch { ··· 115 115 function setServerUrl(url: string): void { 116 116 const db = getDb(); 117 117 118 - // Values in extension_settings must be JSON-stringified 118 + // Values in feature_settings must be JSON-stringified 119 119 const jsonValue = JSON.stringify(url); 120 120 121 121 db.prepare(` 122 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 122 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 123 123 VALUES (?, 'sync', 'serverUrl', ?, ?) 124 124 `).run(`sync-serverUrl`, jsonValue, Date.now()); 125 125 } 126 126 127 127 /** 128 - * Get autoSync setting from extension_settings 128 + * Get autoSync setting from feature_settings 129 129 */ 130 130 function getAutoSync(): boolean { 131 131 const db = getDb(); 132 132 133 133 try { 134 134 const row = db.prepare(` 135 - SELECT value FROM extension_settings 136 - WHERE extensionId = 'sync' AND key = 'autoSync' 135 + SELECT value FROM feature_settings 136 + WHERE featureId = 'sync' AND key = 'autoSync' 137 137 `).get() as { value: string } | undefined; 138 138 139 139 if (row && row.value) { ··· 159 159 const jsonValue = JSON.stringify(enabled); 160 160 161 161 db.prepare(` 162 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 162 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 163 163 VALUES (?, 'sync', 'autoSync', ?, ?) 164 164 `).run(`sync-autoSync`, jsonValue, Date.now()); 165 165 } ··· 173 173 try { 174 174 const activeProfile = getActiveProfile(); 175 175 176 - // Update serverUrl if provided (stored globally in extension_settings) 176 + // Update serverUrl if provided (stored globally in feature_settings) 177 177 if (config.serverUrl !== undefined && config.serverUrl !== '') { 178 178 setServerUrl(config.serverUrl); 179 179 DEBUG && console.log(`[sync] Updated server URL: ${config.serverUrl}`); ··· 642 642 let storedProfileId = ''; 643 643 try { 644 644 const urlRow = db.prepare(` 645 - SELECT value FROM extension_settings 646 - WHERE extensionId = 'sync' AND key = 'lastSyncServerUrl' 645 + SELECT value FROM feature_settings 646 + WHERE featureId = 'sync' AND key = 'lastSyncServerUrl' 647 647 `).get() as { value: string } | undefined; 648 648 if (urlRow?.value) storedUrl = JSON.parse(urlRow.value); 649 649 } catch { /* first sync */ } 650 650 651 651 try { 652 652 const pidRow = db.prepare(` 653 - SELECT value FROM extension_settings 654 - WHERE extensionId = 'sync' AND key = 'lastSyncProfileId' 653 + SELECT value FROM feature_settings 654 + WHERE featureId = 'sync' AND key = 'lastSyncProfileId' 655 655 `).get() as { value: string } | undefined; 656 656 if (pidRow?.value) storedProfileId = JSON.parse(pidRow.value); 657 657 } catch { /* first sync */ } ··· 687 687 const currentProfileId = profileConfig?.serverProfileId || ''; 688 688 689 689 db.prepare(` 690 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 690 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 691 691 VALUES (?, 'sync', 'lastSyncServerUrl', ?, ?) 692 692 `).run('sync-lastSyncServerUrl', JSON.stringify(serverUrl), Date.now()); 693 693 694 694 db.prepare(` 695 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 695 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 696 696 VALUES (?, 'sync', 'lastSyncProfileId', ?, ?) 697 697 `).run('sync-lastSyncProfileId', JSON.stringify(currentProfileId), Date.now()); 698 698 }
+1 -1
backend/extension/tests/bookmarks.test.js
··· 22 22 23 23 // Clear stores between tests 24 24 const db = (await import('../datastore.js')).getRawDb(); 25 - const storeNames = ['items', 'tags', 'item_tags', 'extension_settings']; 25 + const storeNames = ['items', 'tags', 'item_tags', 'feature_settings']; 26 26 for (const name of storeNames) { 27 27 const tx = db.transaction(name, 'readwrite'); 28 28 tx.objectStore(name).clear();
+1 -1
backend/extension/tests/history.test.js
··· 23 23 24 24 // Clear stores between tests 25 25 const db = (await import('../datastore.js')).getRawDb(); 26 - const storeNames = ['items', 'tags', 'item_tags', 'extension_settings']; 26 + const storeNames = ['items', 'tags', 'item_tags', 'feature_settings']; 27 27 for (const name of storeNames) { 28 28 const tx = db.transaction(name, 'readwrite'); 29 29 tx.objectStore(name).clear();
+1 -1
backend/extension/tests/tabs.test.js
··· 23 23 24 24 // Clear stores between tests 25 25 const db = (await import('../datastore.js')).getRawDb(); 26 - const storeNames = ['items', 'tags', 'item_tags', 'extension_settings']; 26 + const storeNames = ['items', 'tags', 'item_tags', 'feature_settings']; 27 27 for (const name of storeNames) { 28 28 const tx = db.transaction(name, 'readwrite'); 29 29 tx.objectStore(name).clear();
+10 -10
backend/tauri/src-tauri/src/datastore.rs
··· 186 186 CREATE INDEX IF NOT EXISTS idx_extensions_status ON extensions(status); 187 187 CREATE INDEX IF NOT EXISTS idx_extensions_builtin ON extensions(builtin); 188 188 189 - CREATE TABLE IF NOT EXISTS extension_settings ( 189 + CREATE TABLE IF NOT EXISTS feature_settings ( 190 190 id TEXT PRIMARY KEY, 191 - extensionId TEXT NOT NULL, 191 + featureId TEXT NOT NULL, 192 192 key TEXT NOT NULL, 193 193 value TEXT, 194 194 updatedAt INTEGER 195 195 ); 196 - CREATE INDEX IF NOT EXISTS idx_extension_settings_extensionId ON extension_settings(extensionId); 197 - CREATE UNIQUE INDEX IF NOT EXISTS idx_extension_settings_unique ON extension_settings(extensionId, key); 196 + CREATE INDEX IF NOT EXISTS idx_feature_settings_featureId ON feature_settings(featureId); 197 + CREATE UNIQUE INDEX IF NOT EXISTS idx_feature_settings_unique ON feature_settings(featureId, key); 198 198 199 199 CREATE TABLE IF NOT EXISTS migrations ( 200 200 id TEXT PRIMARY KEY, ··· 647 647 Ok(()) 648 648 } 649 649 650 - /// Check and write datastore version to extension_settings. 650 + /// Check and write datastore version to feature_settings. 651 651 /// If mismatch detected, sets sync_disabled flag. 652 652 fn check_and_write_datastore_version(conn: &Connection) -> Result<()> { 653 653 let existing: Option<String> = conn 654 654 .query_row( 655 - "SELECT value FROM extension_settings WHERE extensionId = 'system' AND key = 'datastoreVersion'", 655 + "SELECT value FROM feature_settings WHERE featureId = 'system' AND key = 'datastoreVersion'", 656 656 [], 657 657 |row| row.get(0), 658 658 ) ··· 674 674 // Write current version 675 675 let timestamp = now(); 676 676 conn.execute( 677 - "INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) VALUES ('system-datastoreVersion', 'system', 'datastoreVersion', ?1, ?2)", 677 + "INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) VALUES ('system-datastoreVersion', 'system', 'datastoreVersion', ?1, ?2)", 678 678 params![DATASTORE_VERSION.to_string(), timestamp], 679 679 )?; 680 680 ··· 1221 1221 "scripts_data", 1222 1222 "feeds", 1223 1223 "extensions", 1224 - "extension_settings", 1224 + "feature_settings", 1225 1225 "migrations", 1226 1226 "items", 1227 1227 "item_tags", ··· 1285 1285 "scripts_data", 1286 1286 "feeds", 1287 1287 "extensions", 1288 - "extension_settings", 1288 + "feature_settings", 1289 1289 "migrations", 1290 1290 "items", 1291 1291 "item_tags", ··· 1351 1351 "scripts_data", 1352 1352 "feeds", 1353 1353 "extensions", 1354 - "extension_settings", 1354 + "feature_settings", 1355 1355 "migrations", 1356 1356 "items", 1357 1357 "item_tags",
+2 -2
backend/tauri/src-tauri/src/extensions.rs
··· 139 139 ext_id: &str, 140 140 is_builtin: bool, 141 141 ) -> bool { 142 - // Query extension_settings for enabled state 142 + // Query feature_settings for enabled state 143 143 let result: Result<Option<String>, _> = db.query_row( 144 - "SELECT value FROM extension_settings WHERE extensionId = ? AND key = 'enabled'", 144 + "SELECT value FROM feature_settings WHERE featureId = ? AND key = 'enabled'", 145 145 rusqlite::params![ext_id], 146 146 |row| row.get(0), 147 147 );
+9 -9
backend/tauri/src-tauri/src/sync.rs
··· 10 10 //! 11 11 //! Sync configuration is per-profile: 12 12 //! - apiKey + serverProfileSlug stored in profiles.db (per-profile) 13 - //! - serverUrl + autoSync stored in extension_settings (global) 13 + //! - serverUrl + autoSync stored in feature_settings (global) 14 14 //! 15 15 //! Note: rusqlite::Connection is not Send/Sync, so all async functions that need 16 16 //! DB access accept Arc<Mutex<Connection>> and lock/unlock around await points. ··· 120 120 // ==================== Settings Storage ==================== 121 121 122 122 // Note: Sync configuration is now stored per-profile in profiles.db 123 - // Legacy extension_settings storage is deprecated for apiKey/lastSyncTime 124 - // Only serverUrl and autoSync remain global in extension_settings 123 + // Legacy feature_settings storage is deprecated for apiKey/lastSyncTime 124 + // Only serverUrl and autoSync remain global in feature_settings 125 125 126 126 const DEFAULT_SERVER_URL: &str = "https://peek-node.up.railway.app"; 127 127 ··· 155 155 } 156 156 157 157 /// Save sync configuration 158 - /// Saves serverUrl (global in extension_settings), apiKey (per-profile in profiles.db) 158 + /// Saves serverUrl (global in feature_settings), apiKey (per-profile in profiles.db) 159 159 pub fn set_sync_config( 160 160 datastore_conn: &Connection, 161 161 profiles_conn: &Connection, 162 162 config: &SyncConfig, 163 163 ) -> rusqlite::Result<()> { 164 - // Update serverUrl if provided (stored globally in extension_settings) 164 + // Update serverUrl if provided (stored globally in feature_settings) 165 165 if !config.server_url.is_empty() { 166 166 set_server_url(datastore_conn, &config.server_url)?; 167 167 } ··· 233 233 ) 234 234 } 235 235 236 - /// Get a setting value from extension_settings 236 + /// Get a setting value from feature_settings 237 237 fn get_setting(conn: &Connection, extension_id: &str, key: &str) -> Option<String> { 238 238 conn.query_row( 239 - "SELECT value FROM extension_settings WHERE extensionId = ?1 AND key = ?2", 239 + "SELECT value FROM feature_settings WHERE featureId = ?1 AND key = ?2", 240 240 params![extension_id, key], 241 241 |row| row.get(0), 242 242 ) 243 243 .ok() 244 244 } 245 245 246 - /// Set a setting value in extension_settings 246 + /// Set a setting value in feature_settings 247 247 fn set_setting( 248 248 conn: &Connection, 249 249 extension_id: &str, ··· 253 253 let id = format!("{}-{}", extension_id, key); 254 254 let timestamp = datastore::now(); 255 255 conn.execute( 256 - "INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) VALUES (?1, ?2, ?3, ?4, ?5)", 256 + "INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) VALUES (?1, ?2, ?3, ?4, ?5)", 257 257 params![id, extension_id, key, value, timestamp], 258 258 )?; 259 259 Ok(())
+2 -2
backend/tauri/src-tauri/src/theme.rs
··· 145 145 /// Get theme setting from database 146 146 pub fn get_theme_setting(db: &Connection, key: &str) -> Option<String> { 147 147 let result: Result<String, _> = db.query_row( 148 - "SELECT value FROM extension_settings WHERE extensionId = ? AND key = ?", 148 + "SELECT value FROM feature_settings WHERE featureId = ? AND key = ?", 149 149 [THEME_SETTINGS_KEY, key], 150 150 |row| row.get(0), 151 151 ); ··· 169 169 let timestamp = chrono::Utc::now().timestamp_millis(); 170 170 171 171 db.execute( 172 - "INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) VALUES (?, ?, ?, ?, ?)", 172 + "INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) VALUES (?, ?, ?, ?, ?)", 173 173 rusqlite::params![id, THEME_SETTINGS_KEY, key, json_value, timestamp], 174 174 )?; 175 175
+14 -14
backend/tauri/src-tauri/tests/smoke.rs
··· 361 361 println!("✓ Extension removed"); 362 362 } 363 363 364 - /// Test extension_settings operations (used for extension prefs/items) 364 + /// Test feature_settings operations (used for extension prefs/items) 365 365 #[test] 366 - fn test_extension_settings() { 366 + fn test_feature_settings() { 367 367 let temp_dir = TempDir::new().unwrap(); 368 368 let db_path = temp_dir.path().join("test.sqlite"); 369 369 let conn = datastore::init_database(&db_path).unwrap(); ··· 377 377 ]); 378 378 379 379 conn.execute( 380 - "INSERT INTO extension_settings (extensionId, key, value, updatedAt) VALUES (?, ?, ?, ?)", 380 + "INSERT INTO feature_settings (featureId, key, value, updatedAt) VALUES (?, ?, ?, ?)", 381 381 rusqlite::params!["peeks", "items", peeks_items.to_string(), now], 382 382 ).expect("Failed to save extension settings"); 383 383 println!("✓ Extension settings saved"); 384 384 385 385 // Retrieve settings 386 386 let value: String = conn.query_row( 387 - "SELECT value FROM extension_settings WHERE extensionId = ? AND key = ?", 387 + "SELECT value FROM feature_settings WHERE featureId = ? AND key = ?", 388 388 rusqlite::params!["peeks", "items"], 389 389 |row| row.get(0), 390 390 ).expect("Failed to get extension settings"); ··· 401 401 ]); 402 402 403 403 conn.execute( 404 - "UPDATE extension_settings SET value = ?, updatedAt = ? WHERE extensionId = ? AND key = ?", 404 + "UPDATE feature_settings SET value = ?, updatedAt = ? WHERE featureId = ? AND key = ?", 405 405 rusqlite::params![updated_items.to_string(), now, "peeks", "items"], 406 406 ).expect("Failed to update extension settings"); 407 407 408 408 let value: String = conn.query_row( 409 - "SELECT value FROM extension_settings WHERE extensionId = ? AND key = ?", 409 + "SELECT value FROM feature_settings WHERE featureId = ? AND key = ?", 410 410 rusqlite::params!["peeks", "items"], 411 411 |row| row.get(0), 412 412 ).unwrap(); ··· 420 420 let slides_prefs = serde_json::json!({"defaultPosition": "right", "defaultSize": 350}); 421 421 422 422 conn.execute( 423 - "INSERT INTO extension_settings (extensionId, key, value, updatedAt) VALUES (?, ?, ?, ?)", 423 + "INSERT INTO feature_settings (featureId, key, value, updatedAt) VALUES (?, ?, ?, ?)", 424 424 rusqlite::params!["slides", "prefs", slides_prefs.to_string(), now], 425 425 ).unwrap(); 426 426 427 427 // Query all settings for an extension 428 428 let mut stmt = conn.prepare( 429 - "SELECT key, value FROM extension_settings WHERE extensionId = ?" 429 + "SELECT key, value FROM feature_settings WHERE featureId = ?" 430 430 ).unwrap(); 431 431 432 432 let settings: Vec<(String, String)> = stmt.query_map( ··· 462 462 // Add extension settings 463 463 let now = datastore::now(); 464 464 conn.execute( 465 - "INSERT INTO extension_settings (extensionId, key, value, updatedAt) VALUES (?, ?, ?, ?)", 465 + "INSERT INTO feature_settings (featureId, key, value, updatedAt) VALUES (?, ?, ?, ?)", 466 466 rusqlite::params!["test", "data", "\"persisted\"", now], 467 467 ).unwrap(); 468 468 ··· 491 491 492 492 // Verify extension settings persisted 493 493 let value: String = conn.query_row( 494 - "SELECT value FROM extension_settings WHERE extensionId = ? AND key = ?", 494 + "SELECT value FROM feature_settings WHERE featureId = ? AND key = ?", 495 495 rusqlite::params!["test", "data"], 496 496 |row| row.get(0), 497 497 ).unwrap(); ··· 657 657 lastUsed INTEGER DEFAULT 0, 658 658 frecencyScore INTEGER DEFAULT 0 659 659 ); 660 - CREATE TABLE IF NOT EXISTS extension_settings ( 660 + CREATE TABLE IF NOT EXISTS feature_settings ( 661 661 id TEXT PRIMARY KEY, 662 - extensionId TEXT NOT NULL, 662 + featureId TEXT NOT NULL, 663 663 key TEXT NOT NULL, 664 664 value TEXT, 665 665 updatedAt INTEGER ··· 736 736 737 737 // Check that version was written 738 738 let version: String = conn.query_row( 739 - "SELECT value FROM extension_settings WHERE extensionId = 'system' AND key = 'datastoreVersion'", 739 + "SELECT value FROM feature_settings WHERE featureId = 'system' AND key = 'datastoreVersion'", 740 740 [], 741 741 |row| row.get(0), 742 - ).expect("Version should be written to extension_settings"); 742 + ).expect("Version should be written to feature_settings"); 743 743 744 744 assert_eq!(version, datastore::DATASTORE_VERSION.to_string()); 745 745 println!("✓ Datastore version written: {}", version);
+1 -1
backend/tests/migration-regression.test.js
··· 61 61 } 62 62 63 63 const tablesToMigrate = [ 64 - 'addresses', 'visits', 'tags', 'address_tags', 'extension_settings', 64 + 'addresses', 'visits', 'tags', 'address_tags', 'feature_settings', 65 65 'extensions', 'content', 'blobs', 'scripts_data', 'feeds', 66 66 ]; 67 67
+20 -20
backend/tests/sync-version-compat.test.js
··· 376 376 // replicate the version check/write logic inline. 377 377 // 378 378 // The code under test (in datastore.ts) does: 379 - // 1. Read datastore_version from extension_settings 379 + // 1. Read datastore_version from feature_settings 380 380 // 2. If stored > code: disable sync (downgrade detected) 381 381 // 3. If stored < code: update stored version (upgrade) 382 - // 4. Write current version to extension_settings 382 + // 4. Write current version to feature_settings 383 383 384 384 const CODE_DATASTORE_VERSION = 1; // Matches backend/version.ts 385 385 ··· 387 387 const db = new Database(dbPath); 388 388 db.pragma('journal_mode = WAL'); 389 389 390 - // Create the extension_settings table (same as desktop schema) 390 + // Create the feature_settings table (same as desktop schema) 391 391 db.exec(` 392 - CREATE TABLE IF NOT EXISTS extension_settings ( 392 + CREATE TABLE IF NOT EXISTS feature_settings ( 393 393 id TEXT PRIMARY KEY, 394 - extensionId TEXT NOT NULL, 394 + featureId TEXT NOT NULL, 395 395 key TEXT NOT NULL, 396 396 value TEXT, 397 397 updatedAt INTEGER 398 398 ); 399 - CREATE UNIQUE INDEX IF NOT EXISTS idx_extension_settings_unique 400 - ON extension_settings(extensionId, key); 399 + CREATE UNIQUE INDEX IF NOT EXISTS idx_feature_settings_unique 400 + ON feature_settings(featureId, key); 401 401 `); 402 402 403 403 return db; ··· 409 409 */ 410 410 function checkAndWriteDatastoreVersion(db, codeVersion = CODE_DATASTORE_VERSION) { 411 411 const row = db.prepare(` 412 - SELECT value FROM extension_settings 413 - WHERE extensionId = 'system' AND key = 'datastore_version' 412 + SELECT value FROM feature_settings 413 + WHERE featureId = 'system' AND key = 'datastore_version' 414 414 `).get(); 415 415 416 416 if (row) { ··· 429 429 430 430 // Write current version 431 431 db.prepare(` 432 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 432 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 433 433 VALUES (?, 'system', 'datastore_version', ?, ?) 434 434 `).run('system-datastore_version', JSON.stringify(codeVersion), Date.now()); 435 435 436 436 return { syncDisabled: false }; 437 437 } 438 438 439 - // Test 9: Version is written to extension_settings after init 439 + // Test 9: Version is written to feature_settings after init 440 440 async function test9_initWritesVersion() { 441 441 const tempDir = await mkdtemp(join(tmpdir(), 'peek-version-db-')); 442 442 const dbPath = join(tempDir, 'test.db'); ··· 446 446 447 447 // Verify version was written 448 448 const row = db.prepare(` 449 - SELECT value FROM extension_settings 450 - WHERE extensionId = 'system' AND key = 'datastore_version' 449 + SELECT value FROM feature_settings 450 + WHERE featureId = 'system' AND key = 'datastore_version' 451 451 `).get(); 452 452 453 - assert(row, 'datastore_version should be stored in extension_settings'); 453 + assert(row, 'datastore_version should be stored in feature_settings'); 454 454 455 455 let storedVersion; 456 456 try { ··· 492 492 493 493 // Simulate a newer version having written version 99 494 494 db.prepare(` 495 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 495 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 496 496 VALUES (?, 'system', 'datastore_version', ?, ?) 497 497 `).run('system-datastore_version', JSON.stringify(99), Date.now()); 498 498 ··· 502 502 503 503 // Verify the stored version was NOT overwritten 504 504 const row = db.prepare(` 505 - SELECT value FROM extension_settings 506 - WHERE extensionId = 'system' AND key = 'datastore_version' 505 + SELECT value FROM feature_settings 506 + WHERE featureId = 'system' AND key = 'datastore_version' 507 507 `).get(); 508 508 let storedVersion; 509 509 try { ··· 526 526 527 527 // Simulate an older version having written version 0 528 528 db.prepare(` 529 - INSERT OR REPLACE INTO extension_settings (id, extensionId, key, value, updatedAt) 529 + INSERT OR REPLACE INTO feature_settings (id, featureId, key, value, updatedAt) 530 530 VALUES (?, 'system', 'datastore_version', ?, ?) 531 531 `).run('system-datastore_version', JSON.stringify(0), Date.now()); 532 532 ··· 536 536 537 537 // Verify version was updated 538 538 const row = db.prepare(` 539 - SELECT value FROM extension_settings 540 - WHERE extensionId = 'system' AND key = 'datastore_version' 539 + SELECT value FROM feature_settings 540 + WHERE featureId = 'system' AND key = 'datastore_version' 541 541 `).get(); 542 542 let storedVersion; 543 543 try {
+1 -1
backend/types/api.ts
··· 42 42 * Only one major mode can be active per window at a time. 43 43 * Inspired by Emacs major modes. 44 44 */ 45 - export type MajorModeId = 'page' | 'group' | 'default'; 45 + export type MajorModeId = 'page' | 'space' | 'default'; 46 46 47 47 /** 48 48 * Mode metadata for display and behavior
+7 -4
backend/types/index.ts
··· 145 145 metadata: string; 146 146 } 147 147 148 - export interface ExtensionSetting { 148 + export interface FeatureSetting { 149 149 id: string; 150 - extensionId: string; 150 + featureId: string; 151 151 key: string; 152 152 value: string; 153 153 updatedAt: number; 154 154 } 155 + 156 + /** @deprecated Use FeatureSetting instead */ 157 + export type ExtensionSetting = FeatureSetting; 155 158 156 159 export interface DatastoreStats { 157 160 totalAddresses: number; ··· 276 279 | 'scripts_data' 277 280 | 'feeds' 278 281 | 'extensions' 279 - | 'extension_settings' 282 + | 'feature_settings' 280 283 | 'migrations' 281 284 | 'themes' 282 285 | 'items' ··· 294 297 'scripts_data', 295 298 'feeds', 296 299 'extensions', 297 - 'extension_settings', 300 + 'feature_settings', 298 301 'migrations', 299 302 'themes', 300 303 'items',
+3 -3
features/cmd/background.js
··· 72 72 */ 73 73 const loadCommandCache = async () => { 74 74 try { 75 - const result = await api.datastore.getRow('extension_settings', `cmd:command_cache`); 75 + const result = await api.datastore.getRow('feature_settings', `cmd:command_cache`); 76 76 if (result.success && result.data && result.data.value) { 77 77 const cache = JSON.parse(result.data.value); 78 78 log('ext:cmd', 'Loaded command cache:', cache.commands?.length, 'commands'); ··· 117 117 cachedAt: Date.now() 118 118 }; 119 119 120 - await api.datastore.setRow('extension_settings', 'cmd:command_cache', { 121 - extensionId: 'cmd', 120 + await api.datastore.setRow('feature_settings', 'cmd:command_cache', { 121 + featureId: 'cmd', 122 122 key: 'command_cache', 123 123 value: JSON.stringify(cache), 124 124 updatedAt: Date.now()
+23 -85
features/groups/background.js
··· 97 97 } 98 98 99 99 try { 100 - await api.context.setMode('group', { 100 + await api.context.setMode('space', { 101 101 windowId, 102 102 metadata: { 103 - groupId, 104 - groupName, 103 + spaceId: groupId, 104 + spaceName: groupName, 105 105 color 106 106 } 107 107 }); 108 - debug && console.log(`[ext:groups] Set group mode for window ${windowId}: ${groupName}`); 108 + debug && console.log(`[ext:groups] Set space mode for window ${windowId}: ${groupName}`); 109 109 } catch (err) { 110 110 console.error('[ext:groups] Failed to set group mode:', err); 111 111 } ··· 123 123 124 124 try { 125 125 // Get all windows in this group 126 - const result = await api.context.getWindowsInGroup(groupId); 126 + const result = await api.context.getWindowsInSpace(groupId); 127 127 if (!result.success || !result.data) return; 128 128 129 129 const windowIds = result.data; ··· 151 151 const targetWindowId = await api.window.getFocusedVisibleWindowId(); 152 152 if (targetWindowId) { 153 153 const modeResult = await api.context.get('mode', targetWindowId); 154 - if (modeResult.success && modeResult.data?.value === 'group' && modeResult.data.metadata?.groupId) { 154 + if (modeResult.success && modeResult.data?.value === 'space' && modeResult.data.metadata?.spaceId) { 155 155 return { 156 - groupId: modeResult.data.metadata.groupId, 157 - groupName: modeResult.data.metadata.groupName || '' 156 + groupId: modeResult.data.metadata.spaceId, 157 + groupName: modeResult.data.metadata.spaceName || '' 158 158 }; 159 159 } 160 160 } ··· 188 188 189 189 // Save workspace layouts before hiding 190 190 try { 191 - if (api.session?.saveGroupWorkspaces) { 192 - await api.session.saveGroupWorkspaces(); 191 + if (api.session?.saveSpaceWorkspaces) { 192 + await api.session.saveSpaceWorkspaces(); 193 193 } 194 194 } catch (err) { 195 195 debug && console.log('[ext:groups] Failed to save workspace before close:', err); 196 196 } 197 197 198 198 // Get all windows in this group 199 - const result = await api.context.getWindowsInGroup(groupId); 199 + const result = await api.context.getWindowsInSpace(groupId); 200 200 if (!result.success || !result.data || result.data.length === 0) { 201 201 return { success: false, error: 'No windows found in group' }; 202 202 } ··· 316 316 317 317 /** 318 318 * Get pinned item IDs for a group. 319 - * Stored in extension_settings as 'pins:<groupId>'. 319 + * Stored in feature_settings as 'pins:<groupId>'. 320 320 */ 321 321 const getPinnedItems = async (groupId) => { 322 322 try { ··· 521 521 522 522 // Persist current window layouts for this group 523 523 try { 524 - if (api.session?.saveGroupWorkspaces) { 525 - await api.session.saveGroupWorkspaces(); 524 + if (api.session?.saveSpaceWorkspaces) { 525 + await api.session.saveSpaceWorkspaces(); 526 526 debug && console.log('[ext:groups] Group workspace layouts saved'); 527 527 } 528 528 } catch (err) { ··· 596 596 let savedBoundsMap = null; 597 597 let sortedUrlItems = urlItems; 598 598 try { 599 - const wsResult = await api.settings.getExtKey('group-workspaces', 'workspace:' + tag.id); 599 + const wsResult = await api.settings.getExtKey('spaces', 'workspace:' + tag.id); 600 600 if (wsResult.success && wsResult.data) { 601 601 const snapshot = typeof wsResult.data === 'string' ? JSON.parse(wsResult.data) : wsResult.data; 602 602 if (snapshot.version === 1 && Array.isArray(snapshot.windows)) { ··· 639 639 role: 'content', 640 640 trackingSource: 'cmd', 641 641 trackingSourceId: `group:${groupName}`, 642 - // Pass group context for mode inheritance 643 - groupMode: { 644 - groupId: tag.id, 645 - groupName: tag.name, 642 + // Pass space context for mode inheritance 643 + spaceMode: { 644 + spaceId: tag.id, 645 + spaceName: tag.name, 646 646 color: tag.color 647 647 }, 648 648 ...boundsOpts ··· 742 742 743 743 console.log('[ext:groups] Noun registered: groups'); 744 744 745 - // ===== Standalone commands (close, switch, pin, unpin) ===== 746 - // These use direct pubsub registration since the noun system 747 - // only supports query/browse/open/create capabilities. 748 - 749 - // "close group" — hide all windows in the current group 750 - api.subscribe('cmd:execute:close group', async (msg) => { 751 - const result = await closeGroup(); 752 - if (msg.expectResult && msg.resultTopic) { 753 - api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 754 - } 755 - }, api.scopes.GLOBAL); 756 - api.publish('cmd:register', { 757 - name: 'close group', 758 - description: 'Hide all windows in the current group (restore later)', 759 - source: 'groups', 760 - scope: 'global', 761 - accepts: [], 762 - produces: [], 763 - params: [] 764 - }, api.scopes.GLOBAL); 765 - 766 - // "switch group <name>" — close current group, open target 767 - api.subscribe('cmd:execute:switch group', async (msg) => { 768 - const result = await switchGroup(msg.search?.trim()); 769 - if (msg.expectResult && msg.resultTopic) { 770 - api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 771 - } 772 - }, api.scopes.GLOBAL); 773 - api.publish('cmd:register', { 774 - name: 'switch group', 775 - description: 'Close current group and open another', 776 - source: 'groups', 777 - scope: 'global', 778 - accepts: [], 779 - produces: [], 780 - params: [{ name: 'name', type: 'tag', required: true, description: 'Target group name' }] 781 - }, api.scopes.GLOBAL); 782 - 783 - // "restore group <name>" — show suspended group windows 784 - api.subscribe('cmd:execute:restore group', async (msg) => { 785 - const name = msg.search?.trim(); 786 - if (!name) { 787 - if (msg.expectResult && msg.resultTopic) { 788 - api.publish(msg.resultTopic, { success: false, error: 'Usage: restore group <name>' }, api.scopes.GLOBAL); 789 - } 790 - return; 791 - } 792 - // Resolve group 793 - const tagsResult = await api.datastore.getTagsByFrecency(); 794 - const tag = tagsResult.success ? tagsResult.data.find(t => t.name.toLowerCase() === name.toLowerCase()) : null; 795 - const result = tag ? await restoreGroup(tag.id, tag.name) : { success: false, error: 'Group not found' }; 796 - if (msg.expectResult && msg.resultTopic) { 797 - api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 798 - } 799 - }, api.scopes.GLOBAL); 800 - api.publish('cmd:register', { 801 - name: 'restore group', 802 - description: 'Restore a previously closed (suspended) group', 803 - source: 'groups', 804 - scope: 'global', 805 - accepts: [], 806 - produces: [], 807 - params: [{ name: 'name', type: 'tag', required: true, description: 'Group name to restore' }] 808 - }, api.scopes.GLOBAL); 745 + // ===== Standalone commands (pin, unpin) ===== 746 + // Workspace commands (close, switch, restore) moved to spaces feature. 809 747 810 748 // "pin <url>" — mark a URL as pinned in the current group 811 749 api.subscribe('cmd:execute:pin', async (msg) => { ··· 841 779 params: [{ name: 'url', type: 'string', required: true, description: 'URL to unpin' }] 842 780 }, api.scopes.GLOBAL); 843 781 844 - console.log('[ext:groups] Standalone commands registered: close group, switch group, restore group, pin, unpin'); 782 + console.log('[ext:groups] Standalone commands registered: pin, unpin'); 845 783 }; 846 784 847 785 const uninitCommands = () => { 848 786 unregisterNoun('groups'); 849 787 // Unregister standalone commands 850 - for (const name of ['close group', 'switch group', 'restore group', 'pin', 'unpin']) { 788 + for (const name of ['pin', 'unpin']) { 851 789 api.publish('cmd:unregister', { name }, api.scopes.GLOBAL); 852 790 } 853 791 console.log('[ext:groups] Noun and commands unregistered: groups');
+1 -1
features/hud/background.js
··· 61 61 }; 62 62 63 63 /** 64 - * Ensure the HUD sheet config exists in extension_settings. 64 + * Ensure the HUD sheet config exists in feature_settings. 65 65 * Creates a default config if none exists. 66 66 */ 67 67 const CURRENT_CONFIG_VERSION = 2;
+2 -2
features/hud/hud.js
··· 5 5 * Each widget is a webview pointing to an individual widget page 6 6 * (e.g., peek://ext/hud/widgets/mode.html). 7 7 * 8 - * The layout config is loaded from extension_settings (created by background.js). 8 + * The layout config is loaded from feature_settings (created by background.js). 9 9 */ 10 10 11 11 const api = window.app; ··· 16 16 let sheetConfig = null; 17 17 18 18 /** 19 - * Load HUD sheet config from extension_settings 19 + * Load HUD sheet config from feature_settings 20 20 */ 21 21 const loadSheetConfig = async () => { 22 22 const result = await api.settings.getKey(HUD_SHEET_KEY);
+1 -1
features/sheets/background.js
··· 15 15 const sheetPageUrl = 'peek://sheets/sheet.html'; 16 16 17 17 /** 18 - * Get all sheet configs from extension_settings 18 + * Get all sheet configs from feature_settings 19 19 */ 20 20 const listSheets = async () => { 21 21 const result = await api.settings.get();
+2 -2
features/sheets/sheet.js
··· 21 21 }; 22 22 23 23 /** 24 - * Load sheet config from extension_settings 24 + * Load sheet config from feature_settings 25 25 */ 26 26 const loadSheetConfig = async () => { 27 27 const key = `sheet:${sheetId}`; ··· 33 33 }; 34 34 35 35 /** 36 - * Save sheet config to extension_settings 36 + * Save sheet config to feature_settings 37 37 */ 38 38 const saveSheetConfig = async () => { 39 39 if (!sheetConfig) return;
+54
features/spaces/background.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <title>Spaces Feature</title> 7 + </head> 8 + <body> 9 + <script type="module"> 10 + import extension from './background.js'; 11 + 12 + const api = window.app; 13 + const extId = extension.id; 14 + 15 + console.log(`[ext:${extId}] background.html loaded`); 16 + 17 + // Initialize extension BEFORE publishing ext:ready 18 + if (extension.init) { 19 + console.log(`[ext:${extId}] calling init()`); 20 + await extension.init(); 21 + } 22 + 23 + // Collect registered command topics for assertion verification 24 + const registeredTopics = Object.keys(window._cmdHandlers || {}) 25 + .map(name => `cmd:execute:${name}`); 26 + 27 + // Signal ready to main process 28 + api.publish('ext:ready', { 29 + id: extId, 30 + registeredTopics, 31 + manifest: { 32 + id: extension.id, 33 + labels: extension.labels, 34 + version: '1.0.0' 35 + } 36 + }, api.scopes.SYSTEM); 37 + 38 + // Handle shutdown request from main process 39 + api.subscribe('app:shutdown', () => { 40 + console.log(`[ext:${extId}] received shutdown`); 41 + if (extension.uninit) { 42 + extension.uninit(); 43 + } 44 + }, api.scopes.SYSTEM); 45 + 46 + api.subscribe(`ext:${extId}:shutdown`, () => { 47 + console.log(`[ext:${extId}] received extension shutdown`); 48 + if (extension.uninit) { 49 + extension.uninit(); 50 + } 51 + }, api.scopes.SYSTEM); 52 + </script> 53 + </body> 54 + </html>
+443
features/spaces/background.js
··· 1 + /** 2 + * Spaces Feature Background Script 3 + * 4 + * Workspace mode with auto-tagging, screen border, and session management. 5 + * All space-specific logic lives here — the backend provides only generic primitives. 6 + * 7 + * Runs in isolated extension process (peek://ext/spaces/background.html) 8 + */ 9 + 10 + import { 11 + id, labels, schemas, storageKeys, defaults, 12 + SPACE_TAG_PREFIX, isSpaceTag, getSpaceDisplayName, toSpaceTagName 13 + } from './config.js'; 14 + 15 + const api = window.app; 16 + const debug = api.debug; 17 + 18 + console.log('[ext:spaces] background loaded'); 19 + 20 + // ===== Space State ===== 21 + let activeSpaceId = null; 22 + let activeSpaceName = null; 23 + let activeSpaceColor = null; 24 + 25 + // ===== Vivid Colors (for border when tag color is too pale) ===== 26 + const VIVID_SPACE_COLORS = [ 27 + '#ff3b30', '#ff9500', '#34c759', '#007aff', 28 + '#af52de', '#5ac8fa', '#ff2d55', '#ff9f0a', 29 + ]; 30 + 31 + function resolveSpaceBorderColor(color, spaceId) { 32 + if (color && color !== '#999' && color !== '#999999') { 33 + const hex = color.replace('#', ''); 34 + if (hex.length >= 6) { 35 + const r = parseInt(hex.substring(0, 2), 16); 36 + const g = parseInt(hex.substring(2, 4), 16); 37 + const b = parseInt(hex.substring(4, 6), 16); 38 + const chroma = Math.max(r, g, b) - Math.min(r, g, b); 39 + if (chroma > 40) return color; 40 + } 41 + } 42 + if (spaceId) { 43 + let hash = 0; 44 + for (let i = 0; i < spaceId.length; i++) { 45 + hash = ((hash << 5) - hash + spaceId.charCodeAt(i)) | 0; 46 + } 47 + return VIVID_SPACE_COLORS[Math.abs(hash) % VIVID_SPACE_COLORS.length]; 48 + } 49 + return '#007aff'; 50 + } 51 + 52 + // ===== Screen Border ===== 53 + // The border is a feature-owned overlay window showing a colored border 54 + // around the screen when a space is active. 55 + 56 + let borderWindowId = null; 57 + let borderHideTimer = null; 58 + const BORDER_HIDE_DELAY_MS = 600; 59 + 60 + async function showBorder(color, name) { 61 + const safeColor = resolveSpaceBorderColor(color, activeSpaceId); 62 + 63 + // If border window exists, just update it 64 + if (borderWindowId) { 65 + try { 66 + const exists = await api.window.exists(borderWindowId); 67 + if (exists?.exists) { 68 + // Update color and name via message 69 + api.publish('spaces:border-update', { color: safeColor, name }, api.scopes.GLOBAL); 70 + return; 71 + } 72 + } catch { /* window gone */ } 73 + borderWindowId = null; 74 + } 75 + 76 + // Create border overlay window 77 + try { 78 + const displayResult = await api.screen.getPrimaryDisplay(); 79 + if (!displayResult.success) return; 80 + const { bounds } = displayResult.data; 81 + 82 + const borderHtml = `peek://ext/spaces/border.html?color=${encodeURIComponent(safeColor)}&name=${encodeURIComponent(name)}`; 83 + const result = await api.window.open(borderHtml, { 84 + role: 'overlay', 85 + x: bounds.x, 86 + y: bounds.y, 87 + width: bounds.width, 88 + height: bounds.height, 89 + transparent: true, 90 + frame: false, 91 + focusable: false, 92 + skipTaskbar: true, 93 + alwaysOnTop: true, 94 + }); 95 + 96 + if (result?.id) { 97 + borderWindowId = result.id; 98 + await api.window.setIgnoreMouseEvents(borderWindowId, true, true); 99 + await api.window.setVisibleOnAllWorkspaces(borderWindowId, true); 100 + debug && console.log('[ext:spaces] Border window created:', borderWindowId); 101 + } 102 + } catch (err) { 103 + console.error('[ext:spaces] Failed to create border window:', err); 104 + } 105 + } 106 + 107 + function hideBorder() { 108 + if (borderWindowId) { 109 + try { 110 + api.window.close(borderWindowId); 111 + } catch { /* ignore */ } 112 + borderWindowId = null; 113 + } 114 + } 115 + 116 + function scheduleHideBorder() { 117 + if (!borderHideTimer) { 118 + borderHideTimer = setTimeout(() => { 119 + borderHideTimer = null; 120 + hideBorder(); 121 + }, BORDER_HIDE_DELAY_MS); 122 + } 123 + } 124 + 125 + function cancelHideTimer() { 126 + if (borderHideTimer) { 127 + clearTimeout(borderHideTimer); 128 + borderHideTimer = null; 129 + } 130 + } 131 + 132 + // ===== Auto-tagging ===== 133 + // Subscribe to item:created — if originating window is in space mode, tag it. 134 + 135 + async function handleItemCreated(msg) { 136 + const windowId = msg?.windowId; 137 + if (!windowId) return; // No window context (external, cmd palette) 138 + 139 + try { 140 + const modeResult = await api.context.get('mode', windowId); 141 + if (!modeResult.success) return; 142 + 143 + const mode = modeResult.data; 144 + if (mode?.value !== 'space' || !mode.metadata?.spaceId) return; 145 + 146 + const spaceTagId = mode.metadata.spaceId; 147 + const itemId = msg.itemId; 148 + if (!itemId) return; 149 + 150 + // Tag the item with the space tag 151 + const result = await api.datastore.tagItem(itemId, spaceTagId); 152 + if (result.success && !result.alreadyExists) { 153 + api.publish('tag:item-added', { 154 + tagId: spaceTagId, 155 + itemId, 156 + source: 'spaces-auto-tag' 157 + }, api.scopes.GLOBAL); 158 + debug && console.log('[ext:spaces] Auto-tagged item', itemId, 'with space', spaceTagId); 159 + } 160 + } catch (err) { 161 + debug && console.log('[ext:spaces] Auto-tag error:', err); 162 + } 163 + } 164 + 165 + // ===== Tag Removal Mode Reset ===== 166 + // When a space tag is removed from an item, reset matching windows to page mode. 167 + 168 + async function handleTagRemoved(msg) { 169 + const { tagId, itemId } = msg || {}; 170 + if (!tagId || !itemId) return; 171 + 172 + try { 173 + const spaceWindows = await api.context.getWindowsInSpace(tagId); 174 + if (!spaceWindows.success || !spaceWindows.data || spaceWindows.data.length === 0) return; 175 + 176 + // Get the item URL 177 + const itemResult = await api.datastore.getItem(itemId); 178 + if (!itemResult.success || !itemResult.data) return; 179 + const item = itemResult.data; 180 + if (item.type !== 'url' || !item.content) return; 181 + 182 + // Check each space window 183 + for (const windowId of spaceWindows.data) { 184 + const modeResult = await api.context.get('mode', windowId); 185 + if (!modeResult.success || !modeResult.data) continue; 186 + const windowUrl = modeResult.data.metadata?.url; 187 + if (windowUrl && normalizeUrl(windowUrl) === normalizeUrl(item.content)) { 188 + await api.context.setMode('page', { windowId, metadata: { url: windowUrl } }); 189 + debug && console.log('[ext:spaces] Reset window', windowId, 'from space to page after tag removal'); 190 + } 191 + } 192 + } catch (err) { 193 + debug && console.log('[ext:spaces] Tag removal handler error:', err); 194 + } 195 + } 196 + 197 + function normalizeUrl(url) { 198 + try { 199 + const u = new URL(url); 200 + let normalized = u.protocol + '//' + u.hostname.toLowerCase(); 201 + if (u.port && u.port !== '80' && u.port !== '443') normalized += ':' + u.port; 202 + let path = u.pathname; 203 + if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1); 204 + normalized += path + u.search + u.hash; 205 + return normalized; 206 + } catch { 207 + return url; 208 + } 209 + } 210 + 211 + // ===== Border Visibility (focus-based) ===== 212 + 213 + async function updateBorderVisibility() { 214 + if (!activeSpaceId) { 215 + scheduleHideBorder(); 216 + return; 217 + } 218 + 219 + try { 220 + const focusedId = await api.window.getFocusedVisibleWindowId(); 221 + if (!focusedId) { 222 + scheduleHideBorder(); 223 + return; 224 + } 225 + 226 + const modeResult = await api.context.get('mode', focusedId); 227 + if (modeResult.success && modeResult.data?.value === 'space' && modeResult.data.metadata?.spaceId) { 228 + cancelHideTimer(); 229 + const color = modeResult.data.metadata.color || activeSpaceColor; 230 + const name = modeResult.data.metadata.spaceName || activeSpaceName || 'Space'; 231 + showBorder(color, name); 232 + } else { 233 + scheduleHideBorder(); 234 + } 235 + } catch (err) { 236 + debug && console.log('[ext:spaces] updateBorderVisibility error:', err); 237 + scheduleHideBorder(); 238 + } 239 + } 240 + 241 + // ===== Session Management ===== 242 + 243 + async function openSpace(spaceName) { 244 + if (!spaceName) return { success: false, error: 'Usage: open space <name>' }; 245 + 246 + const tagName = toSpaceTagName(spaceName.trim()); 247 + 248 + // Get or create the space tag 249 + const tagResult = await api.datastore.getOrCreateTag(tagName); 250 + if (!tagResult.success) return { success: false, error: 'Failed to get/create space tag' }; 251 + const tag = tagResult.data.tag; 252 + 253 + activeSpaceId = tag.id; 254 + activeSpaceName = getSpaceDisplayName(tag); 255 + activeSpaceColor = tag.color; 256 + 257 + // Check for saved workspace state 258 + const wsResult = await api.settings.getExtKey('spaces', 'workspace:' + tag.id); 259 + let restored = false; 260 + if (wsResult.success && wsResult.data) { 261 + const snapshot = typeof wsResult.data === 'string' ? JSON.parse(wsResult.data) : wsResult.data; 262 + if (snapshot.version === 1 && Array.isArray(snapshot.windows) && snapshot.windows.length > 0) { 263 + // Restore saved windows 264 + for (const w of snapshot.windows) { 265 + if (!w.url) continue; 266 + const result = await api.window.open(w.url, { 267 + role: 'content', 268 + spaceMode: { spaceId: tag.id, spaceName: activeSpaceName, color: tag.color }, 269 + ...(w.bounds || {}), 270 + }); 271 + if (result?.id) { 272 + await api.context.setMode('space', { 273 + windowId: result.id, 274 + metadata: { spaceId: tag.id, spaceName: activeSpaceName, color: tag.color } 275 + }); 276 + } 277 + } 278 + restored = true; 279 + } 280 + } 281 + 282 + if (!restored) { 283 + // No saved state — open spaces home UI 284 + const homeUrl = `peek://ext/spaces/home.html?spaceId=${encodeURIComponent(tag.id)}&spaceName=${encodeURIComponent(activeSpaceName)}`; 285 + await api.window.open(homeUrl, { 286 + role: 'workspace', 287 + width: 1024, 288 + height: 768, 289 + spaceMode: { spaceId: tag.id, spaceName: activeSpaceName, color: tag.color }, 290 + }); 291 + } 292 + 293 + // Show border 294 + const borderColor = resolveSpaceBorderColor(tag.color, tag.id); 295 + showBorder(borderColor, activeSpaceName); 296 + 297 + console.log(`[ext:spaces] Opened space "${activeSpaceName}"`); 298 + return { success: true, spaceId: tag.id, spaceName: activeSpaceName }; 299 + } 300 + 301 + async function closeSpace() { 302 + if (!activeSpaceId) { 303 + return { success: false, error: 'No active space to close' }; 304 + } 305 + 306 + // Save workspace layouts 307 + try { 308 + if (api.session?.saveSpaceWorkspaces) { 309 + await api.session.saveSpaceWorkspaces(); 310 + } 311 + } catch (err) { 312 + debug && console.log('[ext:spaces] Failed to save workspace:', err); 313 + } 314 + 315 + // Get all windows in this space and close them 316 + const result = await api.context.getWindowsInSpace(activeSpaceId); 317 + if (result.success && result.data) { 318 + for (const windowId of result.data) { 319 + try { 320 + await api.window.close(windowId); 321 + } catch (err) { 322 + debug && console.log(`[ext:spaces] Failed to close window ${windowId}:`, err); 323 + } 324 + } 325 + } 326 + 327 + // Hide border 328 + cancelHideTimer(); 329 + hideBorder(); 330 + 331 + const closedName = activeSpaceName; 332 + activeSpaceId = null; 333 + activeSpaceName = null; 334 + activeSpaceColor = null; 335 + 336 + console.log(`[ext:spaces] Closed space "${closedName}"`); 337 + return { success: true }; 338 + } 339 + 340 + async function switchSpace(targetName) { 341 + if (!targetName) return { success: false, error: 'Usage: switch space <name>' }; 342 + await closeSpace(); 343 + return openSpace(targetName); 344 + } 345 + 346 + // ===== Command Registration ===== 347 + 348 + const initCommands = () => { 349 + // "open space <name>" 350 + api.subscribe('cmd:execute:open space', async (msg) => { 351 + const result = await openSpace(msg.search?.trim()); 352 + if (msg.expectResult && msg.resultTopic) { 353 + api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 354 + } 355 + }, api.scopes.GLOBAL); 356 + api.publish('cmd:register', { 357 + name: 'open space', 358 + description: 'Open a space (restore saved state or show home)', 359 + source: 'spaces', 360 + scope: 'global', 361 + params: [{ name: 'name', type: 'string', required: true, description: 'Space name' }] 362 + }, api.scopes.GLOBAL); 363 + 364 + // "close space" 365 + api.subscribe('cmd:execute:close space', async (msg) => { 366 + const result = await closeSpace(); 367 + if (msg.expectResult && msg.resultTopic) { 368 + api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 369 + } 370 + }, api.scopes.GLOBAL); 371 + api.publish('cmd:register', { 372 + name: 'close space', 373 + description: 'Save window state and close all space windows', 374 + source: 'spaces', 375 + scope: 'global', 376 + }, api.scopes.GLOBAL); 377 + 378 + // "switch space <name>" 379 + api.subscribe('cmd:execute:switch space', async (msg) => { 380 + const result = await switchSpace(msg.search?.trim()); 381 + if (msg.expectResult && msg.resultTopic) { 382 + api.publish(msg.resultTopic, result || { success: true }, api.scopes.GLOBAL); 383 + } 384 + }, api.scopes.GLOBAL); 385 + api.publish('cmd:register', { 386 + name: 'switch space', 387 + description: 'Close current space and open another', 388 + source: 'spaces', 389 + scope: 'global', 390 + params: [{ name: 'name', type: 'string', required: true, description: 'Target space name' }] 391 + }, api.scopes.GLOBAL); 392 + 393 + console.log('[ext:spaces] Commands registered: open space, close space, switch space'); 394 + }; 395 + 396 + const uninitCommands = () => { 397 + for (const name of ['open space', 'close space', 'switch space']) { 398 + api.publish('cmd:unregister', { name }, api.scopes.GLOBAL); 399 + } 400 + }; 401 + 402 + // ===== Lifecycle ===== 403 + 404 + const init = async () => { 405 + console.log('[ext:spaces] init'); 406 + 407 + initCommands(); 408 + 409 + // Subscribe to item:created for auto-tagging 410 + api.subscribe('item:created', handleItemCreated, api.scopes.GLOBAL); 411 + 412 + // Subscribe to tag:item-removed for mode reset 413 + api.subscribe('tag:item-removed', handleTagRemoved, api.scopes.GLOBAL); 414 + 415 + // Subscribe to focus events for border visibility 416 + api.subscribe('window:focused', () => updateBorderVisibility(), api.scopes.GLOBAL); 417 + api.subscribe('window:closed', () => updateBorderVisibility(), api.scopes.GLOBAL); 418 + api.subscribe('app:focus-changed', (msg) => { 419 + if (!msg?.focused) { 420 + // App lost focus — hide border 421 + scheduleHideBorder(); 422 + } else { 423 + updateBorderVisibility(); 424 + } 425 + }, api.scopes.GLOBAL); 426 + }; 427 + 428 + const uninit = () => { 429 + console.log('[ext:spaces] uninit'); 430 + uninitCommands(); 431 + cancelHideTimer(); 432 + hideBorder(); 433 + }; 434 + 435 + export default { 436 + defaults, 437 + id, 438 + init, 439 + uninit, 440 + labels, 441 + schemas, 442 + storageKeys 443 + };
+62
features/spaces/border.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <title>Space Border</title> 6 + <style> 7 + * { margin: 0; padding: 0; } 8 + html, body { 9 + width: 100vw; 10 + height: 100vh; 11 + background: transparent; 12 + overflow: hidden; 13 + pointer-events: none; 14 + } 15 + .border { 16 + position: fixed; 17 + inset: 0; 18 + border: 3px solid var(--color, #007aff); 19 + border-radius: 8px; 20 + pointer-events: none; 21 + } 22 + .label { 23 + position: fixed; 24 + top: 6px; 25 + left: 50%; 26 + transform: translateX(-50%); 27 + background: var(--color, #007aff); 28 + color: #fff; 29 + font: 500 11px/1 -apple-system, BlinkMacSystemFont, sans-serif; 30 + padding: 3px 10px; 31 + border-radius: 8px; 32 + white-space: nowrap; 33 + pointer-events: none; 34 + } 35 + </style> 36 + </head> 37 + <body> 38 + <div class="border" id="border"></div> 39 + <div class="label" id="label"></div> 40 + <script> 41 + const params = new URLSearchParams(window.location.search); 42 + const color = params.get('color') || '#007aff'; 43 + const name = params.get('name') || 'Space'; 44 + 45 + document.documentElement.style.setProperty('--color', color); 46 + document.getElementById('label').textContent = name; 47 + 48 + // Listen for updates from background.js 49 + if (window.app) { 50 + window.app.subscribe('spaces:border-update', (msg) => { 51 + if (msg.color) { 52 + document.documentElement.style.setProperty('--color', msg.color); 53 + document.getElementById('border').style.borderColor = msg.color; 54 + } 55 + if (msg.name) { 56 + document.getElementById('label').textContent = msg.name; 57 + } 58 + }, window.app.scopes.GLOBAL); 59 + } 60 + </script> 61 + </body> 62 + </html>
+58
features/spaces/config.js
··· 1 + const id = 'spaces'; 2 + 3 + const labels = { 4 + name: 'Spaces', 5 + }; 6 + 7 + const schemas = {}; 8 + 9 + const storageKeys = { 10 + WORKSPACES: 'workspaces', 11 + }; 12 + 13 + const defaults = {}; 14 + 15 + // Machine tag prefix for space identification 16 + const SPACE_TAG_PREFIX = 'space:'; 17 + 18 + /** 19 + * Check if a tag name is a space machine tag 20 + */ 21 + const isSpaceTag = (name) => { 22 + return typeof name === 'string' && name.startsWith(SPACE_TAG_PREFIX); 23 + }; 24 + 25 + /** 26 + * Get display name for a space tag 27 + * If metadata has displayName, use it. Otherwise strip the space: prefix. 28 + */ 29 + const getSpaceDisplayName = (tag) => { 30 + if (tag.metadata) { 31 + const meta = typeof tag.metadata === 'object' ? tag.metadata : (() => { try { return JSON.parse(tag.metadata); } catch { return {}; } })(); 32 + if (meta.displayName) return meta.displayName; 33 + } 34 + if (isSpaceTag(tag.name)) { 35 + return tag.name.slice(SPACE_TAG_PREFIX.length); 36 + } 37 + return tag.name; 38 + }; 39 + 40 + /** 41 + * Get the machine tag name for a space 42 + */ 43 + const toSpaceTagName = (name) => { 44 + if (name.startsWith(SPACE_TAG_PREFIX)) return name; 45 + return SPACE_TAG_PREFIX + name; 46 + }; 47 + 48 + export { 49 + id, 50 + labels, 51 + schemas, 52 + storageKeys, 53 + defaults, 54 + SPACE_TAG_PREFIX, 55 + isSpaceTag, 56 + getSpaceDisplayName, 57 + toSpaceTagName, 58 + };
+494
features/spaces/home.css
··· 1 + /* Import theme variables */ 2 + @import url('peek://theme/variables.css'); 3 + 4 + * { 5 + box-sizing: border-box; 6 + margin: 0; 7 + padding: 0; 8 + } 9 + 10 + html { 11 + font-family: var(--theme-font-sans); 12 + -webkit-font-smoothing: antialiased; 13 + font-size: 14px; 14 + line-height: 1.5; 15 + } 16 + 17 + body { 18 + background: var(--base00); 19 + color: var(--base05); 20 + min-height: 100vh; 21 + } 22 + 23 + /* Search */ 24 + .search-container { 25 + padding: 12px 16px 0 16px; 26 + } 27 + 28 + .search-row { 29 + display: flex; 30 + align-items: center; 31 + gap: 8px; 32 + } 33 + 34 + .search-row peek-input.search-input { 35 + flex: 1; 36 + } 37 + 38 + /* Create space button */ 39 + .create-space-btn { 40 + display: flex; 41 + align-items: center; 42 + justify-content: center; 43 + width: 28px; 44 + height: 28px; 45 + background: var(--base0D); 46 + border: none; 47 + border-radius: 5px; 48 + cursor: pointer; 49 + color: var(--base00); 50 + transition: all 0.15s; 51 + flex-shrink: 0; 52 + } 53 + 54 + .create-space-btn:hover { 55 + filter: brightness(1.1); 56 + } 57 + 58 + /* Create space form */ 59 + .create-space-form { 60 + margin-top: 12px; 61 + display: flex; 62 + flex-direction: column; 63 + gap: 8px; 64 + } 65 + 66 + .create-space-form peek-input.new-space-input { 67 + width: 100%; 68 + --peek-input-bg: var(--base01); 69 + --peek-input-border: var(--base02); 70 + --peek-input-height: 32px; 71 + } 72 + 73 + .create-space-actions { 74 + display: flex; 75 + justify-content: flex-end; 76 + gap: 8px; 77 + } 78 + 79 + /* Component customization for peek-input */ 80 + peek-input.search-input { 81 + display: block; 82 + width: 100%; 83 + --peek-input-bg: var(--base01); 84 + --peek-input-border: var(--base02); 85 + --peek-input-height: 32px; 86 + } 87 + 88 + peek-input.search-input::part(input) { 89 + color: var(--base05); 90 + font-size: 13px; 91 + } 92 + 93 + /* Grid toolbar */ 94 + peek-grid-toolbar.grid-toolbar { 95 + padding: 0 16px; 96 + --theme-bg-secondary: var(--base01); 97 + --theme-border: var(--base02); 98 + --theme-text: var(--base05); 99 + --theme-text-muted: var(--base04); 100 + --theme-bg-tertiary: var(--base02); 101 + --theme-accent: var(--base0D); 102 + } 103 + 104 + /* Cards container - peek-grid handles layout */ 105 + .cards { 106 + padding: 12px 16px; 107 + } 108 + 109 + /* Component customization for peek-card - shared across all cards */ 110 + peek-card { 111 + --peek-card-bg: var(--base01); 112 + --peek-card-hover-bg: var(--base02); 113 + --peek-card-border: transparent; 114 + --peek-card-radius: 6px; 115 + --peek-card-padding: 8px; 116 + --peek-card-gap: 4px; 117 + } 118 + 119 + peek-card[selected] { 120 + --peek-card-bg: var(--base02); 121 + } 122 + 123 + peek-card[selected]:hover { 124 + --peek-card-bg: var(--base03); 125 + } 126 + 127 + /* Space card slotted content */ 128 + .space-card .color-dot { 129 + width: 10px; 130 + height: 10px; 131 + border-radius: 50%; 132 + flex-shrink: 0; 133 + margin-top: 1px; 134 + } 135 + 136 + /* Address card slotted content */ 137 + .address-card .card-favicon { 138 + width: 16px; 139 + height: 16px; 140 + border-radius: 3px; 141 + flex-shrink: 0; 142 + background: var(--base02); 143 + object-fit: contain; 144 + margin-top: 1px; 145 + } 146 + 147 + /* Card button group */ 148 + .card-btn-group { 149 + display: flex; 150 + align-items: center; 151 + gap: 2px; 152 + flex-shrink: 0; 153 + } 154 + 155 + /* Card open button */ 156 + .card-open-btn { 157 + display: flex; 158 + align-items: center; 159 + justify-content: center; 160 + width: 22px; 161 + height: 22px; 162 + background: transparent; 163 + border: none; 164 + border-radius: 4px; 165 + cursor: pointer; 166 + color: var(--base04); 167 + flex-shrink: 0; 168 + transition: all 0.15s; 169 + padding: 0; 170 + } 171 + 172 + .card-open-btn:hover { 173 + color: var(--base05); 174 + } 175 + 176 + /* Card delete button */ 177 + .card-delete-btn { 178 + display: flex; 179 + align-items: center; 180 + justify-content: center; 181 + width: 22px; 182 + height: 22px; 183 + background: transparent; 184 + border: none; 185 + border-radius: 4px; 186 + cursor: pointer; 187 + color: var(--base04); 188 + flex-shrink: 0; 189 + transition: all 0.15s; 190 + padding: 0; 191 + } 192 + 193 + .card-delete-btn:hover { 194 + background: var(--base08); 195 + color: var(--base00); 196 + } 197 + 198 + /* Card remove-from-space button */ 199 + .card-remove-space-btn { 200 + display: flex; 201 + align-items: center; 202 + justify-content: center; 203 + width: 22px; 204 + height: 22px; 205 + background: transparent; 206 + border: none; 207 + border-radius: 4px; 208 + cursor: pointer; 209 + color: var(--base04); 210 + flex-shrink: 0; 211 + transition: all 0.15s; 212 + padding: 0; 213 + } 214 + 215 + .card-remove-space-btn:hover { 216 + background: var(--base08); 217 + color: var(--base00); 218 + } 219 + 220 + /* Card tags */ 221 + .card-tags { 222 + display: flex; 223 + flex-wrap: wrap; 224 + gap: 3px; 225 + } 226 + 227 + .card-tag { 228 + padding: 1px 6px; 229 + background: var(--base02); 230 + border-radius: 8px; 231 + font-size: 10px; 232 + color: var(--base04); 233 + cursor: pointer; 234 + transition: all 0.15s; 235 + } 236 + 237 + .card-tag:hover { 238 + background: var(--base03); 239 + color: var(--base05); 240 + } 241 + 242 + /* Tag remove button */ 243 + .card-tag-remove { 244 + display: inline-flex; 245 + align-items: center; 246 + justify-content: center; 247 + margin-left: 3px; 248 + font-size: 11px; 249 + line-height: 1; 250 + cursor: pointer; 251 + color: var(--base03); 252 + transition: color 0.15s; 253 + } 254 + 255 + .card-tag-remove:hover { 256 + color: var(--base08); 257 + } 258 + 259 + /* Shared slotted content styles */ 260 + .card-title { 261 + font-size: 12px; 262 + font-weight: 500; 263 + color: var(--base05); 264 + white-space: nowrap; 265 + overflow: hidden; 266 + text-overflow: ellipsis; 267 + line-height: 1.3; 268 + } 269 + 270 + .card-url { 271 + font-size: 11px; 272 + color: var(--base04); 273 + white-space: nowrap; 274 + overflow: hidden; 275 + text-overflow: ellipsis; 276 + margin-top: 1px; 277 + } 278 + 279 + .card-url-row { 280 + margin-top: -2px; 281 + } 282 + 283 + .card-meta { 284 + font-size: 11px; 285 + color: var(--base03); 286 + } 287 + 288 + /* List view mode */ 289 + peek-grid.cards[view-mode="list"] peek-card { 290 + max-width: none; 291 + } 292 + 293 + /* Masonry view */ 294 + peek-grid.cards[view-mode="masonry"] peek-card { 295 + max-width: none; 296 + } 297 + 298 + /* Empty state */ 299 + .empty-state { 300 + grid-column: 1 / -1; 301 + text-align: center; 302 + padding: 32px 16px; 303 + color: var(--base03); 304 + font-size: 13px; 305 + } 306 + 307 + /* Space detail header */ 308 + .space-detail-header { 309 + display: flex; 310 + align-items: center; 311 + gap: 10px; 312 + padding: 14px 16px 10px 16px; 313 + border-bottom: 1px solid var(--base02); 314 + } 315 + 316 + .space-header-name { 317 + font-size: 18px; 318 + font-weight: 700; 319 + color: var(--base05); 320 + cursor: default; 321 + white-space: nowrap; 322 + overflow: hidden; 323 + text-overflow: ellipsis; 324 + min-width: 0; 325 + } 326 + 327 + .space-header-spacer { 328 + flex: 1; 329 + } 330 + 331 + /* View mode buttons in space detail header */ 332 + .space-header-view-modes { 333 + display: flex; 334 + align-items: center; 335 + gap: 2px; 336 + flex-shrink: 0; 337 + } 338 + 339 + .space-header-view-modes .shvm-btn { 340 + display: inline-flex; 341 + align-items: center; 342 + justify-content: center; 343 + width: 28px; 344 + height: 28px; 345 + padding: 0; 346 + border: 1px solid transparent; 347 + border-radius: 5px; 348 + background: transparent; 349 + color: var(--base04); 350 + cursor: pointer; 351 + transition: all 0.15s; 352 + } 353 + 354 + .space-header-view-modes .shvm-btn:hover { 355 + background: var(--base02); 356 + color: var(--base05); 357 + } 358 + 359 + .space-header-view-modes .shvm-btn.active { 360 + background: var(--base0D); 361 + color: var(--base00); 362 + border-color: var(--base0D); 363 + } 364 + 365 + /* Inline sort controls */ 366 + .inline-sort-controls { 367 + display: flex; 368 + align-items: center; 369 + gap: 4px; 370 + flex-shrink: 0; 371 + } 372 + 373 + .inline-sort-controls .inline-sort-select { 374 + height: 28px; 375 + padding: 0 22px 0 8px; 376 + font: inherit; 377 + font-size: 12px; 378 + color: var(--base05); 379 + background: var(--base01); 380 + border: 1px solid var(--base02); 381 + border-radius: 5px; 382 + cursor: pointer; 383 + appearance: none; 384 + outline: none; 385 + transition: border-color 0.15s; 386 + } 387 + 388 + .inline-sort-controls .inline-sort-select:focus { 389 + border-color: var(--base0D); 390 + } 391 + 392 + .inline-sort-controls .inline-sort-wrapper { 393 + position: relative; 394 + display: flex; 395 + align-items: center; 396 + } 397 + 398 + .inline-sort-controls .inline-sort-arrow { 399 + position: absolute; 400 + right: 6px; 401 + pointer-events: none; 402 + color: var(--base04); 403 + } 404 + 405 + .inline-sort-controls .inline-sort-dir-btn { 406 + display: inline-flex; 407 + align-items: center; 408 + justify-content: center; 409 + width: 28px; 410 + height: 28px; 411 + padding: 0; 412 + border: 1px solid var(--base02); 413 + border-radius: 5px; 414 + background: var(--base01); 415 + color: var(--base05); 416 + cursor: pointer; 417 + transition: all 0.15s; 418 + } 419 + 420 + .inline-sort-controls .inline-sort-dir-btn:hover { 421 + background: var(--base02); 422 + } 423 + 424 + .space-header-name[title] { 425 + cursor: pointer; 426 + } 427 + 428 + .space-header-name[title]:hover { 429 + color: var(--base0D); 430 + } 431 + 432 + .space-rename-btn { 433 + display: flex; 434 + align-items: center; 435 + justify-content: center; 436 + width: 24px; 437 + height: 24px; 438 + background: transparent; 439 + border: none; 440 + border-radius: 4px; 441 + cursor: pointer; 442 + color: var(--base04); 443 + flex-shrink: 0; 444 + transition: all 0.15s; 445 + padding: 0; 446 + } 447 + 448 + .space-rename-btn:hover { 449 + color: var(--base05); 450 + background: var(--base02); 451 + } 452 + 453 + .space-rename-input { 454 + font-size: 16px; 455 + font-weight: 600; 456 + color: var(--base05); 457 + background: var(--base01); 458 + border: 1px solid var(--base0D); 459 + border-radius: 4px; 460 + padding: 2px 8px; 461 + outline: none; 462 + flex: 1; 463 + min-width: 0; 464 + font-family: var(--theme-font-sans); 465 + } 466 + 467 + .space-rename-input:focus { 468 + border-color: var(--base0D); 469 + box-shadow: 0 0 0 1px var(--base0D); 470 + } 471 + 472 + /* Space header color dot */ 473 + .space-header-color-dot { 474 + width: 14px; 475 + height: 14px; 476 + border-radius: 50%; 477 + flex-shrink: 0; 478 + cursor: pointer; 479 + border: 1px solid var(--base02); 480 + transition: transform 0.15s, box-shadow 0.15s; 481 + } 482 + 483 + .space-header-color-dot:hover { 484 + transform: scale(1.2); 485 + box-shadow: 0 0 0 2px var(--base02); 486 + } 487 + 488 + .space-header-color-input { 489 + position: absolute; 490 + width: 0; 491 + height: 0; 492 + opacity: 0; 493 + pointer-events: none; 494 + }
+68
features/spaces/home.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline';"> 6 + <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 7 + <title>Spaces</title> 8 + <link rel="stylesheet" type="text/css" href="home.css"> 9 + 10 + <!-- Import map for resolving bare module specifiers --> 11 + <script type="importmap"> 12 + { 13 + "imports": { 14 + "lit": "peek://node_modules/lit/index.js", 15 + "lit/": "peek://node_modules/lit/", 16 + "lit-html": "peek://node_modules/lit-html/lit-html.js", 17 + "lit-html/": "peek://node_modules/lit-html/", 18 + "lit-element": "peek://node_modules/lit-element/lit-element.js", 19 + "lit-element/": "peek://node_modules/lit-element/", 20 + "@lit/reactive-element": "peek://node_modules/@lit/reactive-element/reactive-element.js", 21 + "@lit/reactive-element/": "peek://node_modules/@lit/reactive-element/" 22 + } 23 + } 24 + </script> 25 + 26 + <!-- Import peek-components --> 27 + <script type="module"> 28 + import 'peek://app/components/peek-card.js'; 29 + import 'peek://app/components/peek-grid.js'; 30 + import 'peek://app/components/peek-input.js'; 31 + import 'peek://app/components/peek-button.js'; 32 + import 'peek://app/components/peek-grid-toolbar.js'; 33 + </script> 34 + </head> 35 + <body> 36 + <div class="space-detail-header" style="display: none;"></div> 37 + <div class="search-container"> 38 + <div class="search-row"> 39 + <peek-input 40 + class="search-input" 41 + placeholder="Search spaces..." 42 + type="search" 43 + ></peek-input> 44 + <button class="create-space-btn" title="Create new space"> 45 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> 46 + <line x1="12" y1="5" x2="12" y2="19"></line> 47 + <line x1="5" y1="12" x2="19" y2="12"></line> 48 + </svg> 49 + </button> 50 + </div> 51 + <div class="create-space-form" style="display: none;"> 52 + <peek-input 53 + class="new-space-input" 54 + placeholder="New space name..." 55 + ></peek-input> 56 + <div class="create-space-actions"> 57 + <peek-button class="create-space-cancel" variant="ghost" size="sm">Cancel</peek-button> 58 + <peek-button class="create-space-submit" variant="primary" size="sm">Create</peek-button> 59 + </div> 60 + </div> 61 + </div> 62 + 63 + <peek-grid-toolbar class="grid-toolbar" view-mode="columns"></peek-grid-toolbar> 64 + <peek-grid class="cards" min-item-width="250" gap="10"></peek-grid> 65 + 66 + <script type="module" src="home.js"></script> 67 + </body> 68 + </html>
+886
features/spaces/home.js
··· 1 + /** 2 + * Spaces Home - Shows space tags (space: prefixed) and their items 3 + * 4 + * Spaces are implemented using tags with a "space:" prefix: 5 + * - Each "space" is a tag named "space:Name" 6 + * - Items in a space are items tagged with that tag 7 + * - Creating a new space creates a new "space:" prefixed tag 8 + * - Viewing a space shows all items with that tag 9 + * - Opening items from a space sets space mode context 10 + */ 11 + 12 + import { 13 + updateSelection as _updateSelection, activateSelected as _activateSelected, 14 + createKeydownHandler, 15 + setupToolbar as _setupToolbar, 16 + createViewPrefs 17 + } from 'peek://app/lib/grid-nav.js'; 18 + import { createSearchResultCard } from 'peek://app/lib/search-result-card.js'; 19 + 20 + const api = window.app; 21 + const debug = api.debug; 22 + 23 + const SPACE_PREFIX = 'space:'; 24 + 25 + /** 26 + * Simple debounce helper 27 + */ 28 + const debounce = (fn, ms) => { 29 + let timer; 30 + return (...args) => { 31 + clearTimeout(timer); 32 + timer = setTimeout(() => fn(...args), ms); 33 + }; 34 + }; 35 + 36 + /** 37 + * Check if a URL is a navigable web URL (http/https only) 38 + */ 39 + const isWebUrl = (url) => { 40 + if (!url) return false; 41 + return url.startsWith('http://') || url.startsWith('https://'); 42 + }; 43 + 44 + /** 45 + * Get the display name of a space tag (strip "space:" prefix) 46 + */ 47 + const getSpaceDisplayName = (tag) => { 48 + if (tag.name && tag.name.startsWith(SPACE_PREFIX)) { 49 + return tag.name.substring(SPACE_PREFIX.length); 50 + } 51 + return tag.name; 52 + }; 53 + 54 + // View states 55 + const VIEW_SPACES = 'spaces'; 56 + const VIEW_ITEMS = 'items'; 57 + 58 + // View preferences (persisted) 59 + const { prefs: viewPrefs, load: loadViewPrefs, save: saveViewPrefs } = createViewPrefs('spacesViewPrefs', { 60 + viewMode: 'columns', 61 + sortBy: 'name', 62 + sortDirection: 'asc' 63 + }); 64 + 65 + const SORT_OPTIONS_SPACES = [ 66 + { value: 'name', label: 'Name' }, 67 + { value: 'count', label: 'Count' }, 68 + { value: 'recent', label: 'Recent' } 69 + ]; 70 + 71 + const SORT_OPTIONS_ITEMS = [ 72 + { value: 'name', label: 'Name' }, 73 + { value: 'count', label: 'Visits' }, 74 + { value: 'recent', label: 'Recent' } 75 + ]; 76 + 77 + let state = { 78 + view: VIEW_SPACES, 79 + spaces: [], 80 + currentSpace: null, 81 + items: [], 82 + selectedIndex: 0, 83 + searchQuery: '', 84 + lastViewedSpaceId: null 85 + }; 86 + 87 + // Expose state for debugging 88 + window._spacesState = state; 89 + 90 + /** 91 + * Sort spaces by current view preferences 92 + */ 93 + const sortSpaces = (spaces) => { 94 + const dir = viewPrefs.sortDirection === 'asc' ? 1 : -1; 95 + return spaces.sort((a, b) => { 96 + switch (viewPrefs.sortBy) { 97 + case 'name': 98 + return dir * getSpaceDisplayName(a).localeCompare(getSpaceDisplayName(b)); 99 + case 'count': { 100 + const ca = a.itemCount || 0; 101 + const cb = b.itemCount || 0; 102 + return dir * (ca - cb); 103 + } 104 + case 'recent': { 105 + const ra = a.updatedAt || a.createdAt || 0; 106 + const rb = b.updatedAt || b.createdAt || 0; 107 + return dir * (ra - rb); 108 + } 109 + default: 110 + return 0; 111 + } 112 + }); 113 + }; 114 + 115 + /** 116 + * Sort items by current view preferences 117 + */ 118 + const sortItems = (items) => { 119 + const dir = viewPrefs.sortDirection === 'asc' ? 1 : -1; 120 + return items.sort((a, b) => { 121 + switch (viewPrefs.sortBy) { 122 + case 'name': { 123 + const na = (a.title || a.uri || a.content || '').toLowerCase(); 124 + const nb = (b.title || b.uri || b.content || '').toLowerCase(); 125 + return dir * na.localeCompare(nb); 126 + } 127 + case 'count': { 128 + const ca = a.visitCount || 0; 129 + const cb = b.visitCount || 0; 130 + return dir * (ca - cb); 131 + } 132 + case 'recent': { 133 + const ra = a.updatedAt || a.createdAt || 0; 134 + const rb = b.updatedAt || b.createdAt || 0; 135 + return dir * (ra - rb); 136 + } 137 + default: 138 + return 0; 139 + } 140 + }); 141 + }; 142 + 143 + /** 144 + * Internal ESC handler 145 + */ 146 + const handleEscape = () => { 147 + const searchInput = document.querySelector('peek-input.search-input'); 148 + if (state.searchQuery) { 149 + state.searchQuery = ''; 150 + searchInput.value = ''; 151 + renderCurrentView(); 152 + return { handled: true }; 153 + } 154 + 155 + if (state.view === VIEW_ITEMS) { 156 + setTimeout(() => { 157 + showSpaces().catch(err => { 158 + console.error('[spaces] Error navigating back:', err); 159 + }); 160 + }, 0); 161 + return { handled: true }; 162 + } 163 + return { handled: false }; 164 + }; 165 + 166 + /** 167 + * Wrappers around shared grid-nav functions 168 + */ 169 + const updateSelection = () => _updateSelection(state); 170 + const activateSelected = () => _activateSelected(state); 171 + 172 + const handleKeydown = createKeydownHandler(state, { 173 + shouldIgnore: (e) => { 174 + const newSpaceInput = document.querySelector('peek-input.new-space-input'); 175 + const isNewSpaceFocused = document.activeElement === newSpaceInput || 176 + (newSpaceInput && newSpaceInput.shadowRoot?.activeElement); 177 + if (isNewSpaceFocused) return true; 178 + 179 + const searchInput = document.querySelector('peek-input.search-input'); 180 + const isSearchFocused = document.activeElement === searchInput || 181 + (searchInput && searchInput.shadowRoot?.activeElement); 182 + 183 + if ((e.key === '/' || (e.key === 'f' && (e.metaKey || e.ctrlKey))) && !isSearchFocused) { 184 + e.preventDefault(); 185 + searchInput.focus(); 186 + return true; 187 + } 188 + return false; 189 + }, 190 + isInputFocused: () => { 191 + const searchInput = document.querySelector('peek-input.search-input'); 192 + return document.activeElement === searchInput || 193 + !!(searchInput && searchInput.shadowRoot?.activeElement); 194 + } 195 + }); 196 + 197 + const setupToolbar = () => { 198 + _setupToolbar({ 199 + viewPrefs, 200 + sortOptions: SORT_OPTIONS_SPACES, 201 + onSave: saveViewPrefs, 202 + onRender: renderCurrentView 203 + }); 204 + }; 205 + 206 + const updateToolbarSortOptions = () => { 207 + const toolbar = document.querySelector('.grid-toolbar'); 208 + if (!toolbar) return; 209 + toolbar.sortOptions = state.view === VIEW_SPACES ? SORT_OPTIONS_SPACES : SORT_OPTIONS_ITEMS; 210 + }; 211 + 212 + /** 213 + * Load all space tags (filtered to space: prefix) 214 + */ 215 + const loadSpaces = async () => { 216 + const result = await api.datastore.getTagsByFrecency(); 217 + if (result.success) { 218 + // Filter to only space: prefixed tags 219 + const spaceTags = result.data.filter(tag => tag.name && tag.name.startsWith(SPACE_PREFIX)); 220 + 221 + // Count items per space 222 + for (const tag of spaceTags) { 223 + const itemsResult = await api.datastore.getItemsByTag(tag.id); 224 + if (itemsResult.success) { 225 + tag.itemCount = itemsResult.data.filter(item => 226 + item.type === 'url' && isWebUrl(item.content) 227 + ).length; 228 + } else { 229 + tag.itemCount = 0; 230 + } 231 + } 232 + state.spaces = spaceTags; 233 + debug && console.log('[spaces] Loaded spaces:', state.spaces.length); 234 + } else { 235 + console.error('[spaces] Failed to load spaces:', result.error); 236 + state.spaces = []; 237 + } 238 + }; 239 + 240 + /** 241 + * Normalize URL for dedup 242 + */ 243 + const normalizeUrlForCompare = (url) => { 244 + try { 245 + const u = new URL(url); 246 + let normalized = u.protocol + '//' + u.hostname.toLowerCase(); 247 + if (u.port && u.port !== '80' && u.port !== '443') normalized += ':' + u.port; 248 + let path = u.pathname; 249 + if (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1); 250 + normalized += path + u.search + u.hash; 251 + return normalized; 252 + } catch { 253 + return url; 254 + } 255 + }; 256 + 257 + const deduplicateByUrl = (items) => { 258 + const seen = new Map(); 259 + for (const item of items) { 260 + const normalized = normalizeUrlForCompare(item.content); 261 + const existing = seen.get(normalized); 262 + if (!existing || (item.updatedAt || 0) > (existing.updatedAt || 0)) { 263 + seen.set(normalized, item); 264 + } 265 + } 266 + return Array.from(seen.values()); 267 + }; 268 + 269 + /** 270 + * Load items for a specific space tag 271 + */ 272 + const loadItemsForSpace = async (tagId) => { 273 + const result = await api.datastore.getItemsByTag(tagId); 274 + if (result.success) { 275 + const urlItems = result.data.filter(item => 276 + item.type === 'url' && isWebUrl(item.content) 277 + ); 278 + state.items = deduplicateByUrl(urlItems); 279 + debug && console.log('[spaces] Loaded items for space:', state.items.length); 280 + } else { 281 + console.error('[spaces] Failed to load items:', result.error); 282 + state.items = []; 283 + } 284 + }; 285 + 286 + const filterSpaces = (spaces) => { 287 + if (!state.searchQuery) return spaces; 288 + const q = state.searchQuery.toLowerCase(); 289 + return spaces.filter(tag => getSpaceDisplayName(tag).toLowerCase().includes(q)); 290 + }; 291 + 292 + const filterItems = (items) => { 293 + if (!state.searchQuery) return items; 294 + const q = state.searchQuery.toLowerCase(); 295 + return items.filter(item => { 296 + const url = item.uri || item.content || ''; 297 + return (item.title || '').toLowerCase().includes(q) || 298 + url.toLowerCase().includes(q); 299 + }); 300 + }; 301 + 302 + /** 303 + * Create inline sort controls 304 + */ 305 + const addInlineSortControls = (container) => { 306 + if (!container) return; 307 + const existing = container.querySelector('.inline-sort-controls'); 308 + if (existing) existing.remove(); 309 + 310 + const controls = document.createElement('div'); 311 + controls.className = 'inline-sort-controls'; 312 + 313 + const sortWrapper = document.createElement('div'); 314 + sortWrapper.className = 'inline-sort-wrapper'; 315 + 316 + const select = document.createElement('select'); 317 + select.className = 'inline-sort-select'; 318 + SORT_OPTIONS_ITEMS.forEach(opt => { 319 + const option = document.createElement('option'); 320 + option.value = opt.value; 321 + option.textContent = opt.label; 322 + if (opt.value === viewPrefs.sortBy) option.selected = true; 323 + select.appendChild(option); 324 + }); 325 + select.addEventListener('change', () => { 326 + viewPrefs.sortBy = select.value; 327 + saveViewPrefs(); 328 + renderCurrentView(); 329 + }); 330 + sortWrapper.appendChild(select); 331 + 332 + const arrow = document.createElement('svg'); 333 + arrow.className = 'inline-sort-arrow'; 334 + arrow.setAttribute('width', '10'); 335 + arrow.setAttribute('height', '10'); 336 + arrow.setAttribute('viewBox', '0 0 10 10'); 337 + arrow.setAttribute('fill', 'none'); 338 + arrow.innerHTML = '<path d="M2.5 3.75L5 6.25L7.5 3.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>'; 339 + sortWrapper.appendChild(arrow); 340 + 341 + controls.appendChild(sortWrapper); 342 + 343 + const dirBtn = document.createElement('button'); 344 + dirBtn.className = 'inline-sort-dir-btn'; 345 + dirBtn.title = viewPrefs.sortDirection === 'asc' ? 'Ascending' : 'Descending'; 346 + const updateDirIcon = () => { 347 + dirBtn.title = viewPrefs.sortDirection === 'asc' ? 'Ascending' : 'Descending'; 348 + dirBtn.innerHTML = viewPrefs.sortDirection === 'asc' 349 + ? '<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 11V3M7 3L4 6M7 3L10 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>' 350 + : '<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><path d="M7 3V11M7 11L4 8M7 11L10 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>'; 351 + }; 352 + updateDirIcon(); 353 + dirBtn.addEventListener('click', () => { 354 + viewPrefs.sortDirection = viewPrefs.sortDirection === 'asc' ? 'desc' : 'asc'; 355 + saveViewPrefs(); 356 + updateDirIcon(); 357 + renderCurrentView(); 358 + }); 359 + controls.appendChild(dirBtn); 360 + 361 + container.appendChild(controls); 362 + }; 363 + 364 + /** 365 + * Set up create space UI 366 + */ 367 + const setupCreateSpace = () => { 368 + const createBtn = document.querySelector('.create-space-btn'); 369 + const form = document.querySelector('.create-space-form'); 370 + const newSpaceInput = document.querySelector('.new-space-input'); 371 + const cancelBtn = document.querySelector('.create-space-cancel'); 372 + const submitBtn = document.querySelector('.create-space-submit'); 373 + 374 + if (!createBtn || !form) return; 375 + 376 + const showForm = () => { 377 + form.style.display = ''; 378 + newSpaceInput.value = ''; 379 + setTimeout(() => newSpaceInput.focus(), 50); 380 + }; 381 + 382 + const hideForm = () => { 383 + form.style.display = 'none'; 384 + newSpaceInput.value = ''; 385 + }; 386 + 387 + const submitSpace = async () => { 388 + const name = (newSpaceInput.value || '').trim(); 389 + if (!name) return; 390 + 391 + // Prepend space: prefix if not already present 392 + const tagName = name.startsWith(SPACE_PREFIX) ? name : SPACE_PREFIX + name; 393 + 394 + try { 395 + const result = await api.datastore.getOrCreateTag(tagName); 396 + if (result.success) { 397 + debug && console.log('[spaces] Created new space:', tagName, result.data); 398 + hideForm(); 399 + await loadSpaces(); 400 + renderSpaces(); 401 + } else { 402 + console.error('[spaces] Failed to create space:', result.error); 403 + } 404 + } catch (err) { 405 + console.error('[spaces] Error creating space:', err); 406 + } 407 + }; 408 + 409 + createBtn.addEventListener('click', showForm); 410 + cancelBtn.addEventListener('click', hideForm); 411 + submitBtn.addEventListener('click', submitSpace); 412 + newSpaceInput.addEventListener('keydown', (e) => { 413 + if (e.key === 'Enter') { 414 + e.preventDefault(); 415 + submitSpace(); 416 + } else if (e.key === 'Escape') { 417 + e.preventDefault(); 418 + hideForm(); 419 + } 420 + }); 421 + }; 422 + 423 + const renderCurrentView = () => { 424 + if (state.view === VIEW_SPACES) { 425 + renderSpaces(); 426 + } else { 427 + renderItems(); 428 + } 429 + }; 430 + 431 + /** 432 + * Show the spaces list view 433 + */ 434 + const showSpaces = async () => { 435 + if (state.currentSpace) { 436 + state.lastViewedSpaceId = state.currentSpace.id; 437 + } 438 + state.view = VIEW_SPACES; 439 + state.currentSpace = null; 440 + state.searchQuery = ''; 441 + 442 + await loadSpaces(); 443 + 444 + const searchInput = document.querySelector('peek-input.search-input'); 445 + searchInput.value = ''; 446 + searchInput.placeholder = 'Search spaces...'; 447 + 448 + const createBtn = document.querySelector('.create-space-btn'); 449 + if (createBtn) createBtn.style.display = ''; 450 + 451 + const toolbar = document.querySelector('peek-grid-toolbar.grid-toolbar'); 452 + if (toolbar) toolbar.style.display = ''; 453 + 454 + const inlineSort = document.querySelector('.inline-sort-controls'); 455 + if (inlineSort) inlineSort.remove(); 456 + 457 + renderSpaces(); 458 + }; 459 + 460 + window.showSpaces = showSpaces; 461 + 462 + /** 463 + * Render space cards 464 + */ 465 + const renderSpaces = () => { 466 + const container = document.querySelector('.cards'); 467 + container.innerHTML = ''; 468 + updateToolbarSortOptions(); 469 + 470 + renderSpaceHeader(); 471 + 472 + let filteredSpaces = filterSpaces([...state.spaces]); 473 + filteredSpaces = sortSpaces(filteredSpaces); 474 + 475 + if (filteredSpaces.length === 0) { 476 + const message = state.searchQuery 477 + ? 'No spaces match your search.' 478 + : 'No spaces yet. Create one with the + button above.'; 479 + container.innerHTML = `<div class="empty-state">${message}</div>`; 480 + } else { 481 + filteredSpaces.forEach(space => { 482 + const card = createSpaceCard(space); 483 + container.appendChild(card); 484 + }); 485 + } 486 + 487 + if (state.lastViewedSpaceId) { 488 + const cards = Array.from(container.querySelectorAll('peek-card')); 489 + const targetIndex = cards.findIndex(card => card.dataset.tagId === String(state.lastViewedSpaceId)); 490 + state.selectedIndex = targetIndex >= 0 ? targetIndex : 0; 491 + state.lastViewedSpaceId = null; 492 + } else { 493 + state.selectedIndex = 0; 494 + } 495 + updateSelection(); 496 + }; 497 + 498 + /** 499 + * Show items in a space 500 + */ 501 + const showItems = async (space) => { 502 + state.view = VIEW_ITEMS; 503 + state.currentSpace = space; 504 + state.searchQuery = ''; 505 + 506 + await loadItemsForSpace(space.id); 507 + 508 + const searchInput = document.querySelector('peek-input.search-input'); 509 + searchInput.value = ''; 510 + searchInput.placeholder = `Search in ${getSpaceDisplayName(space)}...`; 511 + 512 + const createBtn = document.querySelector('.create-space-btn'); 513 + const createForm = document.querySelector('.create-space-form'); 514 + if (createBtn) createBtn.style.display = 'none'; 515 + if (createForm) createForm.style.display = 'none'; 516 + 517 + const toolbar = document.querySelector('peek-grid-toolbar.grid-toolbar'); 518 + if (toolbar) toolbar.style.display = 'none'; 519 + 520 + const searchRow = document.querySelector('.search-row'); 521 + addInlineSortControls(searchRow); 522 + 523 + renderItems(); 524 + }; 525 + 526 + /** 527 + * Render the space detail header 528 + */ 529 + const renderSpaceHeader = () => { 530 + const header = document.querySelector('body > .space-detail-header'); 531 + if (!header) return; 532 + 533 + const space = state.currentSpace; 534 + if (!space) { 535 + header.style.display = 'none'; 536 + header.innerHTML = ''; 537 + return; 538 + } 539 + 540 + header.style.display = ''; 541 + header.innerHTML = ''; 542 + 543 + const nameEl = document.createElement('span'); 544 + nameEl.className = 'space-header-name'; 545 + nameEl.textContent = getSpaceDisplayName(space); 546 + nameEl.title = 'Double-click to rename'; 547 + nameEl.addEventListener('dblclick', () => startRenameSpace(header, space)); 548 + header.appendChild(nameEl); 549 + 550 + // Rename button + color dot 551 + const renameBtn = document.createElement('button'); 552 + renameBtn.className = 'space-rename-btn'; 553 + renameBtn.title = 'Rename space'; 554 + renameBtn.innerHTML = 555 + '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + 556 + '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>' + 557 + '<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>' + 558 + '</svg>'; 559 + renameBtn.addEventListener('click', () => startRenameSpace(header, space)); 560 + header.appendChild(renameBtn); 561 + 562 + const colorDot = document.createElement('div'); 563 + colorDot.className = 'space-header-color-dot'; 564 + colorDot.style.backgroundColor = space.color || '#999'; 565 + colorDot.title = 'Change space color'; 566 + 567 + const colorInput = document.createElement('input'); 568 + colorInput.type = 'color'; 569 + colorInput.className = 'space-header-color-input'; 570 + colorInput.value = space.color || '#999999'; 571 + colorInput.addEventListener('input', (e) => { 572 + colorDot.style.backgroundColor = e.target.value; 573 + }); 574 + colorInput.addEventListener('change', async (e) => { 575 + const newColor = e.target.value; 576 + space.color = newColor; 577 + await api.datastore.updateTagColor(space.id, newColor); 578 + }); 579 + 580 + colorDot.addEventListener('click', () => colorInput.click()); 581 + colorDot.appendChild(colorInput); 582 + header.appendChild(colorDot); 583 + 584 + // Spacer 585 + const spacer = document.createElement('div'); 586 + spacer.className = 'space-header-spacer'; 587 + header.appendChild(spacer); 588 + 589 + // View mode buttons 590 + const viewModes = document.createElement('div'); 591 + viewModes.className = 'space-header-view-modes'; 592 + 593 + const modes = [ 594 + { mode: 'columns', title: 'Grid view', svg: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><rect x="1" y="1" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="8" y="1" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="1" y="8" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.5"/><rect x="8" y="8" width="5" height="5" rx="1" stroke="currentColor" stroke-width="1.5"/></svg>' }, 595 + { mode: 'list', title: 'List view', svg: '<svg width="14" height="14" viewBox="0 0 14 14" fill="none"><line x1="1" y1="3" x2="13" y2="3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="1" y1="7" x2="13" y2="7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="1" y1="11" x2="13" y2="11" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>' }, 596 + ]; 597 + 598 + modes.forEach(({ mode, title, svg }) => { 599 + const btn = document.createElement('button'); 600 + btn.className = 'shvm-btn' + (viewPrefs.viewMode === mode ? ' active' : ''); 601 + btn.title = title; 602 + btn.innerHTML = svg; 603 + btn.addEventListener('click', () => { 604 + viewPrefs.viewMode = mode; 605 + saveViewPrefs(); 606 + const grid = document.querySelector('peek-grid.cards'); 607 + if (grid) grid.setAttribute('view-mode', mode); 608 + renderSpaceHeader(); 609 + renderCurrentView(); 610 + }); 611 + viewModes.appendChild(btn); 612 + }); 613 + 614 + header.appendChild(viewModes); 615 + }; 616 + 617 + /** 618 + * Start inline rename of a space 619 + */ 620 + const startRenameSpace = (headerEl, space) => { 621 + const nameEl = headerEl.querySelector('.space-header-name'); 622 + const renameBtn = headerEl.querySelector('.space-rename-btn'); 623 + if (!nameEl) return; 624 + 625 + const input = document.createElement('input'); 626 + input.className = 'space-rename-input'; 627 + input.type = 'text'; 628 + input.value = getSpaceDisplayName(space); 629 + 630 + const finishRename = async (commit) => { 631 + const newDisplayName = input.value.trim(); 632 + if (commit && newDisplayName && newDisplayName !== getSpaceDisplayName(space)) { 633 + const newTagName = SPACE_PREFIX + newDisplayName; 634 + try { 635 + const result = await api.datastore.renameTag(space.id, newTagName); 636 + if (result.success) { 637 + space.name = newTagName; 638 + const searchInput = document.querySelector('peek-input.search-input'); 639 + searchInput.placeholder = `Search in ${newDisplayName}...`; 640 + debug && console.log('[spaces] Renamed space to:', newTagName); 641 + } else { 642 + console.error('[spaces] Failed to rename space:', result.error); 643 + } 644 + } catch (err) { 645 + console.error('[spaces] Error renaming space:', err); 646 + } 647 + } 648 + nameEl.textContent = getSpaceDisplayName(space); 649 + nameEl.style.display = ''; 650 + input.remove(); 651 + if (renameBtn) renameBtn.style.display = ''; 652 + }; 653 + 654 + nameEl.style.display = 'none'; 655 + if (renameBtn) renameBtn.style.display = 'none'; 656 + headerEl.insertBefore(input, nameEl.nextSibling); 657 + input.focus(); 658 + input.select(); 659 + 660 + input.addEventListener('keydown', (e) => { 661 + if (e.key === 'Enter') { 662 + e.preventDefault(); 663 + finishRename(true); 664 + } else if (e.key === 'Escape') { 665 + e.preventDefault(); 666 + finishRename(false); 667 + } 668 + }); 669 + input.addEventListener('blur', () => finishRename(true)); 670 + }; 671 + 672 + /** 673 + * Render item cards 674 + */ 675 + const renderItems = () => { 676 + const container = document.querySelector('.cards'); 677 + container.innerHTML = ''; 678 + updateToolbarSortOptions(); 679 + 680 + renderSpaceHeader(); 681 + 682 + let filteredItems = filterItems(state.items); 683 + 684 + if (filteredItems.length === 0) { 685 + const message = state.searchQuery 686 + ? 'No pages match your search.' 687 + : 'No pages in this space yet.'; 688 + const emptyEl = document.createElement('div'); 689 + emptyEl.className = 'empty-state'; 690 + emptyEl.textContent = message; 691 + container.appendChild(emptyEl); 692 + return; 693 + } 694 + 695 + filteredItems = sortItems(filteredItems); 696 + 697 + filteredItems.forEach(item => { 698 + const card = createItemCard(item); 699 + container.appendChild(card); 700 + }); 701 + 702 + state.selectedIndex = 0; 703 + updateSelection(); 704 + }; 705 + 706 + /** 707 + * Create a card for a space 708 + */ 709 + const createSpaceCard = (space) => { 710 + const card = document.createElement('peek-card'); 711 + card.className = 'space-card'; 712 + card.interactive = true; 713 + card.dataset.tagId = space.id; 714 + 715 + const header = document.createElement('div'); 716 + header.slot = 'header'; 717 + header.style.display = 'flex'; 718 + header.style.alignItems = 'center'; 719 + header.style.gap = '8px'; 720 + 721 + const colorDot = document.createElement('div'); 722 + colorDot.className = 'color-dot'; 723 + colorDot.style.backgroundColor = space.color || '#999'; 724 + 725 + const title = document.createElement('h2'); 726 + title.className = 'card-title'; 727 + title.textContent = getSpaceDisplayName(space); 728 + title.style.margin = '0'; 729 + title.style.flex = '1'; 730 + title.style.minWidth = '0'; 731 + 732 + header.appendChild(colorDot); 733 + header.appendChild(title); 734 + card.appendChild(header); 735 + 736 + const footer = document.createElement('div'); 737 + footer.slot = 'footer'; 738 + footer.className = 'card-meta'; 739 + const count = space.itemCount || 0; 740 + footer.textContent = `${count} ${count === 1 ? 'page' : 'pages'}`; 741 + card.appendChild(footer); 742 + 743 + card.addEventListener('card-click', () => showItems(space)); 744 + 745 + return card; 746 + }; 747 + 748 + /** 749 + * Create a card for an item in a space 750 + */ 751 + const createItemCard = (item) => { 752 + const itemUrl = item.uri || item.content; 753 + 754 + const card = createSearchResultCard(item, { 755 + className: 'address-card', 756 + visitCount: item.visitCount, 757 + showOpenButton: isWebUrl(itemUrl), 758 + onOpen: (url) => { 759 + // Open with space mode context 760 + api.window.open(url, { 761 + role: 'content', 762 + key: url, 763 + width: 1024, 764 + height: 768, 765 + spaceMode: state.currentSpace ? { 766 + spaceId: state.currentSpace.id, 767 + spaceName: getSpaceDisplayName(state.currentSpace), 768 + color: state.currentSpace.color || '#999' 769 + } : undefined 770 + }); 771 + }, 772 + onDelete: async (deleteItem) => { 773 + if (!confirm(`Delete "${deleteItem.title || deleteItem.content || 'this item'}"?`)) return; 774 + try { 775 + await api.datastore.deleteItem(deleteItem.id); 776 + api.publish('item:deleted', { id: deleteItem.id }, api.scopes.GLOBAL); 777 + if (state.currentSpace) { await loadItemsForSpace(state.currentSpace.id); renderItems(); } 778 + } catch (err) { 779 + console.error('[spaces] Failed to delete item:', err); 780 + } 781 + }, 782 + onClick: async () => { 783 + debug && console.log('[spaces] Opening item:', itemUrl); 784 + // Open with space mode context so the window enters space mode 785 + const openOptions = { 786 + role: 'content', 787 + key: itemUrl, 788 + width: 1024, 789 + height: 768, 790 + spaceMode: state.currentSpace ? { 791 + spaceId: state.currentSpace.id, 792 + spaceName: getSpaceDisplayName(state.currentSpace), 793 + color: state.currentSpace.color || '#999' 794 + } : undefined 795 + }; 796 + const result = await api.window.open(itemUrl, openOptions); 797 + debug && console.log('[spaces] Window opened:', result); 798 + } 799 + }); 800 + 801 + card.dataset.itemId = item.id; 802 + 803 + // Add remove-from-space button 804 + if (state.currentSpace) { 805 + const header = card.querySelector('[slot="header"]'); 806 + if (header) { 807 + let btnGroup = header.querySelector('.card-btn-group'); 808 + if (!btnGroup) { 809 + btnGroup = document.createElement('span'); 810 + btnGroup.className = 'card-btn-group'; 811 + header.appendChild(btnGroup); 812 + } 813 + const removeBtn = document.createElement('button'); 814 + removeBtn.className = 'card-remove-space-btn'; 815 + removeBtn.title = `Remove from ${getSpaceDisplayName(state.currentSpace)}`; 816 + removeBtn.innerHTML = 817 + '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + 818 + '<line x1="18" y1="6" x2="6" y2="18"></line>' + 819 + '<line x1="6" y1="6" x2="18" y2="18"></line>' + 820 + '</svg>'; 821 + removeBtn.addEventListener('click', async (e) => { 822 + e.stopPropagation(); 823 + try { 824 + await api.datastore.untagItem(item.id, state.currentSpace.id); 825 + api.publish('tag:item-removed', { itemId: item.id, tagId: state.currentSpace.id }, api.scopes.GLOBAL); 826 + if (state.currentSpace) { await loadItemsForSpace(state.currentSpace.id); renderItems(); } 827 + } catch (err) { 828 + console.error('[spaces] Failed to remove item from space:', err); 829 + } 830 + }); 831 + const deleteBtn = btnGroup.querySelector('.card-delete-btn'); 832 + if (deleteBtn) { 833 + btnGroup.insertBefore(removeBtn, deleteBtn); 834 + } else { 835 + btnGroup.appendChild(removeBtn); 836 + } 837 + } 838 + } 839 + 840 + return card; 841 + }; 842 + 843 + const init = async () => { 844 + debug && console.log('[spaces] init'); 845 + 846 + api.escape.onEscape(handleEscape); 847 + 848 + loadViewPrefs(); 849 + setupToolbar(); 850 + 851 + await loadSpaces(); 852 + 853 + const searchInput = document.querySelector('peek-input.search-input'); 854 + searchInput.addEventListener('input', (e) => { 855 + state.searchQuery = e.target.value; 856 + renderCurrentView(); 857 + }); 858 + 859 + document.addEventListener('keydown', handleKeydown); 860 + 861 + const debouncedRefresh = debounce(async () => { 862 + debug && console.log('[spaces] debounced refresh triggered'); 863 + await loadSpaces(); 864 + if (state.view === VIEW_SPACES) { 865 + renderSpaces(); 866 + } else if (state.view === VIEW_ITEMS && state.currentSpace) { 867 + await loadItemsForSpace(state.currentSpace.id); 868 + renderItems(); 869 + } 870 + }, 150); 871 + 872 + api.subscribe('tag:item-added', () => debouncedRefresh(), api.scopes.GLOBAL); 873 + api.subscribe('tag:item-removed', () => debouncedRefresh(), api.scopes.GLOBAL); 874 + api.subscribe('tag:created', () => debouncedRefresh(), api.scopes.GLOBAL); 875 + api.subscribe('item:created', () => debouncedRefresh(), api.scopes.GLOBAL); 876 + api.subscribe('item:deleted', () => debouncedRefresh(), api.scopes.GLOBAL); 877 + api.subscribe('sync:pull-completed', () => debouncedRefresh(), api.scopes.GLOBAL); 878 + api.subscribe('tag:renamed', () => debouncedRefresh(), api.scopes.GLOBAL); 879 + api.subscribe('tag:color-changed', () => debouncedRefresh(), api.scopes.GLOBAL); 880 + 881 + setupCreateSpace(); 882 + 883 + showSpaces(); 884 + }; 885 + 886 + document.addEventListener('DOMContentLoaded', init);
+39
features/spaces/manifest.json
··· 1 + { 2 + "id": "spaces", 3 + "shortname": "spaces", 4 + "name": "Spaces", 5 + "description": "Workspace mode with auto-tagging, screen border, and session management", 6 + "version": "1.0.0", 7 + "background": "background.html", 8 + "builtin": true, 9 + "commands": [ 10 + { 11 + "name": "open space", 12 + "description": "Open a space (restore saved state or show home)", 13 + "action": { "type": "execute" } 14 + }, 15 + { 16 + "name": "close space", 17 + "description": "Save window state and close all space windows", 18 + "action": { "type": "execute" } 19 + }, 20 + { 21 + "name": "switch space", 22 + "description": "Close current space and open another", 23 + "action": { "type": "execute" } 24 + }, 25 + { 26 + "name": "open spaces", 27 + "description": "Browse all spaces", 28 + "action": { 29 + "type": "window", 30 + "url": "peek://ext/spaces/home.html", 31 + "options": { 32 + "width": 800, 33 + "height": 600, 34 + "role": "workspace" 35 + } 36 + } 37 + } 38 + ] 39 + }
+3 -1
features/theme-shichijuni/variables.css
··· 60 60 --theme-surface-border: 1px solid rgba(0, 0, 0, 0.10); 61 61 --theme-surface-ring: 0 0 0 1px rgba(0, 0, 0, 0.06); 62 62 63 - /* Typography: inherited from peek base layer — colors-only theme */ 63 + /* Typography */ 64 + --theme-font-sans: "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Yu Gothic", system-ui, sans-serif; 65 + --theme-font-mono: "Hiragino Kaku Gothic ProN", "Noto Sans Mono", "SF Mono", SFMono-Regular, Menlo, Monaco, monospace; 64 66 } 65 67 66 68 /* ===================================================================
+1 -1
features/widget-demo/background.js
··· 59 59 }; 60 60 61 61 /** 62 - * Ensure the demo sheet config exists in extension_settings. 62 + * Ensure the demo sheet config exists in feature_settings. 63 63 * Creates a default config if none exists. 64 64 */ 65 65 const ensureSheetConfig = async () => {
+3 -3
features/widget-demo/sheet.js
··· 5 5 * Each widget is a webview pointing to an individual widget page 6 6 * (e.g., peek://ext/widget-demo/widgets/clock.html). 7 7 * 8 - * Layout config is loaded from extension_settings (created by background.js). 8 + * Layout config is loaded from feature_settings (created by background.js). 9 9 */ 10 10 11 11 const api = window.app; ··· 51 51 let editing = false; 52 52 53 53 /** 54 - * Load demo sheet config from extension_settings. 54 + * Load demo sheet config from feature_settings. 55 55 * Creates default config if none exists (lazy init, no background page needed). 56 56 */ 57 57 const loadSheetConfig = async () => { ··· 68 68 }; 69 69 70 70 /** 71 - * Save sheet config to extension_settings 71 + * Save sheet config to feature_settings 72 72 */ 73 73 const saveSheetConfig = async () => { 74 74 if (!sheetConfig) return;
+6 -6
preload.js
··· 1748 1748 * @returns {Promise<{success: boolean, data?: {extId, name, schema}, error?: string}>} 1749 1749 */ 1750 1750 getSettingsSchema: (extId) => { 1751 - return ipcRenderer.invoke('extension-settings-schema', { extId }); 1751 + return ipcRenderer.invoke('feature-settings-schema', { extId }); 1752 1752 } 1753 1753 }; 1754 1754 ··· 1765 1765 if (!extId) { 1766 1766 return Promise.resolve({ success: false, error: 'Not an extension context' }); 1767 1767 } 1768 - return ipcRenderer.invoke('extension-settings-get', { extId }); 1768 + return ipcRenderer.invoke('feature-settings-get', { extId }); 1769 1769 }, 1770 1770 1771 1771 /** ··· 1779 1779 if (!extId) { 1780 1780 return Promise.resolve({ success: false, error: 'Not an extension context' }); 1781 1781 } 1782 - return ipcRenderer.invoke('extension-settings-set', { extId, settings }); 1782 + return ipcRenderer.invoke('feature-settings-set', { extId, settings }); 1783 1783 }, 1784 1784 1785 1785 /** ··· 1792 1792 if (!extId) { 1793 1793 return Promise.resolve({ success: false, error: 'Not an extension context' }); 1794 1794 } 1795 - return ipcRenderer.invoke('extension-settings-get-key', { extId, key }); 1795 + return ipcRenderer.invoke('feature-settings-get-key', { extId, key }); 1796 1796 }, 1797 1797 1798 1798 /** ··· 1806 1806 if (!extId) { 1807 1807 return Promise.resolve({ success: false, error: 'Not an extension context' }); 1808 1808 } 1809 - return ipcRenderer.invoke('extension-settings-set-key', { extId, key, value }); 1809 + return ipcRenderer.invoke('feature-settings-set-key', { extId, key, value }); 1810 1810 }, 1811 1811 1812 1812 /** ··· 1820 1820 if (!extId || !key) { 1821 1821 return Promise.resolve({ success: false, error: 'extId and key are required' }); 1822 1822 } 1823 - return ipcRenderer.invoke('extension-settings-get-key', { extId, key }); 1823 + return ipcRenderer.invoke('feature-settings-get-key', { extId, key }); 1824 1824 } 1825 1825 }; 1826 1826
+7 -7
sync/adapters/indexeddb.js
··· 84 84 85 85 database.createObjectStore('settings', { keyPath: 'key' }); 86 86 87 - // extension_settings for profile-specific settings 88 - const extSettings = database.createObjectStore('extension_settings', { keyPath: 'id' }); 89 - extSettings.createIndex('extensionId', 'extensionId', { unique: false }); 87 + // feature_settings for profile-specific settings 88 + const extSettings = database.createObjectStore('feature_settings', { keyPath: 'id' }); 89 + extSettings.createIndex('featureId', 'featureId', { unique: false }); 90 90 } 91 91 92 92 if (oldVersion >= 1 && oldVersion < 2) { ··· 115 115 } 116 116 117 117 if (oldVersion >= 1 && oldVersion < 3) { 118 - // v2 → v3: add extension_settings store 119 - if (!database.objectStoreNames.contains('extension_settings')) { 120 - const extSettings = database.createObjectStore('extension_settings', { keyPath: 'id' }); 121 - extSettings.createIndex('extensionId', 'extensionId', { unique: false }); 118 + // v2 → v3: add feature_settings store 119 + if (!database.objectStoreNames.contains('feature_settings')) { 120 + const extSettings = database.createObjectStore('feature_settings', { keyPath: 'id' }); 121 + extSettings.createIndex('featureId', 'featureId', { unique: false }); 122 122 } 123 123 } 124 124 };
+1 -1
sync/sync.js
··· 5 5 * Runtime-agnostic — uses fetch() (available in Node 18+, all browsers, Tauri WebView). 6 6 * 7 7 * Config is provided via callbacks so each runtime can store it however it wants 8 - * (chrome.storage.local, SQLite extension_settings, env vars, etc.). 8 + * (chrome.storage.local, SQLite feature_settings, env vars, etc.). 9 9 */ 10 10 11 11 import { DATASTORE_VERSION, PROTOCOL_VERSION } from './version.js';
+34 -34
tests/desktop/smoke.spec.ts
··· 1185 1185 // Save peeks items 1186 1186 const savePeeksResult = await bgWindow.evaluate(async (items) => { 1187 1187 const api = (window as any).app; 1188 - return await api.datastore.setRow('extension_settings', 'peeks:items', { 1189 - extensionId: 'peeks', 1188 + return await api.datastore.setRow('feature_settings', 'peeks:items', { 1189 + featureId: 'peeks', 1190 1190 key: 'items', 1191 1191 value: JSON.stringify(items), 1192 1192 updatedAt: Date.now() ··· 1197 1197 // Save slides items 1198 1198 const saveSlidesResult = await bgWindow.evaluate(async (items) => { 1199 1199 const api = (window as any).app; 1200 - return await api.datastore.setRow('extension_settings', 'slides:items', { 1201 - extensionId: 'slides', 1200 + return await api.datastore.setRow('feature_settings', 'slides:items', { 1201 + featureId: 'slides', 1202 1202 key: 'items', 1203 1203 value: JSON.stringify(items), 1204 1204 updatedAt: Date.now() ··· 1209 1209 // Save prefs 1210 1210 const savePeeksPrefs = await bgWindow.evaluate(async () => { 1211 1211 const api = (window as any).app; 1212 - return await api.datastore.setRow('extension_settings', 'peeks:prefs', { 1213 - extensionId: 'peeks', 1212 + return await api.datastore.setRow('feature_settings', 'peeks:prefs', { 1213 + featureId: 'peeks', 1214 1214 key: 'prefs', 1215 1215 value: JSON.stringify({ shortcutKeyPrefix: 'Option+' }), 1216 1216 updatedAt: Date.now() ··· 1220 1220 1221 1221 const saveSlidesPrefs = await bgWindow.evaluate(async () => { 1222 1222 const api = (window as any).app; 1223 - return await api.datastore.setRow('extension_settings', 'slides:prefs', { 1224 - extensionId: 'slides', 1223 + return await api.datastore.setRow('feature_settings', 'slides:prefs', { 1224 + featureId: 'slides', 1225 1225 key: 'prefs', 1226 1226 value: JSON.stringify({ defaultPosition: 'right', defaultSize: 350 }), 1227 1227 updatedAt: Date.now() ··· 1284 1284 // --- Verify Peeks and Slides --- 1285 1285 const persistedSettings = await bgWindow.evaluate(async () => { 1286 1286 const api = (window as any).app; 1287 - return await api.datastore.getTable('extension_settings'); 1287 + return await api.datastore.getTable('feature_settings'); 1288 1288 }); 1289 1289 expect(persistedSettings.success).toBe(true); 1290 1290 ··· 1293 1293 // Peeks items 1294 1294 const peeksItems = settingsData['peeks:items']; 1295 1295 expect(peeksItems).toBeTruthy(); 1296 - expect(peeksItems.extensionId).toBe('peeks'); 1296 + expect(peeksItems.featureId).toBe('peeks'); 1297 1297 const parsedPeeks = JSON.parse(peeksItems.value); 1298 1298 expect(parsedPeeks.length).toBe(3); 1299 1299 expect(parsedPeeks[0].title).toBe('Test Peek 1'); ··· 4462 4462 // Note: extension-settings-set stores with id format ${extId}_${key} 4463 4463 const persistResult = await settingsBgWindow.evaluate(async (expectedShortcut) => { 4464 4464 const api = (window as any).app; 4465 - const stored = await api.datastore.getRow('extension_settings', 'cmd_prefs'); 4465 + const stored = await api.datastore.getRow('feature_settings', 'cmd_prefs'); 4466 4466 4467 4467 if (!stored.success || !stored.data?.value) { 4468 4468 return { success: false, error: 'No stored settings found', stored }; ··· 4501 4501 // Store custom settings (using format ${extId}_${key} to match extension-settings-set handler) 4502 4502 const saveResult = await setupWindow.evaluate(async (shortcut) => { 4503 4503 const api = (window as any).app; 4504 - return await api.datastore.setRow('extension_settings', 'cmd_prefs', { 4505 - extensionId: 'cmd', 4504 + return await api.datastore.setRow('feature_settings', 'cmd_prefs', { 4505 + featureId: 'cmd', 4506 4506 key: 'prefs', 4507 4507 value: JSON.stringify({ shortcutKey: shortcut }), 4508 4508 updatedAt: Date.now() ··· 4796 4796 const api = (window as any).app; 4797 4797 4798 4798 // Get current prefs 4799 - const prefsResult = await api.datastore.getTable('extension_settings'); 4799 + const prefsResult = await api.datastore.getTable('feature_settings'); 4800 4800 const corePrefsRow = Object.values(prefsResult.data || {}).find( 4801 - (r: any) => r.extensionId === 'core' && r.key === 'prefs' 4801 + (r: any) => r.featureId === 'core' && r.key === 'prefs' 4802 4802 ) as any; 4803 4803 const originalPrefs = corePrefsRow ? JSON.parse(corePrefsRow.value) : {}; 4804 4804 4805 4805 // Set backupDir in core prefs 4806 4806 const newPrefs = { ...originalPrefs, backupDir }; 4807 - await api.datastore.setRow('extension_settings', 'core:prefs', { 4808 - extensionId: 'core', 4807 + await api.datastore.setRow('feature_settings', 'core:prefs', { 4808 + featureId: 'core', 4809 4809 key: 'prefs', 4810 4810 value: JSON.stringify(newPrefs), 4811 4811 updatedAt: Date.now() ··· 4843 4843 // Restore original prefs 4844 4844 await bgWindow.evaluate(async (originalPrefs: Record<string, unknown>) => { 4845 4845 const api = (window as any).app; 4846 - await api.datastore.setRow('extension_settings', 'core:prefs', { 4847 - extensionId: 'core', 4846 + await api.datastore.setRow('feature_settings', 'core:prefs', { 4847 + featureId: 'core', 4848 4848 key: 'prefs', 4849 4849 value: JSON.stringify(originalPrefs), 4850 4850 updatedAt: Date.now() ··· 5455 5455 const scriptId = `script_test_${Date.now()}`; 5456 5456 5457 5457 // Get current settings from datastore 5458 - const settingsTable = await api.datastore.getTable('extension_settings'); 5458 + const settingsTable = await api.datastore.getTable('feature_settings'); 5459 5459 const scriptsRow = settingsTable.data?.['scripts:scripts']; 5460 5460 const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5461 5461 ··· 5477 5477 scripts.push(newScript); 5478 5478 5479 5479 // Save back to datastore 5480 - await api.datastore.setRow('extension_settings', 'scripts:scripts', { 5481 - extensionId: 'scripts', 5480 + await api.datastore.setRow('feature_settings', 'scripts:scripts', { 5481 + featureId: 'scripts', 5482 5482 key: 'scripts', 5483 5483 value: JSON.stringify(scripts), 5484 5484 updatedAt: Date.now() ··· 5492 5492 // Verify script was saved 5493 5493 const savedScript = await sharedBgWindow.evaluate(async (scriptId) => { 5494 5494 const api = (window as any).app; 5495 - const settingsTable = await api.datastore.getTable('extension_settings'); 5495 + const settingsTable = await api.datastore.getTable('feature_settings'); 5496 5496 const scriptsRow = settingsTable.data?.['scripts:scripts']; 5497 5497 const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5498 5498 return scripts.find((s: any) => s.id === scriptId); ··· 5507 5507 const { scriptExecutor } = await import('peek://ext/scripts/script-executor.js'); 5508 5508 5509 5509 // Get the script from datastore 5510 - const settingsTable = await api.datastore.getTable('extension_settings'); 5510 + const settingsTable = await api.datastore.getTable('feature_settings'); 5511 5511 const scriptsRow = settingsTable.data?.['scripts:scripts']; 5512 5512 const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5513 5513 const script = scripts.find((s: any) => s.id === scriptId); ··· 5532 5532 // Clean up - delete script 5533 5533 await sharedBgWindow.evaluate(async (scriptId) => { 5534 5534 const api = (window as any).app; 5535 - const settingsTable = await api.datastore.getTable('extension_settings'); 5535 + const settingsTable = await api.datastore.getTable('feature_settings'); 5536 5536 const scriptsRow = settingsTable.data?.['scripts:scripts']; 5537 5537 const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5538 5538 const filtered = scripts.filter((s: any) => s.id !== scriptId); 5539 - await api.datastore.setRow('extension_settings', 'scripts:scripts', { 5540 - extensionId: 'scripts', 5539 + await api.datastore.setRow('feature_settings', 'scripts:scripts', { 5540 + featureId: 'scripts', 5541 5541 key: 'scripts', 5542 5542 value: JSON.stringify(filtered), 5543 5543 updatedAt: Date.now() ··· 5573 5573 const scriptId = `script_timeout_test_${Date.now()}`; 5574 5574 5575 5575 // Get current settings from datastore 5576 - const settingsTable = await api.datastore.getTable('extension_settings'); 5576 + const settingsTable = await api.datastore.getTable('feature_settings'); 5577 5577 const scriptsRow = settingsTable.data?.['scripts:scripts']; 5578 5578 const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5579 5579 ··· 5592 5592 }; 5593 5593 5594 5594 scripts.push(newScript); 5595 - await api.datastore.setRow('extension_settings', 'scripts:scripts', { 5596 - extensionId: 'scripts', 5595 + await api.datastore.setRow('feature_settings', 'scripts:scripts', { 5596 + featureId: 'scripts', 5597 5597 key: 'scripts', 5598 5598 value: JSON.stringify(scripts), 5599 5599 updatedAt: Date.now() ··· 5608 5608 const { scriptExecutor } = await import('peek://ext/scripts/script-executor.js'); 5609 5609 5610 5610 // Get the script from datastore 5611 - const settingsTable = await api.datastore.getTable('extension_settings'); 5611 + const settingsTable = await api.datastore.getTable('feature_settings'); 5612 5612 const scriptsRow = settingsTable.data?.['scripts:scripts']; 5613 5613 const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5614 5614 const script = scripts.find((s: any) => s.id === scriptId); ··· 5635 5635 // Clean up 5636 5636 await sharedBgWindow.evaluate(async (scriptId) => { 5637 5637 const api = (window as any).app; 5638 - const settingsTable = await api.datastore.getTable('extension_settings'); 5638 + const settingsTable = await api.datastore.getTable('feature_settings'); 5639 5639 const scriptsRow = settingsTable.data?.['scripts:scripts']; 5640 5640 const scripts = scriptsRow ? JSON.parse(scriptsRow.value) : []; 5641 5641 const filtered = scripts.filter((s: any) => s.id !== scriptId); 5642 - await api.datastore.setRow('extension_settings', 'scripts:scripts', { 5643 - extensionId: 'scripts', 5642 + await api.datastore.setRow('feature_settings', 'scripts:scripts', { 5643 + featureId: 'scripts', 5644 5644 key: 'scripts', 5645 5645 value: JSON.stringify(filtered), 5646 5646 updatedAt: Date.now()
+2 -2
tests/mocks/tauri-backend.js
··· 304 304 case 'tags': 305 305 store.tags.forEach((v, k) => data[k] = v); 306 306 break; 307 - case 'extension_settings': 307 + case 'feature_settings': 308 308 store.extensionSettings.forEach((v, k) => data[k] = v); 309 309 break; 310 310 default: ··· 316 316 317 317 setRow: async (tableName, rowId, rowData) => { 318 318 switch (tableName) { 319 - case 'extension_settings': 319 + case 'feature_settings': 320 320 store.extensionSettings.set(rowId, rowData); 321 321 break; 322 322 case 'addresses':