···3232}
33333434/**
3535+ * Removes a command from the registry
3636+ * @param {string} name - The command name to remove
3737+ */
3838+function removeCommand(name) {
3939+ if (commands[name]) {
4040+ delete commands[name];
4141+ onCommandsUpdated();
4242+ console.log('Command removed:', name);
4343+ }
4444+}
4545+4646+/**
3547 * Initializes all command sources and registers them
3648 */
3749async function initializeCommandSources() {
···56685769// Initialize commands when the DOM is loaded
5870window.addEventListener('DOMContentLoaded', () => initializeCommandSources());
7171+7272+/**
7373+ * Create a proxy command that publishes execution back to the registering extension
7474+ */
7575+function createProxyCommand(cmdData) {
7676+ return {
7777+ name: cmdData.name,
7878+ description: cmdData.description || '',
7979+ source: cmdData.source,
8080+ execute: async (ctx) => {
8181+ // Publish execution request to the extension that registered this command
8282+ // Use GLOBAL scope so it reaches the extension in background.html
8383+ api.publish(`cmd:execute:${cmdData.name}`, ctx, api.scopes.GLOBAL);
8484+ }
8585+ };
8686+}
8787+8888+/**
8989+ * Subscribe to dynamic command registration from extensions
9090+ */
9191+function initializeCommandRegistration() {
9292+ // Listen for response to our query
9393+ api.subscribe('cmd:query-commands-response', (msg) => {
9494+ console.log('cmd:query-commands-response received', msg.commands?.length, 'commands');
9595+ if (msg.commands) {
9696+ msg.commands.forEach(cmdData => {
9797+ const command = createProxyCommand(cmdData);
9898+ addCommand(command);
9999+ });
100100+ }
101101+ }, api.scopes.GLOBAL);
102102+103103+ // Also listen for new registrations while panel is open
104104+ api.subscribe('cmd:register', (msg) => {
105105+ console.log('cmd:register received (live)', msg);
106106+ const command = createProxyCommand(msg);
107107+ addCommand(command);
108108+ }, api.scopes.GLOBAL);
109109+110110+ // Listen for unregistrations while panel is open
111111+ api.subscribe('cmd:unregister', (msg) => {
112112+ console.log('cmd:unregister received', msg);
113113+ removeCommand(msg.name);
114114+ }, api.scopes.GLOBAL);
115115+116116+ // Query the background process for currently registered commands
117117+ console.log('Querying for registered commands...');
118118+ api.publish('cmd:query-commands', {}, api.scopes.GLOBAL);
119119+120120+ console.log('Command registration listeners initialized');
121121+}
122122+123123+// Initialize command registration listeners
124124+initializeCommandRegistration();
5912560126/**
61127 * Helper function for notifications (currently unused)
-164
app/cmd/commands/groups.js
···11-/**
22- * Groups command - manage groups (tags) and their addresses
33- * Groups are implemented as tags in the datastore
44- */
55-import windows from '../../windows.js';
66-import api from '../../api.js';
77-88-const GROUPS_ADDRESS = 'peek://ext/groups/home.html';
99-1010-/**
1111- * Helper to get or create an address for a URI
1212- */
1313-const getOrCreateAddress = async (uri) => {
1414- const result = await api.datastore.queryAddresses({});
1515- if (!result.success) return null;
1616-1717- const existing = result.data.find(addr => addr.uri === uri);
1818- if (existing) return existing;
1919-2020- // Create new address
2121- const addResult = await api.datastore.addAddress(uri, {});
2222- if (!addResult.success) return null;
2323-2424- return { id: addResult.id, uri, tags: '' };
2525-};
2626-2727-/**
2828- * Get all tags (groups) sorted by frecency
2929- */
3030-const getAllGroups = async () => {
3131- const result = await api.datastore.getTagsByFrecency();
3232- if (!result.success) return [];
3333- return result.data;
3434-};
3535-3636-/**
3737- * Save current windows to a group (tag)
3838- */
3939-const saveToGroup = async (groupName) => {
4040- console.log('Saving to group:', groupName);
4141-4242- // Get or create the tag
4343- const tagResult = await api.datastore.getOrCreateTag(groupName);
4444- if (!tagResult.success) {
4545- console.error('Failed to get/create tag:', tagResult.error);
4646- return { success: false, error: tagResult.error };
4747- }
4848-4949- const tagId = tagResult.data.id;
5050-5151- // Get all open windows (excluding internal peek:// URLs)
5252- const listResult = await api.window.list({ includeInternal: false });
5353- if (!listResult.success || listResult.windows.length === 0) {
5454- console.log('No windows to save');
5555- return { success: false, error: 'No windows to save' };
5656- }
5757-5858- let savedCount = 0;
5959-6060- for (const win of listResult.windows) {
6161- const addr = await getOrCreateAddress(win.url);
6262- if (addr) {
6363- const linkResult = await api.datastore.tagAddress(addr.id, tagId);
6464- if (linkResult.success && !linkResult.alreadyExists) {
6565- savedCount++;
6666- }
6767- }
6868- }
6969-7070- console.log(`Saved ${savedCount} addresses to group "${groupName}"`);
7171- return { success: true, count: savedCount, total: listResult.windows.length };
7272-};
7373-7474-/**
7575- * Open all addresses in a group (tag)
7676- */
7777-const openGroup = async (groupName) => {
7878- console.log('Opening group:', groupName);
7979-8080- // Find the tag by name
8181- const tagsResult = await api.datastore.getTagsByFrecency();
8282- if (!tagsResult.success) {
8383- return { success: false, error: 'Failed to get tags' };
8484- }
8585-8686- const tag = tagsResult.data.find(t => t.name.toLowerCase() === groupName.toLowerCase());
8787- if (!tag) {
8888- console.log('Group not found:', groupName);
8989- return { success: false, error: 'Group not found' };
9090- }
9191-9292- // Get addresses with this tag
9393- const addressesResult = await api.datastore.getAddressesByTag(tag.id);
9494- if (!addressesResult.success || addressesResult.data.length === 0) {
9595- console.log('No addresses in group:', groupName);
9696- return { success: false, error: 'Group is empty' };
9797- }
9898-9999- for (const addr of addressesResult.data) {
100100- await windows.createWindow(addr.uri, {
101101- trackingSource: 'cmd',
102102- trackingSourceId: `group:${groupName}`
103103- });
104104- }
105105-106106- console.log(`Opened ${addressesResult.data.length} windows from group "${groupName}"`);
107107- return { success: true, count: addressesResult.data.length };
108108-};
109109-110110-// Commands
111111-const commands = [
112112- {
113113- name: 'groups',
114114- description: 'Open the groups manager',
115115- async execute(ctx) {
116116- console.log('Opening groups manager');
117117- await windows.createWindow(GROUPS_ADDRESS, {
118118- width: 800,
119119- height: 600,
120120- trackingSource: 'cmd',
121121- trackingSourceId: 'groups',
122122- escapeMode: 'navigate' // Allow internal navigation before closing
123123- });
124124- }
125125- },
126126- {
127127- name: 'save group',
128128- description: 'Save open windows to a group',
129129- async execute(ctx) {
130130- if (ctx.search) {
131131- const groupName = ctx.search.trim();
132132- const result = await saveToGroup(groupName);
133133- if (result.success) {
134134- console.log(`Saved ${result.count} of ${result.total} windows to "${groupName}"`);
135135- }
136136- } else {
137137- console.log('Usage: save group <name>');
138138- }
139139- }
140140- },
141141- {
142142- name: 'open group',
143143- description: 'Open all addresses in a group',
144144- async execute(ctx) {
145145- if (ctx.search) {
146146- const groupName = ctx.search.trim();
147147- await openGroup(groupName);
148148- } else {
149149- // Show available groups
150150- const groups = await getAllGroups();
151151- if (groups.length === 0) {
152152- console.log('No groups saved yet. Use "save group <name>" to create one.');
153153- } else {
154154- console.log('Available groups:');
155155- groups.forEach(g => console.log(' -', g.name));
156156- }
157157- }
158158- }
159159- }
160160-];
161161-162162-export default {
163163- commands
164164-};
+2-2
app/cmd/commands/index.js
···11/**
22 * Commands module - exports all available commands
33+ * Note: groups commands are now provided by the groups extension
34 */
45import openCommand from './open.js';
56import debugCommand from './debug.js';
67import modalCommand from './modal.js';
77-import groupsModule from './groups.js';
88import noteModule from './note.js';
99import historyModule from './history.js';
1010import tagModule from './tag.js';
···2424import emailCommand from './email.js';
25252626// Active commands - only these will be loaded
2727+// Note: groups commands are dynamically registered by the groups extension
2728const activeCommands = [
2829 openCommand,
2930 debugCommand,
3031 modalCommand,
3131- ...groupsModule.commands,
3232 ...noteModule.commands,
3333 ...historyModule.commands,
3434 ...tagModule.commands
+40
app/cmd/index.js
···12121313const address = 'peek://app/cmd/panel.html';
14141515+// ===== Dynamic Command Registry =====
1616+// Commands registered by extensions are stored here in the background process
1717+// The panel queries this registry when it opens
1818+1919+const dynamicCommands = new Map();
2020+2121+/**
2222+ * Initialize command registration listeners
2323+ * Extensions publish cmd:register to add commands, cmd:unregister to remove
2424+ */
2525+const initCommandRegistry = () => {
2626+ // Listen for command registrations from extensions
2727+ api.subscribe('cmd:register', (msg) => {
2828+ console.log('[cmd] cmd:register received:', msg.name);
2929+ dynamicCommands.set(msg.name, {
3030+ name: msg.name,
3131+ description: msg.description || '',
3232+ source: msg.source
3333+ });
3434+ }, api.scopes.GLOBAL);
3535+3636+ // Listen for command unregistrations
3737+ api.subscribe('cmd:unregister', (msg) => {
3838+ console.log('[cmd] cmd:unregister received:', msg.name);
3939+ dynamicCommands.delete(msg.name);
4040+ }, api.scopes.GLOBAL);
4141+4242+ // Respond to queries for registered commands from the panel
4343+ api.subscribe('cmd:query-commands', (msg) => {
4444+ console.log('[cmd] cmd:query-commands received');
4545+ const commands = Array.from(dynamicCommands.values());
4646+ api.publish('cmd:query-commands-response', { commands }, api.scopes.GLOBAL);
4747+ }, api.scopes.GLOBAL);
4848+4949+ console.log('[cmd] Command registry initialized');
5050+};
5151+1552const openInputWindow = prefs => {
1653 const height = prefs.height || 50;
1754 const width = prefs.width || 600;
···70107 console.log('init');
7110872109 const prefs = () => store.get(storageKeys.PREFS);
110110+111111+ // Initialize command registry before shortcuts so extensions can register
112112+ initCommandRegistry();
7311374114 initShortcut(prefs());
75115};
···1919 - Activate/suspend/reload
2020 - Click to access settings
2121- Peeks, Slides and Groups as built-in but disable-able extensions
2222+- Command registration moves to API, so extensions can call it
2323+- Extension related commands like the groups ones are moved to the extension
2424+- Coarse permissions flag: built-in extensions get full access to api, others are restricted from using the extensions management api (to start)
2525+- Extensions need to register a shortname for use in the peek:// address, conflicts are rejected at install time
22262327Note: 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.
2428
+77
preload.js
···284284 ipcRenderer.send('app-quit', { source: sourceAddress });
285285};
286286287287+// Command registration API for extensions
288288+// Commands are registered via pubsub since cmd runs in renderer
289289+api.commands = {
290290+ /**
291291+ * Register a command with the cmd palette
292292+ * @param {Object} command - Command object with name, description, execute
293293+ */
294294+ register: (command) => {
295295+ if (!command.name || !command.execute) {
296296+ console.error('commands.register: name and execute are required');
297297+ return;
298298+ }
299299+300300+ // Store the execute handler locally (can't serialize functions via pubsub)
301301+ window._cmdHandlers = window._cmdHandlers || {};
302302+ window._cmdHandlers[command.name] = command.execute;
303303+304304+ // Register the command metadata via pubsub (GLOBAL scope for cross-window)
305305+ ipcRenderer.send('publish', {
306306+ source: sourceAddress,
307307+ scope: 3, // GLOBAL - so cmd panel in separate window receives it
308308+ topic: 'cmd:register',
309309+ data: {
310310+ name: command.name,
311311+ description: command.description || '',
312312+ source: sourceAddress
313313+ }
314314+ });
315315+316316+ // Subscribe to execution requests for this command (GLOBAL scope)
317317+ const execTopic = `cmd:execute:${command.name}`;
318318+ const replyTopic = `${execTopic}:${rndm()}`;
319319+320320+ ipcRenderer.send('subscribe', {
321321+ source: sourceAddress,
322322+ scope: 3,
323323+ topic: execTopic,
324324+ replyTopic
325325+ });
326326+327327+ ipcRenderer.on(replyTopic, async (ev, msg) => {
328328+ console.log('cmd:execute', command.name, msg);
329329+ const handler = window._cmdHandlers?.[command.name];
330330+ if (handler) {
331331+ try {
332332+ await handler(msg);
333333+ } catch (err) {
334334+ console.error('Error executing command', command.name, err);
335335+ }
336336+ }
337337+ });
338338+339339+ console.log('commands.register:', command.name);
340340+ },
341341+342342+ /**
343343+ * Unregister a command from the cmd palette
344344+ * @param {string} name - Command name to unregister
345345+ */
346346+ unregister: (name) => {
347347+ // Remove local handler
348348+ if (window._cmdHandlers) {
349349+ delete window._cmdHandlers[name];
350350+ }
351351+352352+ // Notify cmd to remove the command (GLOBAL scope for cross-window)
353353+ ipcRenderer.send('publish', {
354354+ source: sourceAddress,
355355+ scope: 3,
356356+ topic: 'cmd:unregister',
357357+ data: { name }
358358+ });
359359+360360+ console.log('commands.unregister:', name);
361361+ }
362362+};
363363+287364// Escape handling API
288365// For windows with escapeMode: 'navigate' or 'auto'
289366// Callback should return { handled: true } if escape was handled internally