experiments in a post-browser web
10
fork

Configure Feed

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

more extension files, and move command registration to api, port groups commands

+1277 -167
+66
app/cmd/commands.js
··· 32 32 } 33 33 34 34 /** 35 + * Removes a command from the registry 36 + * @param {string} name - The command name to remove 37 + */ 38 + function removeCommand(name) { 39 + if (commands[name]) { 40 + delete commands[name]; 41 + onCommandsUpdated(); 42 + console.log('Command removed:', name); 43 + } 44 + } 45 + 46 + /** 35 47 * Initializes all command sources and registers them 36 48 */ 37 49 async function initializeCommandSources() { ··· 56 68 57 69 // Initialize commands when the DOM is loaded 58 70 window.addEventListener('DOMContentLoaded', () => initializeCommandSources()); 71 + 72 + /** 73 + * Create a proxy command that publishes execution back to the registering extension 74 + */ 75 + function createProxyCommand(cmdData) { 76 + return { 77 + name: cmdData.name, 78 + description: cmdData.description || '', 79 + source: cmdData.source, 80 + execute: async (ctx) => { 81 + // Publish execution request to the extension that registered this command 82 + // Use GLOBAL scope so it reaches the extension in background.html 83 + api.publish(`cmd:execute:${cmdData.name}`, ctx, api.scopes.GLOBAL); 84 + } 85 + }; 86 + } 87 + 88 + /** 89 + * Subscribe to dynamic command registration from extensions 90 + */ 91 + function initializeCommandRegistration() { 92 + // Listen for response to our query 93 + api.subscribe('cmd:query-commands-response', (msg) => { 94 + console.log('cmd:query-commands-response received', msg.commands?.length, 'commands'); 95 + if (msg.commands) { 96 + msg.commands.forEach(cmdData => { 97 + const command = createProxyCommand(cmdData); 98 + addCommand(command); 99 + }); 100 + } 101 + }, api.scopes.GLOBAL); 102 + 103 + // Also listen for new registrations while panel is open 104 + api.subscribe('cmd:register', (msg) => { 105 + console.log('cmd:register received (live)', msg); 106 + const command = createProxyCommand(msg); 107 + addCommand(command); 108 + }, api.scopes.GLOBAL); 109 + 110 + // Listen for unregistrations while panel is open 111 + api.subscribe('cmd:unregister', (msg) => { 112 + console.log('cmd:unregister received', msg); 113 + removeCommand(msg.name); 114 + }, api.scopes.GLOBAL); 115 + 116 + // Query the background process for currently registered commands 117 + console.log('Querying for registered commands...'); 118 + api.publish('cmd:query-commands', {}, api.scopes.GLOBAL); 119 + 120 + console.log('Command registration listeners initialized'); 121 + } 122 + 123 + // Initialize command registration listeners 124 + initializeCommandRegistration(); 59 125 60 126 /** 61 127 * Helper function for notifications (currently unused)
-164
app/cmd/commands/groups.js
··· 1 - /** 2 - * Groups command - manage groups (tags) and their addresses 3 - * Groups are implemented as tags in the datastore 4 - */ 5 - import windows from '../../windows.js'; 6 - import api from '../../api.js'; 7 - 8 - const GROUPS_ADDRESS = 'peek://ext/groups/home.html'; 9 - 10 - /** 11 - * Helper to get or create an address for a URI 12 - */ 13 - const getOrCreateAddress = async (uri) => { 14 - const result = await api.datastore.queryAddresses({}); 15 - if (!result.success) return null; 16 - 17 - const existing = result.data.find(addr => addr.uri === uri); 18 - if (existing) return existing; 19 - 20 - // Create new address 21 - const addResult = await api.datastore.addAddress(uri, {}); 22 - if (!addResult.success) return null; 23 - 24 - return { id: addResult.id, uri, tags: '' }; 25 - }; 26 - 27 - /** 28 - * Get all tags (groups) sorted by frecency 29 - */ 30 - const getAllGroups = async () => { 31 - const result = await api.datastore.getTagsByFrecency(); 32 - if (!result.success) return []; 33 - return result.data; 34 - }; 35 - 36 - /** 37 - * Save current windows to a group (tag) 38 - */ 39 - const saveToGroup = async (groupName) => { 40 - console.log('Saving to group:', groupName); 41 - 42 - // Get or create the tag 43 - const tagResult = await api.datastore.getOrCreateTag(groupName); 44 - if (!tagResult.success) { 45 - console.error('Failed to get/create tag:', tagResult.error); 46 - return { success: false, error: tagResult.error }; 47 - } 48 - 49 - const tagId = tagResult.data.id; 50 - 51 - // Get all open windows (excluding internal peek:// URLs) 52 - const listResult = await api.window.list({ includeInternal: false }); 53 - if (!listResult.success || listResult.windows.length === 0) { 54 - console.log('No windows to save'); 55 - return { success: false, error: 'No windows to save' }; 56 - } 57 - 58 - let savedCount = 0; 59 - 60 - for (const win of listResult.windows) { 61 - const addr = await getOrCreateAddress(win.url); 62 - if (addr) { 63 - const linkResult = await api.datastore.tagAddress(addr.id, tagId); 64 - if (linkResult.success && !linkResult.alreadyExists) { 65 - savedCount++; 66 - } 67 - } 68 - } 69 - 70 - console.log(`Saved ${savedCount} addresses to group "${groupName}"`); 71 - return { success: true, count: savedCount, total: listResult.windows.length }; 72 - }; 73 - 74 - /** 75 - * Open all addresses in a group (tag) 76 - */ 77 - const openGroup = async (groupName) => { 78 - console.log('Opening group:', groupName); 79 - 80 - // Find the tag by name 81 - const tagsResult = await api.datastore.getTagsByFrecency(); 82 - if (!tagsResult.success) { 83 - return { success: false, error: 'Failed to get tags' }; 84 - } 85 - 86 - const tag = tagsResult.data.find(t => t.name.toLowerCase() === groupName.toLowerCase()); 87 - if (!tag) { 88 - console.log('Group not found:', groupName); 89 - return { success: false, error: 'Group not found' }; 90 - } 91 - 92 - // Get addresses with this tag 93 - const addressesResult = await api.datastore.getAddressesByTag(tag.id); 94 - if (!addressesResult.success || addressesResult.data.length === 0) { 95 - console.log('No addresses in group:', groupName); 96 - return { success: false, error: 'Group is empty' }; 97 - } 98 - 99 - for (const addr of addressesResult.data) { 100 - await windows.createWindow(addr.uri, { 101 - trackingSource: 'cmd', 102 - trackingSourceId: `group:${groupName}` 103 - }); 104 - } 105 - 106 - console.log(`Opened ${addressesResult.data.length} windows from group "${groupName}"`); 107 - return { success: true, count: addressesResult.data.length }; 108 - }; 109 - 110 - // Commands 111 - const commands = [ 112 - { 113 - name: 'groups', 114 - description: 'Open the groups manager', 115 - async execute(ctx) { 116 - console.log('Opening groups manager'); 117 - await windows.createWindow(GROUPS_ADDRESS, { 118 - width: 800, 119 - height: 600, 120 - trackingSource: 'cmd', 121 - trackingSourceId: 'groups', 122 - escapeMode: 'navigate' // Allow internal navigation before closing 123 - }); 124 - } 125 - }, 126 - { 127 - name: 'save group', 128 - description: 'Save open windows to a group', 129 - async execute(ctx) { 130 - if (ctx.search) { 131 - const groupName = ctx.search.trim(); 132 - const result = await saveToGroup(groupName); 133 - if (result.success) { 134 - console.log(`Saved ${result.count} of ${result.total} windows to "${groupName}"`); 135 - } 136 - } else { 137 - console.log('Usage: save group <name>'); 138 - } 139 - } 140 - }, 141 - { 142 - name: 'open group', 143 - description: 'Open all addresses in a group', 144 - async execute(ctx) { 145 - if (ctx.search) { 146 - const groupName = ctx.search.trim(); 147 - await openGroup(groupName); 148 - } else { 149 - // Show available groups 150 - const groups = await getAllGroups(); 151 - if (groups.length === 0) { 152 - console.log('No groups saved yet. Use "save group <name>" to create one.'); 153 - } else { 154 - console.log('Available groups:'); 155 - groups.forEach(g => console.log(' -', g.name)); 156 - } 157 - } 158 - } 159 - } 160 - ]; 161 - 162 - export default { 163 - commands 164 - };
+2 -2
app/cmd/commands/index.js
··· 1 1 /** 2 2 * Commands module - exports all available commands 3 + * Note: groups commands are now provided by the groups extension 3 4 */ 4 5 import openCommand from './open.js'; 5 6 import debugCommand from './debug.js'; 6 7 import modalCommand from './modal.js'; 7 - import groupsModule from './groups.js'; 8 8 import noteModule from './note.js'; 9 9 import historyModule from './history.js'; 10 10 import tagModule from './tag.js'; ··· 24 24 import emailCommand from './email.js'; 25 25 26 26 // Active commands - only these will be loaded 27 + // Note: groups commands are dynamically registered by the groups extension 27 28 const activeCommands = [ 28 29 openCommand, 29 30 debugCommand, 30 31 modalCommand, 31 - ...groupsModule.commands, 32 32 ...noteModule.commands, 33 33 ...historyModule.commands, 34 34 ...tagModule.commands
+40
app/cmd/index.js
··· 12 12 13 13 const address = 'peek://app/cmd/panel.html'; 14 14 15 + // ===== Dynamic Command Registry ===== 16 + // Commands registered by extensions are stored here in the background process 17 + // The panel queries this registry when it opens 18 + 19 + const dynamicCommands = new Map(); 20 + 21 + /** 22 + * Initialize command registration listeners 23 + * Extensions publish cmd:register to add commands, cmd:unregister to remove 24 + */ 25 + const initCommandRegistry = () => { 26 + // Listen for command registrations from extensions 27 + api.subscribe('cmd:register', (msg) => { 28 + console.log('[cmd] cmd:register received:', msg.name); 29 + dynamicCommands.set(msg.name, { 30 + name: msg.name, 31 + description: msg.description || '', 32 + source: msg.source 33 + }); 34 + }, api.scopes.GLOBAL); 35 + 36 + // Listen for command unregistrations 37 + api.subscribe('cmd:unregister', (msg) => { 38 + console.log('[cmd] cmd:unregister received:', msg.name); 39 + dynamicCommands.delete(msg.name); 40 + }, api.scopes.GLOBAL); 41 + 42 + // Respond to queries for registered commands from the panel 43 + api.subscribe('cmd:query-commands', (msg) => { 44 + console.log('[cmd] cmd:query-commands received'); 45 + const commands = Array.from(dynamicCommands.values()); 46 + api.publish('cmd:query-commands-response', { commands }, api.scopes.GLOBAL); 47 + }, api.scopes.GLOBAL); 48 + 49 + console.log('[cmd] Command registry initialized'); 50 + }; 51 + 15 52 const openInputWindow = prefs => { 16 53 const height = prefs.height || 50; 17 54 const width = prefs.width || 600; ··· 70 107 console.log('init'); 71 108 72 109 const prefs = () => store.get(storageKeys.PREFS); 110 + 111 + // Initialize command registry before shortcuts so extensions can register 112 + initCommandRegistry(); 73 113 74 114 initShortcut(prefs()); 75 115 };
+1 -1
app/config.js
··· 124 124 name: 'Cmd', 125 125 description: 'Command entry', 126 126 start_url: 'peek://app/cmd/background.html', 127 - enabled: false, 127 + enabled: true, 128 128 settings_url: 'peek://app/cmd/settings.html', 129 129 }, 130 130 { id: '82de735f-a4b7-4fe6-a458-ec29939ae00d',
+242
extensions/groups/background.js
··· 1 + // Groups extension background script 2 + // This runs in the core background context and registers the extension 3 + 4 + import { id, labels, schemas, storageKeys, defaults } from './config.js'; 5 + // Use absolute peek:// URLs since relative paths stay within the ext host 6 + import { openStore } from "peek://app/utils.js"; 7 + import windows from "peek://app/windows.js"; 8 + 9 + const api = window.app; 10 + const debug = api.debug; 11 + const clear = false; 12 + 13 + const store = openStore(id, defaults, clear /* clear storage */); 14 + 15 + // Extension content is served from peek://ext/groups/ 16 + const address = 'peek://ext/groups/home.html'; 17 + 18 + const openGroupsWindow = () => { 19 + const height = 600; 20 + const width = 800; 21 + 22 + const params = { 23 + key: address, 24 + height, 25 + width, 26 + escapeMode: 'navigate', // Allow internal navigation before closing 27 + trackingSource: 'cmd', 28 + trackingSourceId: 'groups' 29 + }; 30 + 31 + windows.createWindow(address, params) 32 + .then(window => { 33 + debug && console.log('Groups window opened:', window); 34 + }) 35 + .catch(error => { 36 + console.error('Failed to open groups window:', error); 37 + }); 38 + }; 39 + 40 + // ===== Command helpers (moved from app/cmd/commands/groups.js) ===== 41 + 42 + /** 43 + * Helper to get or create an address for a URI 44 + */ 45 + const getOrCreateAddress = async (uri) => { 46 + const result = await api.datastore.queryAddresses({}); 47 + if (!result.success) return null; 48 + 49 + const existing = result.data.find(addr => addr.uri === uri); 50 + if (existing) return existing; 51 + 52 + // Create new address 53 + const addResult = await api.datastore.addAddress(uri, {}); 54 + if (!addResult.success) return null; 55 + 56 + return { id: addResult.id, uri, tags: '' }; 57 + }; 58 + 59 + /** 60 + * Get all tags (groups) sorted by frecency 61 + */ 62 + const getAllGroups = async () => { 63 + const result = await api.datastore.getTagsByFrecency(); 64 + if (!result.success) return []; 65 + return result.data; 66 + }; 67 + 68 + /** 69 + * Save current windows to a group (tag) 70 + */ 71 + const saveToGroup = async (groupName) => { 72 + console.log('[ext:groups] Saving to group:', groupName); 73 + 74 + // Get or create the tag 75 + const tagResult = await api.datastore.getOrCreateTag(groupName); 76 + if (!tagResult.success) { 77 + console.error('[ext:groups] Failed to get/create tag:', tagResult.error); 78 + return { success: false, error: tagResult.error }; 79 + } 80 + 81 + const tagId = tagResult.data.id; 82 + 83 + // Get all open windows (excluding internal peek:// URLs) 84 + const listResult = await api.window.list({ includeInternal: false }); 85 + if (!listResult.success || listResult.windows.length === 0) { 86 + console.log('[ext:groups] No windows to save'); 87 + return { success: false, error: 'No windows to save' }; 88 + } 89 + 90 + let savedCount = 0; 91 + 92 + for (const win of listResult.windows) { 93 + const addr = await getOrCreateAddress(win.url); 94 + if (addr) { 95 + const linkResult = await api.datastore.tagAddress(addr.id, tagId); 96 + if (linkResult.success && !linkResult.alreadyExists) { 97 + savedCount++; 98 + } 99 + } 100 + } 101 + 102 + console.log(`[ext:groups] Saved ${savedCount} addresses to group "${groupName}"`); 103 + return { success: true, count: savedCount, total: listResult.windows.length }; 104 + }; 105 + 106 + /** 107 + * Open all addresses in a group (tag) 108 + */ 109 + const openGroup = async (groupName) => { 110 + console.log('[ext:groups] Opening group:', groupName); 111 + 112 + // Find the tag by name 113 + const tagsResult = await api.datastore.getTagsByFrecency(); 114 + if (!tagsResult.success) { 115 + return { success: false, error: 'Failed to get tags' }; 116 + } 117 + 118 + const tag = tagsResult.data.find(t => t.name.toLowerCase() === groupName.toLowerCase()); 119 + if (!tag) { 120 + console.log('[ext:groups] Group not found:', groupName); 121 + return { success: false, error: 'Group not found' }; 122 + } 123 + 124 + // Get addresses with this tag 125 + const addressesResult = await api.datastore.getAddressesByTag(tag.id); 126 + if (!addressesResult.success || addressesResult.data.length === 0) { 127 + console.log('[ext:groups] No addresses in group:', groupName); 128 + return { success: false, error: 'Group is empty' }; 129 + } 130 + 131 + for (const addr of addressesResult.data) { 132 + await windows.createWindow(addr.uri, { 133 + trackingSource: 'cmd', 134 + trackingSourceId: `group:${groupName}` 135 + }); 136 + } 137 + 138 + console.log(`[ext:groups] Opened ${addressesResult.data.length} windows from group "${groupName}"`); 139 + return { success: true, count: addressesResult.data.length }; 140 + }; 141 + 142 + // ===== Command definitions ===== 143 + 144 + const commandDefinitions = [ 145 + { 146 + name: 'groups', 147 + description: 'Open the groups manager', 148 + execute: async (ctx) => { 149 + console.log('[ext:groups] Opening groups manager'); 150 + openGroupsWindow(); 151 + } 152 + }, 153 + { 154 + name: 'save group', 155 + description: 'Save open windows to a group', 156 + execute: async (ctx) => { 157 + if (ctx.search) { 158 + const groupName = ctx.search.trim(); 159 + const result = await saveToGroup(groupName); 160 + if (result.success) { 161 + console.log(`[ext:groups] Saved ${result.count} of ${result.total} windows to "${groupName}"`); 162 + } 163 + } else { 164 + console.log('[ext:groups] Usage: save group <name>'); 165 + } 166 + } 167 + }, 168 + { 169 + name: 'open group', 170 + description: 'Open all addresses in a group', 171 + execute: async (ctx) => { 172 + if (ctx.search) { 173 + const groupName = ctx.search.trim(); 174 + await openGroup(groupName); 175 + } else { 176 + // Show available groups 177 + const groups = await getAllGroups(); 178 + if (groups.length === 0) { 179 + console.log('[ext:groups] No groups saved yet. Use "save group <name>" to create one.'); 180 + } else { 181 + console.log('[ext:groups] Available groups:'); 182 + groups.forEach(g => console.log(' -', g.name)); 183 + } 184 + } 185 + } 186 + } 187 + ]; 188 + 189 + // ===== Registration ===== 190 + 191 + let registeredShortcut = null; 192 + let registeredCommands = []; 193 + 194 + const initShortcut = shortcut => { 195 + api.shortcuts.register(shortcut, () => { 196 + openGroupsWindow(); 197 + }); 198 + registeredShortcut = shortcut; 199 + }; 200 + 201 + const initCommands = () => { 202 + commandDefinitions.forEach(cmd => { 203 + api.commands.register(cmd); 204 + registeredCommands.push(cmd.name); 205 + }); 206 + console.log('[ext:groups] Registered commands:', registeredCommands); 207 + }; 208 + 209 + const uninitCommands = () => { 210 + registeredCommands.forEach(name => { 211 + api.commands.unregister(name); 212 + }); 213 + registeredCommands = []; 214 + console.log('[ext:groups] Unregistered commands'); 215 + }; 216 + 217 + const init = () => { 218 + console.log('[ext:groups] init'); 219 + 220 + const prefs = () => store.get(storageKeys.PREFS); 221 + initShortcut(prefs().shortcutKey); 222 + initCommands(); 223 + }; 224 + 225 + const uninit = () => { 226 + console.log('[ext:groups] uninit'); 227 + if (registeredShortcut) { 228 + api.shortcuts.unregister(registeredShortcut); 229 + registeredShortcut = null; 230 + } 231 + uninitCommands(); 232 + }; 233 + 234 + export default { 235 + defaults, 236 + id, 237 + init, 238 + uninit, 239 + labels, 240 + schemas, 241 + storageKeys 242 + };
+46
extensions/groups/config.js
··· 1 + const id = '82de735f-a4b7-4fe6-a458-ec29939ae00d'; 2 + 3 + const labels = { 4 + name: 'Groups', 5 + prefs: { 6 + shortcutKey: 'Groups shortcut', 7 + } 8 + }; 9 + 10 + const prefsSchema = { 11 + "$schema": "https://json-schema.org/draft/2020-12/schema", 12 + "$id": "peek.groups.prefs.schema.json", 13 + "title": "Groups preferences", 14 + "description": "Peek app Groups user preferences", 15 + "type": "object", 16 + "properties": { 17 + "shortcutKey": { 18 + "description": "Global OS hotkey to open command panel", 19 + "type": "string", 20 + "default": "Option+Space" 21 + }, 22 + }, 23 + "required": [ "shortcutKey"] 24 + }; 25 + 26 + const schemas = { 27 + prefs: prefsSchema, 28 + }; 29 + 30 + const storageKeys = { 31 + PREFS: 'prefs', 32 + }; 33 + 34 + const defaults = { 35 + prefs: { 36 + shortcutKey: 'Option+g' 37 + }, 38 + }; 39 + 40 + export { 41 + id, 42 + labels, 43 + schemas, 44 + storageKeys, 45 + defaults 46 + };
+223
extensions/groups/home.css
··· 1 + * { 2 + box-sizing: border-box; 3 + margin: 0; 4 + padding: 0; 5 + } 6 + 7 + html { 8 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 9 + -webkit-font-smoothing: antialiased; 10 + font-size: 14px; 11 + line-height: 1.5; 12 + } 13 + 14 + body { 15 + background: #1a1a1a; 16 + color: #e0e0e0; 17 + min-height: 100vh; 18 + } 19 + 20 + /* Header */ 21 + .header { 22 + display: flex; 23 + align-items: center; 24 + justify-content: space-between; 25 + padding: 16px 24px; 26 + border-bottom: 1px solid #333; 27 + background: #222; 28 + position: sticky; 29 + top: 0; 30 + z-index: 100; 31 + } 32 + 33 + .header-title { 34 + font-size: 18px; 35 + font-weight: 600; 36 + color: #fff; 37 + flex: 1; 38 + text-align: center; 39 + } 40 + 41 + .back-btn, 42 + .new-group-btn { 43 + padding: 8px 16px; 44 + border: none; 45 + border-radius: 6px; 46 + font-size: 14px; 47 + font-weight: 500; 48 + cursor: pointer; 49 + transition: all 0.15s ease; 50 + } 51 + 52 + .back-btn { 53 + background: #333; 54 + color: #e0e0e0; 55 + } 56 + 57 + .back-btn:hover { 58 + background: #444; 59 + } 60 + 61 + .new-group-btn { 62 + background: #0066cc; 63 + color: #fff; 64 + } 65 + 66 + .new-group-btn:hover { 67 + background: #0077ee; 68 + } 69 + 70 + /* Cards container */ 71 + .cards { 72 + padding: 24px; 73 + display: grid; 74 + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); 75 + gap: 16px; 76 + } 77 + 78 + /* Card base */ 79 + .card { 80 + background: #252525; 81 + border-radius: 8px; 82 + padding: 16px; 83 + cursor: pointer; 84 + transition: all 0.15s ease; 85 + display: flex; 86 + align-items: flex-start; 87 + gap: 12px; 88 + } 89 + 90 + .card:hover { 91 + background: #2a2a2a; 92 + transform: translateY(-2px); 93 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); 94 + } 95 + 96 + .card.selected { 97 + background: #333; 98 + outline: 2px solid #0066cc; 99 + outline-offset: -2px; 100 + } 101 + 102 + .card.selected:hover { 103 + background: #383838; 104 + } 105 + 106 + /* Group card */ 107 + .group-card .color-dot { 108 + width: 12px; 109 + height: 12px; 110 + border-radius: 50%; 111 + flex-shrink: 0; 112 + margin-top: 4px; 113 + } 114 + 115 + /* Address card */ 116 + .address-card .card-favicon { 117 + width: 32px; 118 + height: 32px; 119 + border-radius: 4px; 120 + flex-shrink: 0; 121 + background: #333; 122 + object-fit: contain; 123 + } 124 + 125 + /* Card content */ 126 + .card-content { 127 + flex: 1; 128 + min-width: 0; 129 + } 130 + 131 + .card-title { 132 + font-size: 15px; 133 + font-weight: 600; 134 + color: #fff; 135 + margin-bottom: 4px; 136 + white-space: nowrap; 137 + overflow: hidden; 138 + text-overflow: ellipsis; 139 + } 140 + 141 + .card-url { 142 + font-size: 12px; 143 + color: #888; 144 + white-space: nowrap; 145 + overflow: hidden; 146 + text-overflow: ellipsis; 147 + margin-bottom: 8px; 148 + } 149 + 150 + .card-meta { 151 + font-size: 12px; 152 + color: #666; 153 + } 154 + 155 + /* Empty state */ 156 + .empty-state { 157 + grid-column: 1 / -1; 158 + text-align: center; 159 + padding: 48px 24px; 160 + color: #666; 161 + font-size: 15px; 162 + } 163 + 164 + /* Dark mode support (already dark, but for consistency) */ 165 + @media (prefers-color-scheme: light) { 166 + body { 167 + background: #f5f5f5; 168 + color: #333; 169 + } 170 + 171 + .header { 172 + background: #fff; 173 + border-bottom-color: #e0e0e0; 174 + } 175 + 176 + .header-title { 177 + color: #333; 178 + } 179 + 180 + .back-btn { 181 + background: #e0e0e0; 182 + color: #333; 183 + } 184 + 185 + .back-btn:hover { 186 + background: #d0d0d0; 187 + } 188 + 189 + .card { 190 + background: #fff; 191 + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 192 + } 193 + 194 + .card:hover { 195 + background: #fafafa; 196 + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); 197 + } 198 + 199 + .card.selected { 200 + background: #e8f0fe; 201 + outline: 2px solid #0066cc; 202 + } 203 + 204 + .card.selected:hover { 205 + background: #dbe7fc; 206 + } 207 + 208 + .card-title { 209 + color: #333; 210 + } 211 + 212 + .card-url { 213 + color: #666; 214 + } 215 + 216 + .card-meta { 217 + color: #999; 218 + } 219 + 220 + .address-card .card-favicon { 221 + background: #f0f0f0; 222 + } 223 + }
+20
extensions/groups/home.html
··· 1 + <!DOCTYPE html> 2 + <html> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width,user-scalable=no,initial-scale=1"> 6 + <title>Groups</title> 7 + <link rel="stylesheet" type="text/css" href="home.css"> 8 + </head> 9 + <body> 10 + <header class="header"> 11 + <button class="back-btn" style="display: none;">Back</button> 12 + <h1 class="header-title">Groups</h1> 13 + <button class="new-group-btn">+ New Group</button> 14 + </header> 15 + 16 + <main class="cards"></main> 17 + 18 + <script type="module" src="home.js"></script> 19 + </body> 20 + </html>
+394
extensions/groups/home.js
··· 1 + /** 2 + * Groups - Tag-based grouping of addresses 3 + * 4 + * Groups are implemented using tags: 5 + * - Each "group" is a tag 6 + * - Addresses in a group are addresses tagged with that tag 7 + * - Creating a new group creates a new tag 8 + * - Viewing a group shows all addresses with that tag 9 + */ 10 + 11 + const api = window.app; 12 + const debug = api.debug; 13 + 14 + // View states 15 + const VIEW_GROUPS = 'groups'; 16 + const VIEW_ADDRESSES = 'addresses'; 17 + 18 + // Special pseudo-tag for untagged addresses 19 + const UNTAGGED_GROUP = { 20 + id: '__untagged__', 21 + name: 'Untagged', 22 + color: '#666666', 23 + frequency: 0, 24 + isSpecial: true 25 + }; 26 + 27 + let state = { 28 + view: VIEW_GROUPS, 29 + tags: [], 30 + currentTag: null, 31 + addresses: [], 32 + untaggedCount: 0, 33 + selectedIndex: 0 34 + }; 35 + 36 + // Handle ESC - cooperative escape handling with window manager 37 + // Returns { handled: true } if we navigated internally 38 + // Returns { handled: false } if at root (groups list) and window should close 39 + api.escape.onEscape(() => { 40 + if (state.view === VIEW_ADDRESSES) { 41 + // Navigate back to groups list 42 + showGroups(); 43 + return { handled: true }; 44 + } 45 + // At root (groups list) - let window close 46 + return { handled: false }; 47 + }); 48 + 49 + /** 50 + * Get all cards in the current view 51 + */ 52 + const getCards = () => { 53 + return Array.from(document.querySelectorAll('.cards .card')); 54 + }; 55 + 56 + /** 57 + * Update visual selection on cards 58 + */ 59 + const updateSelection = () => { 60 + const cards = getCards(); 61 + cards.forEach((card, i) => { 62 + card.classList.toggle('selected', i === state.selectedIndex); 63 + }); 64 + 65 + // Scroll selected card into view 66 + const selected = cards[state.selectedIndex]; 67 + if (selected) { 68 + selected.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); 69 + } 70 + }; 71 + 72 + /** 73 + * Activate the currently selected card 74 + */ 75 + const activateSelected = () => { 76 + const cards = getCards(); 77 + const selected = cards[state.selectedIndex]; 78 + if (selected) { 79 + selected.click(); 80 + } 81 + }; 82 + 83 + /** 84 + * Get number of columns in the grid based on card positions 85 + */ 86 + const getGridColumns = (cards) => { 87 + if (cards.length < 2) return 1; 88 + const firstTop = cards[0].getBoundingClientRect().top; 89 + for (let i = 1; i < cards.length; i++) { 90 + if (cards[i].getBoundingClientRect().top !== firstTop) { 91 + return i; 92 + } 93 + } 94 + return cards.length; // All on one row 95 + }; 96 + 97 + /** 98 + * Handle keyboard navigation (vim-style hjkl for grid movement) 99 + */ 100 + const handleKeydown = (e) => { 101 + const cards = getCards(); 102 + if (cards.length === 0) return; 103 + 104 + const cols = getGridColumns(cards); 105 + 106 + switch (e.key) { 107 + case 'j': 108 + case 'ArrowDown': 109 + e.preventDefault(); 110 + if (state.selectedIndex + cols < cards.length) { 111 + state.selectedIndex += cols; 112 + updateSelection(); 113 + } 114 + break; 115 + case 'k': 116 + case 'ArrowUp': 117 + e.preventDefault(); 118 + if (state.selectedIndex - cols >= 0) { 119 + state.selectedIndex -= cols; 120 + updateSelection(); 121 + } 122 + break; 123 + case 'h': 124 + case 'ArrowLeft': 125 + e.preventDefault(); 126 + if (state.selectedIndex > 0) { 127 + state.selectedIndex--; 128 + updateSelection(); 129 + } 130 + break; 131 + case 'l': 132 + case 'ArrowRight': 133 + e.preventDefault(); 134 + if (state.selectedIndex < cards.length - 1) { 135 + state.selectedIndex++; 136 + updateSelection(); 137 + } 138 + break; 139 + case 'Enter': 140 + e.preventDefault(); 141 + activateSelected(); 142 + break; 143 + } 144 + }; 145 + 146 + const init = async () => { 147 + debug && console.log('Groups init'); 148 + 149 + // Load tags from datastore 150 + await loadTags(); 151 + 152 + // Set up event listeners 153 + document.querySelector('.new-group-btn').addEventListener('click', createNewGroup); 154 + document.querySelector('.back-btn').addEventListener('click', showGroups); 155 + 156 + // Keyboard navigation 157 + document.addEventListener('keydown', handleKeydown); 158 + 159 + // Show groups view 160 + showGroups(); 161 + }; 162 + 163 + /** 164 + * Load all tags sorted by frecency, and count untagged addresses 165 + */ 166 + const loadTags = async () => { 167 + const result = await api.datastore.getTagsByFrecency(); 168 + if (result.success) { 169 + state.tags = result.data; 170 + debug && console.log('Loaded tags:', state.tags.length); 171 + } else { 172 + console.error('Failed to load tags:', result.error); 173 + state.tags = []; 174 + } 175 + 176 + // Get count of untagged addresses 177 + const untaggedResult = await api.datastore.getUntaggedAddresses(); 178 + if (untaggedResult.success) { 179 + state.untaggedCount = untaggedResult.data.length; 180 + debug && console.log('Untagged addresses:', state.untaggedCount); 181 + } else { 182 + state.untaggedCount = 0; 183 + } 184 + }; 185 + 186 + /** 187 + * Load addresses for a specific tag 188 + */ 189 + const loadAddressesForTag = async (tagId) => { 190 + const result = await api.datastore.getAddressesByTag(tagId); 191 + if (result.success) { 192 + state.addresses = result.data; 193 + debug && console.log('Loaded addresses for tag:', state.addresses.length); 194 + } else { 195 + console.error('Failed to load addresses:', result.error); 196 + state.addresses = []; 197 + } 198 + }; 199 + 200 + /** 201 + * Create a new group (tag) 202 + */ 203 + const createNewGroup = async () => { 204 + const name = prompt('Enter group name:'); 205 + if (!name || !name.trim()) return; 206 + 207 + const result = await api.datastore.getOrCreateTag(name.trim()); 208 + if (result.success) { 209 + debug && console.log('Created tag:', result.data); 210 + await loadTags(); 211 + showGroups(); 212 + } else { 213 + console.error('Failed to create tag:', result.error); 214 + } 215 + }; 216 + 217 + /** 218 + * Show the groups (tags) view 219 + */ 220 + const showGroups = async () => { 221 + state.view = VIEW_GROUPS; 222 + state.currentTag = null; 223 + 224 + // Refresh tags 225 + await loadTags(); 226 + 227 + // Update UI 228 + document.querySelector('.header-title').textContent = 'Groups'; 229 + document.querySelector('.back-btn').style.display = 'none'; 230 + document.querySelector('.new-group-btn').style.display = 'block'; 231 + 232 + // Clear and populate cards 233 + const container = document.querySelector('.cards'); 234 + container.innerHTML = ''; 235 + 236 + // Always show Untagged group first if there are untagged addresses 237 + if (state.untaggedCount > 0) { 238 + const untaggedCard = createGroupCard({ ...UNTAGGED_GROUP, frequency: state.untaggedCount }); 239 + container.appendChild(untaggedCard); 240 + } 241 + 242 + if (state.tags.length === 0 && state.untaggedCount === 0) { 243 + container.innerHTML = '<div class="empty-state">No groups yet. Create one to get started.</div>'; 244 + return; 245 + } 246 + 247 + state.tags.forEach(tag => { 248 + const card = createGroupCard(tag); 249 + container.appendChild(card); 250 + }); 251 + 252 + // Reset selection 253 + state.selectedIndex = 0; 254 + updateSelection(); 255 + }; 256 + 257 + /** 258 + * Show addresses in a group (tag) 259 + */ 260 + const showAddresses = async (tag) => { 261 + state.view = VIEW_ADDRESSES; 262 + state.currentTag = tag; 263 + 264 + // Load addresses - handle special untagged group 265 + if (tag.isSpecial && tag.id === '__untagged__') { 266 + const result = await api.datastore.getUntaggedAddresses(); 267 + if (result.success) { 268 + state.addresses = result.data; 269 + } else { 270 + state.addresses = []; 271 + } 272 + } else { 273 + await loadAddressesForTag(tag.id); 274 + } 275 + 276 + // Update UI 277 + document.querySelector('.header-title').textContent = tag.name; 278 + document.querySelector('.back-btn').style.display = 'block'; 279 + document.querySelector('.new-group-btn').style.display = 'none'; 280 + 281 + // Clear and populate cards 282 + const container = document.querySelector('.cards'); 283 + container.innerHTML = ''; 284 + 285 + if (state.addresses.length === 0) { 286 + container.innerHTML = '<div class="empty-state">No addresses in this group yet.</div>'; 287 + return; 288 + } 289 + 290 + state.addresses.forEach(address => { 291 + const card = createAddressCard(address); 292 + container.appendChild(card); 293 + }); 294 + 295 + // Reset selection 296 + state.selectedIndex = 0; 297 + updateSelection(); 298 + }; 299 + 300 + /** 301 + * Create a card element for a group (tag) 302 + */ 303 + const createGroupCard = (tag) => { 304 + const card = document.createElement('div'); 305 + card.className = 'card group-card'; 306 + if (tag.isSpecial) { 307 + card.classList.add('special-group'); 308 + } 309 + card.dataset.tagId = tag.id; 310 + 311 + const colorDot = document.createElement('div'); 312 + colorDot.className = 'color-dot'; 313 + colorDot.style.backgroundColor = tag.color || '#999'; 314 + 315 + const content = document.createElement('div'); 316 + content.className = 'card-content'; 317 + 318 + const title = document.createElement('h2'); 319 + title.className = 'card-title'; 320 + title.textContent = tag.name; 321 + 322 + const meta = document.createElement('div'); 323 + meta.className = 'card-meta'; 324 + if (tag.isSpecial) { 325 + meta.textContent = `${tag.frequency || 0} addresses`; 326 + } else { 327 + meta.textContent = `Used ${tag.frequency || 0} times`; 328 + } 329 + 330 + content.appendChild(title); 331 + content.appendChild(meta); 332 + 333 + card.appendChild(colorDot); 334 + card.appendChild(content); 335 + 336 + // Click to view addresses in this group 337 + card.addEventListener('click', () => showAddresses(tag)); 338 + 339 + return card; 340 + }; 341 + 342 + /** 343 + * Create a card element for an address 344 + */ 345 + const createAddressCard = (address) => { 346 + const card = document.createElement('div'); 347 + card.className = 'card address-card'; 348 + card.dataset.addressId = address.id; 349 + 350 + const favicon = document.createElement('img'); 351 + favicon.className = 'card-favicon'; 352 + favicon.src = address.favicon || 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; 353 + favicon.onerror = () => { 354 + favicon.src = 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y=".9em" font-size="90">🌐</text></svg>'; 355 + }; 356 + 357 + const content = document.createElement('div'); 358 + content.className = 'card-content'; 359 + 360 + const title = document.createElement('h2'); 361 + title.className = 'card-title'; 362 + title.textContent = address.title || address.uri; 363 + 364 + const url = document.createElement('div'); 365 + url.className = 'card-url'; 366 + url.textContent = address.uri; 367 + 368 + const meta = document.createElement('div'); 369 + meta.className = 'card-meta'; 370 + const lastVisit = address.lastVisitAt ? new Date(address.lastVisitAt).toLocaleDateString() : 'Never'; 371 + meta.textContent = `${address.visitCount || 0} visits · Last: ${lastVisit}`; 372 + 373 + content.appendChild(title); 374 + content.appendChild(url); 375 + content.appendChild(meta); 376 + 377 + card.appendChild(favicon); 378 + card.appendChild(content); 379 + 380 + // Click to open address 381 + card.addEventListener('click', async () => { 382 + debug && console.log('Opening address:', address.uri); 383 + const result = await api.window.open(address.uri, { 384 + width: 800, 385 + height: 600 386 + }); 387 + debug && console.log('Window opened:', result); 388 + }); 389 + 390 + return card; 391 + }; 392 + 393 + // Initialize when DOM is ready 394 + document.addEventListener('DOMContentLoaded', init);
+8
extensions/groups/manifest.json
··· 1 + { 2 + "id": "groups", 3 + "name": "Groups", 4 + "description": "Tag-based grouping of addresses", 5 + "version": "1.0.0", 6 + "background": "background.js", 7 + "builtin": true 8 + }
+154
extensions/loader.js
··· 1 + /** 2 + * Extension Loader 3 + * 4 + * Manages extension lifecycle: loading, unloading, and reloading extensions. 5 + * Runs in the core background context (app/index.js). 6 + */ 7 + 8 + const api = window.app; 9 + const debug = api.debug; 10 + 11 + // Track running extensions: id -> { module, manifest } 12 + const runningExtensions = new Map(); 13 + 14 + /** 15 + * List of built-in extensions bundled with the app. 16 + * External extensions will be loaded from the datastore. 17 + */ 18 + export const builtinExtensions = [ 19 + { 20 + id: 'groups', 21 + path: 'peek://ext/groups', 22 + backgroundScript: 'background.js' 23 + } 24 + // Future: { id: 'peeks', path: 'peek://ext/peeks', backgroundScript: 'background.js' } 25 + // Future: { id: 'slides', path: 'peek://ext/slides', backgroundScript: 'background.js' } 26 + ]; 27 + 28 + /** 29 + * Load a single extension by dynamically importing its background script 30 + */ 31 + export const loadExtension = async (extension) => { 32 + const { id, path, backgroundScript } = extension; 33 + 34 + if (runningExtensions.has(id)) { 35 + debug && console.log(`[ext:loader] Extension ${id} already running`); 36 + return { success: true, alreadyRunning: true }; 37 + } 38 + 39 + try { 40 + debug && console.log(`[ext:loader] Loading extension: ${id}`); 41 + 42 + // Dynamically import the extension's background script 43 + const backgroundUrl = `${path}/${backgroundScript}`; 44 + const module = await import(backgroundUrl); 45 + 46 + // Call init if it exists 47 + if (module.default && typeof module.default.init === 'function') { 48 + module.default.init(); 49 + } 50 + 51 + runningExtensions.set(id, { 52 + module: module.default, 53 + extension 54 + }); 55 + 56 + console.log(`[ext:loader] Extension loaded: ${id}`); 57 + return { success: true }; 58 + 59 + } catch (error) { 60 + console.error(`[ext:loader] Failed to load extension ${id}:`, error); 61 + return { success: false, error: error.message }; 62 + } 63 + }; 64 + 65 + /** 66 + * Unload an extension 67 + */ 68 + export const unloadExtension = async (id) => { 69 + const running = runningExtensions.get(id); 70 + if (!running) { 71 + debug && console.log(`[ext:loader] Extension ${id} not running`); 72 + return { success: true, wasRunning: false }; 73 + } 74 + 75 + try { 76 + debug && console.log(`[ext:loader] Unloading extension: ${id}`); 77 + 78 + // Call uninit if it exists 79 + if (running.module && typeof running.module.uninit === 'function') { 80 + running.module.uninit(); 81 + } 82 + 83 + runningExtensions.delete(id); 84 + console.log(`[ext:loader] Extension unloaded: ${id}`); 85 + return { success: true, wasRunning: true }; 86 + 87 + } catch (error) { 88 + console.error(`[ext:loader] Failed to unload extension ${id}:`, error); 89 + return { success: false, error: error.message }; 90 + } 91 + }; 92 + 93 + /** 94 + * Reload an extension (unload + load) 95 + */ 96 + export const reloadExtension = async (id) => { 97 + const running = runningExtensions.get(id); 98 + if (!running) { 99 + console.log(`[ext:loader] Extension ${id} not running, cannot reload`); 100 + return { success: false, error: 'Extension not running' }; 101 + } 102 + 103 + await unloadExtension(id); 104 + return loadExtension(running.extension); 105 + }; 106 + 107 + /** 108 + * Get list of running extensions 109 + */ 110 + export const getRunningExtensions = () => { 111 + return Array.from(runningExtensions.entries()).map(([id, data]) => ({ 112 + id, 113 + ...data.extension 114 + })); 115 + }; 116 + 117 + /** 118 + * Check if an extension is running 119 + */ 120 + export const isExtensionRunning = (id) => { 121 + return runningExtensions.has(id); 122 + }; 123 + 124 + /** 125 + * Load all enabled built-in extensions. 126 + * Called during app initialization. 127 + * 128 + * @param {Function} isFeatureEnabled - Function to check if a feature is enabled 129 + */ 130 + export const loadBuiltinExtensions = async (isFeatureEnabled) => { 131 + console.log('[ext:loader] Loading built-in extensions...'); 132 + 133 + for (const ext of builtinExtensions) { 134 + // Check if this extension's corresponding feature is enabled 135 + if (isFeatureEnabled && !isFeatureEnabled(ext.id)) { 136 + debug && console.log(`[ext:loader] Extension ${ext.id} is disabled, skipping`); 137 + continue; 138 + } 139 + 140 + await loadExtension(ext); 141 + } 142 + 143 + console.log(`[ext:loader] Loaded ${runningExtensions.size} extensions`); 144 + }; 145 + 146 + export default { 147 + builtinExtensions, 148 + loadExtension, 149 + unloadExtension, 150 + reloadExtension, 151 + getRunningExtensions, 152 + isExtensionRunning, 153 + loadBuiltinExtensions 154 + };
+4
notes/extensibility.md
··· 19 19 - Activate/suspend/reload 20 20 - Click to access settings 21 21 - Peeks, Slides and Groups as built-in but disable-able extensions 22 + - Command registration moves to API, so extensions can call it 23 + - Extension related commands like the groups ones are moved to the extension 24 + - Coarse permissions flag: built-in extensions get full access to api, others are restricted from using the extensions management api (to start) 25 + - Extensions need to register a shortname for use in the peek:// address, conflicts are rejected at install time 22 26 23 27 Note: The implementation will also instigate another shift, moving as much logic into the background web app as possible, vs in node.js space, so we can eventually move to other back-ends than Electron. 24 28
+77
preload.js
··· 284 284 ipcRenderer.send('app-quit', { source: sourceAddress }); 285 285 }; 286 286 287 + // Command registration API for extensions 288 + // Commands are registered via pubsub since cmd runs in renderer 289 + api.commands = { 290 + /** 291 + * Register a command with the cmd palette 292 + * @param {Object} command - Command object with name, description, execute 293 + */ 294 + register: (command) => { 295 + if (!command.name || !command.execute) { 296 + console.error('commands.register: name and execute are required'); 297 + return; 298 + } 299 + 300 + // Store the execute handler locally (can't serialize functions via pubsub) 301 + window._cmdHandlers = window._cmdHandlers || {}; 302 + window._cmdHandlers[command.name] = command.execute; 303 + 304 + // Register the command metadata via pubsub (GLOBAL scope for cross-window) 305 + ipcRenderer.send('publish', { 306 + source: sourceAddress, 307 + scope: 3, // GLOBAL - so cmd panel in separate window receives it 308 + topic: 'cmd:register', 309 + data: { 310 + name: command.name, 311 + description: command.description || '', 312 + source: sourceAddress 313 + } 314 + }); 315 + 316 + // Subscribe to execution requests for this command (GLOBAL scope) 317 + const execTopic = `cmd:execute:${command.name}`; 318 + const replyTopic = `${execTopic}:${rndm()}`; 319 + 320 + ipcRenderer.send('subscribe', { 321 + source: sourceAddress, 322 + scope: 3, 323 + topic: execTopic, 324 + replyTopic 325 + }); 326 + 327 + ipcRenderer.on(replyTopic, async (ev, msg) => { 328 + console.log('cmd:execute', command.name, msg); 329 + const handler = window._cmdHandlers?.[command.name]; 330 + if (handler) { 331 + try { 332 + await handler(msg); 333 + } catch (err) { 334 + console.error('Error executing command', command.name, err); 335 + } 336 + } 337 + }); 338 + 339 + console.log('commands.register:', command.name); 340 + }, 341 + 342 + /** 343 + * Unregister a command from the cmd palette 344 + * @param {string} name - Command name to unregister 345 + */ 346 + unregister: (name) => { 347 + // Remove local handler 348 + if (window._cmdHandlers) { 349 + delete window._cmdHandlers[name]; 350 + } 351 + 352 + // Notify cmd to remove the command (GLOBAL scope for cross-window) 353 + ipcRenderer.send('publish', { 354 + source: sourceAddress, 355 + scope: 3, 356 + topic: 'cmd:unregister', 357 + data: { name } 358 + }); 359 + 360 + console.log('commands.unregister:', name); 361 + } 362 + }; 363 + 287 364 // Escape handling API 288 365 // For windows with escapeMode: 'navigate' or 'auto' 289 366 // Callback should return { handled: true } if escape was handled internally