Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions frontend/src/commands/CommandRuntimeContext.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { createContext, useContext } from 'react';

const CommandRuntimeContext = createContext(null);

export function CommandRuntimeProvider({ runtime, children }) {
return <CommandRuntimeContext.Provider value={runtime}>{children}</CommandRuntimeContext.Provider>;
}

export function useCommandRuntimeContext() {
const runtime = useContext(CommandRuntimeContext);
if (!runtime) throw new Error('useCommandRuntimeContext must be used inside CommandRuntimeProvider');
return runtime;
}
12 changes: 12 additions & 0 deletions frontend/src/commands/CommandRuntimeContext.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';

describe('CommandRuntimeContext source contract', () => {
it('exports one provider and one strict consumer hook', () => {
const source = fs.readFileSync(new URL('./CommandRuntimeContext.jsx', import.meta.url), 'utf8');
assert.match(source, /export function CommandRuntimeProvider/);
assert.match(source, /export function useCommandRuntimeContext/);
assert.match(source, /must be used inside CommandRuntimeProvider/);
});
});
113 changes: 113 additions & 0 deletions frontend/src/commands/appCommands.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
const globalCommand = (id, titleKey, icon, group, executorId, params = {}, overrides = {}) => ({
id, titleKey, aliasKeys: [], icon, group,
defaultKeys: { primary: null, secondary: [] },
rank: { base: 50 }, isAvailable: () => true, targetMode: 'global', executorId, params,
...overrides,
});

const settingsTabs = ['accounts', 'notifications', 'rules', 'categories', 'appearance', 'shortcuts',
'security', 'integrations', 'ai-actions', 'about'];
const adminTabs = ['users', 'sso', 'ai'];
const gtdSections = ['todo', 'watch', 'delegated', 'reference', 'someday'];

export function createAppCommandDefinitions({ accounts = [], folders = {}, themes = {}, user = {} }) {
const definitions = [
globalCommand('compose.new', 'commands.compose.new.title', 'compose', 'compose', 'app.compose', {}, {
aliasKeys: ['commands.compose.new.alias.write'],
defaultKeys: { primary: 'c', secondary: [] }, rank: { base: 100 },
isAvailable: context => context.surface !== 'compose' && context.surface !== 'settings',
}),
globalCommand('navigation.search', 'commands.navigation.search.title', 'search', 'navigation', 'navigation.search', {}, {
defaultKeys: { primary: '/', secondary: [] }, rank: { base: 90 },
isAvailable: context => context.surface !== 'compose' && context.surface !== 'settings',
}),
globalCommand('navigation.contacts', 'commands.navigation.contacts.title', 'contacts', 'navigation', 'navigation.contacts'),
globalCommand('navigation.unified-inbox', 'commands.navigation.unifiedInbox.title', 'inbox', 'navigation', 'navigation.inbox', {
accountId: null, folder: 'INBOX',
}),
];

for (const account of accounts) {
definitions.push(globalCommand(
`navigation.account-inbox.${account.id}`,
'commands.navigation.accountInbox.title',
'inbox', 'navigation', 'navigation.inbox',
{ accountId: account.id, folder: 'INBOX', name: account.name || account.email_address },
));
for (const folder of folders[account.id] || []) {
if (folder.path === 'INBOX') continue;
definitions.push(globalCommand(
`navigation.folder.${account.id}.${folder.path}`,
'commands.navigation.folder.title',
'folder', 'navigation', 'navigation.folder',
{ accountId: account.id, folder: folder.path, name: folder.name || folder.path },
));
}
}

for (const section of gtdSections) {
definitions.push(globalCommand(
`navigation.gtd.${section}`,
`commands.navigation.gtd.${section}.title`,
'gtd', 'navigation', 'navigation.gtd', { section },
{ isAvailable: context => context.gtdAvailable },
));
}
for (const [theme, value] of Object.entries(themes)) {
definitions.push(globalCommand(
`appearance.theme.${theme}`,
'commands.appearance.theme.title',
'appearance', 'appearance', 'appearance.theme', { theme, name: value.label || theme },
));
}
for (const tab of [...settingsTabs, ...(user.isAdmin ? adminTabs : [])]) {
definitions.push(globalCommand(
`settings.${tab}`,
`commands.settings.${tab}.title`,
'settings', 'settings', 'settings.open', { tab },
));
}
return definitions;
}

export function createAppCommandExecutors({ getState, emitShortcut }) {
return Object.freeze({
'app.compose': () => {
const state = getState();
state.openCompose({ accountId: state.selectedAccountId || undefined });
return { status: 'success' };
},
'navigation.search': () => {
emitShortcut('focusSearch');
return { status: 'success' };
},
'navigation.contacts': () => {
getState().setShowContacts(true);
return { status: 'success' };
},
'navigation.inbox': ({ command }) => {
getState().setSelectedAccount(command.params.accountId, 'INBOX');
return { status: 'success' };
},
'navigation.folder': ({ command }) => {
getState().setSelectedAccount(command.params.accountId, command.params.folder);
return { status: 'success' };
},
'navigation.gtd': ({ command }) => {
const state = getState();
state.setSelectedAccount(state.selectedAccountId, 'INBOX');
state.setActiveGtdTab(command.params.section);
return { status: 'success' };
},
'appearance.theme': ({ command }) => {
getState().setTheme(command.params.theme);
return { status: 'success' };
},
'settings.open': ({ command }) => {
const state = getState();
state.setAdminTab(command.params.tab);
state.setShowAdmin(true);
return { status: 'success' };
},
});
}
83 changes: 83 additions & 0 deletions frontend/src/commands/appCommands.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { createCommandContext } from './contracts.js';
import { createCommandRegistry } from './registry.js';
import { createAppCommandDefinitions, createAppCommandExecutors } from './appCommands.js';

const snapshot = {
accounts: [{ id: 'acct-1', name: 'Work', gtd_enabled: true }],
folders: { 'acct-1': [{ path: 'INBOX', name: 'Inbox' }, { path: 'Projects', name: 'Projects' }] },
themes: { system: { label: 'System' }, midnight: { label: 'Midnight' } },
user: { isAdmin: false },
};
const context = createCommandContext({
surface: 'list', activeConversationId: null, selectedConversationIds: [], conversations: [],
accountId: 'acct-1', folder: 'INBOX', draft: null, gtdAvailable: true,
cardDavConnected: false, modal: null, editing: false, platform: 'mac', shortcutOverrides: {},
translate: (key, values) => values?.name || key,
});

describe('application commands', () => {
it('builds Compose, Search, Contacts, unified/account/folder/GTD, theme, and settings commands', () => {
const ids = createAppCommandDefinitions(snapshot).map(command => command.id);
for (const id of [
'compose.new', 'navigation.search', 'navigation.contacts', 'navigation.unified-inbox',
'navigation.account-inbox.acct-1', 'navigation.folder.acct-1.Projects',
'navigation.gtd.todo', 'appearance.theme.system', 'settings.accounts', 'settings.shortcuts',
]) assert.ok(ids.includes(id), `missing ${id}`);
assert.equal(ids.some(id => id.startsWith('mail.')), false);
});

it('keeps administrator-only settings absent for non-admin users', () => {
const userIds = createAppCommandDefinitions(snapshot).map(command => command.id);
assert.equal(userIds.includes('settings.users'), false);
const adminIds = createAppCommandDefinitions({ ...snapshot, user: { isAdmin: true } }).map(command => command.id);
assert.equal(adminIds.includes('settings.users'), true);
assert.equal(adminIds.includes('settings.sso'), true);
assert.equal(adminIds.includes('settings.ai'), true);
});

it('searches account/folder labels through localized dynamic keys', () => {
const registry = createCommandRegistry(createAppCommandDefinitions(snapshot));
assert.equal(registry.search('Projects', context)[0].command.id, 'navigation.folder.acct-1.Projects');
});

it('routes execution through injected state services', async () => {
const calls = [];
const state = {
openCompose: value => calls.push(['compose', value]),
setSelectedAccount: (accountId, folder) => calls.push(['navigate', accountId, folder]),
setShowContacts: value => calls.push(['contacts', value]),
setActiveGtdTab: value => calls.push(['gtd', value]),
setAdminTab: value => calls.push(['tab', value]),
setShowAdmin: value => calls.push(['admin', value]),
setTheme: value => calls.push(['theme', value]),
};
const executors = createAppCommandExecutors({ getState: () => state, emitShortcut: id => calls.push(['shortcut', id]) });
await executors['navigation.folder']({ command: { params: { accountId: 'acct-1', folder: 'Projects' } } });
await executors['settings.open']({ command: { params: { tab: 'appearance' } } });
await executors['navigation.search']({ command: { params: {} } });
assert.deepEqual(calls, [
['navigate', 'acct-1', 'Projects'], ['tab', 'appearance'], ['admin', true], ['shortcut', 'focusSearch'],
]);
});

it('selects a GTD destination after navigation clears the previous tab', async () => {
const state = {
selectedAccountId: 'acct-1',
activeGtdTab: 'watch',
setSelectedAccount(accountId, folder) {
this.selectedAccountId = accountId;
this.selectedFolder = folder;
this.activeGtdTab = null;
},
setActiveGtdTab(section) {
this.activeGtdTab = section;
},
};
const executors = createAppCommandExecutors({ getState: () => state, emitShortcut() {} });
await executors['navigation.gtd']({ command: { params: { section: 'delegated' } } });
assert.equal(state.selectedFolder, 'INBOX');
assert.equal(state.activeGtdTab, 'delegated');
});
});
92 changes: 92 additions & 0 deletions frontend/src/commands/appContext.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { createCommandContext, stableConversationId } from './contracts.js';

export function detectCommandPlatform(navigatorLike = {}) {
const value = navigatorLike.userAgentData?.platform || navigatorLike.platform || '';
if (/mac|iphone|ipad|ipod/i.test(value)) return 'mac';
if (/win/i.test(value)) return 'windows';
return 'linux';
}

function allConversations(state) {
const gtd = Object.values(state.gtdSections || {}).flatMap(section => section?.threads || []);
return [
...(state.messages || []),
...(state.searchResults || []),
...Object.values(state.threadMessages || {}).flat(),
...gtd,
].filter((message, index, all) => {
const id = stableConversationId(message);
return id && all.findIndex(candidate => stableConversationId(candidate) === id) === index;
});
}

export function buildAppCommandContext(state, {
translate,
platform = detectCommandPlatform(globalThis.navigator),
editing = false,
modal = null,
} = {}) {
const conversations = allConversations(state);
const visibleMessages = state.searchQuery?.trim()
? (state.searchResults || [])
: state.activeGtdTab
? (state.gtdSections?.[state.activeGtdTab]?.threads || [])
: (state.messages || []);
const selectedRows = new Set(state.selectedMessageIds || []);
const selectedConversationIds = conversations
.filter(message => selectedRows.has(message.id))
.map(stableConversationId);
const active = conversations.find(message => message.id === state.selectedMessageId);
const targetedMessages = selectedConversationIds.length
? conversations.filter(message => selectedConversationIds.includes(stableConversationId(message)))
: active ? [active] : [];
const targetedAccountIds = [...new Set(targetedMessages.map(message => message.account_id).filter(Boolean))];
const accounts = state.accounts || [];
const gtdAvailable = targetedAccountIds.length
? targetedAccountIds.every(id => accounts.find(account => account.id === id)?.gtd_enabled)
: state.selectedAccountId
? Boolean(accounts.find(account => account.id === state.selectedAccountId)?.gtd_enabled)
: accounts.some(account => account.gtd_enabled);
const surface = modal ? 'picker'
: state.showAdmin ? 'settings'
: state.composing ? 'compose'
: state.selectedMessageId ? 'conversation' : 'list';
const legacyShortcutIds = {
compose: 'compose.new',
focusSearch: 'navigation.search',
goInbox: 'navigation.unified-inbox',
};
const shortcutOverrides = Object.fromEntries(Object.entries(state.shortcuts || {})
.filter(([legacyId]) => legacyShortcutIds[legacyId])
.map(([legacyId, key]) => [legacyShortcutIds[legacyId], key]));

return createCommandContext({
surface,
activeConversationId: stableConversationId(active),
activeMessage: active || null,
selectedConversationIds,
visibleConversationIds: visibleMessages.map(stableConversationId).filter(Boolean),
conversations,
accountId: state.selectedAccountId,
folder: state.selectedFolder,
draft: state.composing ? (state.composeData || { id: 'active-compose' }) : null,
gtdAvailable,
cardDavConnected: Boolean(state.carddavStatus?.connected),
modal,
editing,
undoAvailable: (state.notifications || []).some(notification => typeof notification.onUndo === 'function'),
platform,
shortcutOverrides,
translate,
});
}

export function commandTargetLabel(context) {
if (context.selectedConversationIds.length > 1) {
return { key: 'commandPalette.target.selected', values: { count: context.selectedConversationIds.length } };
}
if (context.selectedConversationIds.length === 1 || context.activeConversationId) {
return { key: 'commandPalette.target.conversation', values: {} };
}
return { key: 'commandPalette.target.application', values: {} };
}
Loading