diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts
new file mode 100644
index 00000000000..4ec2b19c77f
--- /dev/null
+++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.stories.ts
@@ -0,0 +1,220 @@
+import type { StoryFn } from '@storybook/vue3-vite';
+import { action } from 'storybook/actions';
+
+import N8nCommandBar from './CommandBar.vue';
+
+const sampleItems = [
+ // Ungrouped items (appear first)
+ {
+ id: 'recent-workflow-1',
+ title: 'Recent: Customer Sync',
+ icon: { html: '📋' },
+ handler: () => console.log('Opening recent workflow'),
+ },
+ {
+ id: 'recent-workflow-2',
+ title: 'Recent: Email Campaign',
+ icon: { html: '✉️' },
+ handler: () => console.log('Opening recent workflow'),
+ },
+
+ // Actions section
+ {
+ id: 'create-workflow',
+ title: 'Create new workflow',
+ icon: { html: '⚡' },
+ section: 'Actions',
+ handler: () => console.log('Creating new workflow'),
+ },
+ {
+ id: 'import-workflow',
+ title: 'Import workflow',
+ icon: { html: '📥' },
+ section: 'Actions',
+ keywords: ['upload', 'file'],
+ handler: () => console.log('Importing workflow'),
+ },
+ {
+ id: 'duplicate-workflow',
+ title: 'Duplicate current workflow',
+ icon: { html: '📋' },
+ section: 'Actions',
+ handler: () => console.log('Duplicating workflow'),
+ },
+
+ // Navigation section
+ {
+ id: 'workflows',
+ title: 'All Workflows',
+ icon: { html: '📁' },
+ section: 'Navigation',
+ handler: () => console.log('Opening workflows'),
+ },
+ {
+ id: 'executions',
+ title: 'Executions',
+ icon: { html: '🏃' },
+ section: 'Navigation',
+ handler: () => console.log('Opening executions'),
+ },
+ {
+ id: 'credentials',
+ title: 'Credentials',
+ icon: { html: '🔑' },
+ section: 'Navigation',
+ handler: () => console.log('Opening credentials'),
+ },
+
+ // Tools section
+ {
+ id: 'search-nodes',
+ title: 'Search nodes',
+ icon: { html: '🔍' },
+ keywords: ['node', 'add', 'integration'],
+ section: 'Tools',
+ },
+ {
+ id: 'test-webhook',
+ title: 'Test webhook',
+ icon: { html: '🌐' },
+ section: 'Tools',
+ handler: () => console.log('Testing webhook'),
+ },
+
+ // Settings section
+ {
+ id: 'settings',
+ title: 'Settings',
+ icon: {
+ html: ' ',
+ },
+ section: 'Settings',
+ handler: () => console.log('Opening settings'),
+ },
+ {
+ id: 'help',
+ title: 'Help & Documentation',
+ icon: { html: '❓' },
+ section: 'Settings',
+ handler: () => console.log('Opening help'),
+ },
+];
+
+export default {
+ title: 'Molecules/CommandBar',
+ component: N8nCommandBar,
+ argTypes: {
+ placeholder: {
+ control: 'text',
+ },
+ context: {
+ control: 'text',
+ },
+ items: {
+ control: 'object',
+ },
+ },
+ parameters: {
+ backgrounds: { default: '--color-background-light' },
+ },
+};
+
+const Template: StoryFn = (args, { argTypes }) => ({
+ setup: () => ({ args }),
+ props: Object.keys(argTypes),
+ components: {
+ N8nCommandBar,
+ },
+ template:
+ ' ',
+ methods: {
+ onInputChange: action('input-change'),
+ onNavigateTo: action('navigate-to'),
+ onLoadMore: action('load-more'),
+ },
+});
+
+export const Default = Template.bind({});
+Default.args = {
+ placeholder: 'Type a command...',
+ items: [],
+};
+
+export const WithItems = Template.bind({});
+WithItems.args = {
+ placeholder: 'Search commands...',
+ items: sampleItems,
+};
+
+export const WithContext = Template.bind({});
+WithContext.args = {
+ placeholder: 'Search for anything...',
+ context: 'Workflow Editor',
+ items: sampleItems,
+};
+
+export const CustomPlaceholder = Template.bind({});
+CustomPlaceholder.args = {
+ placeholder: 'What would you like to do?',
+ items: sampleItems,
+};
+
+export const KeyboardShortcut: StoryFn = () => ({
+ components: {
+ N8nCommandBar,
+ },
+ data: () => ({
+ items: sampleItems,
+ }),
+ template: `
+
+
+ Press ⌘ + K
+ or Ctrl + K
+ to open the command bar. Use arrow keys to navigate and Enter to select.
+
+
+
+ `,
+ methods: {
+ onInputChange: action('input-change'),
+ onNavigateTo: action('navigate-to'),
+ onLoadMore: action('load-more'),
+ },
+});
+
+export const SectionGrouping: StoryFn = () => ({
+ components: {
+ N8nCommandBar,
+ },
+ data: () => ({
+ items: sampleItems,
+ }),
+ template: `
+
+
+ This example shows how items are grouped by sections:
+ • Recent items (no section) appear first
+ • Then items are grouped by Actions , Navigation , Tools , and Settings sections
+
+
+
+ `,
+ methods: {
+ onInputChange: action('input-change'),
+ onNavigateTo: action('navigate-to'),
+ onLoadMore: action('load-more'),
+ },
+});
diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue
new file mode 100644
index 00000000000..49b0dc16017
--- /dev/null
+++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBar.vue
@@ -0,0 +1,403 @@
+
+
+
+
+
+
+
+ {{ context }}
+
+
+
+
+
+
+
+
+ {{ section.title }}
+
+
+
+
+
+
+ No results found
+
+
+
+
+
+
+
+
+
diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBarItem.vue b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBarItem.vue
new file mode 100644
index 00000000000..d2f972af378
--- /dev/null
+++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/CommandBarItem.vue
@@ -0,0 +1,109 @@
+
+
+
+
+
+
+
diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/index.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/index.ts
new file mode 100644
index 00000000000..77e15135d42
--- /dev/null
+++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/index.ts
@@ -0,0 +1,3 @@
+import N8nCommandBar from './CommandBar.vue';
+
+export default N8nCommandBar;
diff --git a/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts
new file mode 100644
index 00000000000..f2bebf8a81e
--- /dev/null
+++ b/packages/frontend/@n8n/design-system/src/components/N8nCommandBar/types.ts
@@ -0,0 +1,14 @@
+import type { Component } from 'vue';
+
+export interface CommandBarItem {
+ id: string;
+ title: string;
+ icon?: { html: string } | { component: Component; props?: Record };
+ section?: string;
+ keywords?: string[];
+ handler?: () => void | Promise;
+ href?: string;
+ children?: CommandBarItem[];
+ placeholder?: string;
+ hasMoreChildren?: boolean;
+}
diff --git a/packages/frontend/@n8n/design-system/src/components/index.ts b/packages/frontend/@n8n/design-system/src/components/index.ts
index c4fdce97c35..7b160559aff 100644
--- a/packages/frontend/@n8n/design-system/src/components/index.ts
+++ b/packages/frontend/@n8n/design-system/src/components/index.ts
@@ -68,3 +68,4 @@ export { default as N8nDataTableServer } from './N8nDataTableServer';
export { default as N8nTableHeaderControlsButton } from './TableHeaderControlsButton';
export { default as N8nInlineTextEdit } from './N8nInlineTextEdit';
export { default as N8nScrollArea } from './N8nScrollArea';
+export { default as N8nCommandBar } from './N8nCommandBar';
diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json
index a8fa1c31b2a..f28296df265 100644
--- a/packages/frontend/@n8n/i18n/src/locales/en.json
+++ b/packages/frontend/@n8n/i18n/src/locales/en.json
@@ -3566,5 +3566,65 @@
"workflowDiff.newWorkflow.remote": "The workflow will be created on remote",
"preBuiltAgentTemplates.title": "Pre-built agents",
"preBuiltAgentTemplates.tutorials": "Tutorial templates",
- "preBuiltAgentTemplates.viewAllLink": "View all templates"
+ "preBuiltAgentTemplates.viewAllLink": "View all templates",
+ "commandBar.placeholder": "Type a command...",
+ "commandBar.noResults": "No results found",
+ "commandBar.sections.nodes": "Nodes",
+ "commandBar.sections.workflow": "Workflow",
+ "commandBar.sections.workflows": "Workflows",
+ "commandBar.sections.credentials": "Credentials",
+ "commandBar.sections.dataTables": "Data Tables",
+ "commandBar.sections.templates": "Templates",
+ "commandBar.sections.demo": "Demo",
+ "commandBar.nodes.addNode": "Add node",
+ "commandBar.nodes.addStickyNote": "Add sticky note",
+ "commandBar.nodes.openNode": "Open node",
+ "commandBar.nodes.openNodeWithPrefix": "Open node > {nodeName}",
+ "commandBar.nodes.addNodeWithPrefix": "Add node > {nodeName}",
+ "commandBar.nodes.searchPlaceholder": "Search by node name, type, etc.",
+ "commandBar.nodes.keywords.insert": "insert",
+ "commandBar.nodes.keywords.add": "add",
+ "commandBar.nodes.keywords.create": "create",
+ "commandBar.nodes.keywords.node": "node",
+ "commandBar.workflow.test": "Test workflow",
+ "commandBar.workflow.save": "Save workflow",
+ "commandBar.workflow.activate": "Activate workflow",
+ "commandBar.workflow.deactivate": "Deactivate workflow",
+ "commandBar.workflow.selectAll": "Select all",
+ "commandBar.workflow.tidyUp": "Tidy up workflow",
+ "commandBar.workflow.duplicate": "Duplicate workflow",
+ "commandBar.workflow.download": "Download workflow",
+ "commandBar.workflow.openCredential": "Open credential",
+ "commandBar.workflow.openSubworkflow": "Open subworkflow",
+ "commandBar.workflow.keywords.test": "test",
+ "commandBar.workflow.keywords.execute": "execute",
+ "commandBar.workflow.keywords.run": "run",
+ "commandBar.workflow.keywords.workflow": "workflow",
+ "commandBar.workflows.create": "Create workflow in {projectName}",
+ "commandBar.workflows.open": "Open workflow",
+ "commandBar.workflows.searchPlaceholder": "Search by workflow name or node type...",
+ "commandBar.workflows.prefixPersonal": "[Personal] > ",
+ "commandBar.workflows.prefixProject": "[{projectName}] > ",
+ "commandBar.workflows.openPrefixPersonal": "Open workflow > [Personal] > ",
+ "commandBar.workflows.openPrefixProject": "Open workflow > [{projectName}] > ",
+ "commandBar.workflows.unnamed": "(unnamed workflow)",
+ "commandBar.credentials.create": "Create credential in {projectName}",
+ "commandBar.credentials.open": "Open credential",
+ "commandBar.credentials.searchPlaceholder": "Search by credential name...",
+ "commandBar.credentials.prefixPersonal": "[Personal] > ",
+ "commandBar.credentials.prefixProject": "[{projectName}] > ",
+ "commandBar.credentials.openPrefixPersonal": "Open credential > [Personal] > ",
+ "commandBar.credentials.openPrefixProject": "Open credential > [{projectName}] > ",
+ "commandBar.credentials.unnamed": "(unnamed credential)",
+ "commandBar.dataTables.create": "Create data table in {projectName}",
+ "commandBar.dataTables.open": "Open data table",
+ "commandBar.dataTables.searchPlaceholder": "Search by data table name...",
+ "commandBar.dataTables.prefixPersonal": "[Personal] > ",
+ "commandBar.dataTables.prefixProject": "[{projectName}] > ",
+ "commandBar.dataTables.openPrefixPersonal": "Open data table > [Personal] > ",
+ "commandBar.dataTables.openPrefixProject": "Open data table > [{projectName}] > ",
+ "commandBar.dataTables.unnamed": "(unnamed data table)",
+ "commandBar.templates.import": "Import template",
+ "commandBar.templates.importWithPrefix": "Import template > {templateName}",
+ "commandBar.demo.availableEverywhere": "This is available everywhere"
}
diff --git a/packages/frontend/editor-ui/src/App.vue b/packages/frontend/editor-ui/src/App.vue
index b766d9184ea..e434856e54a 100644
--- a/packages/frontend/editor-ui/src/App.vue
+++ b/packages/frontend/editor-ui/src/App.vue
@@ -22,7 +22,7 @@ import { useSettingsStore } from '@/stores/settings.store';
import { useUIStore } from '@/stores/ui.store';
import { useUsersStore } from '@/stores/users.store';
import LoadingView from '@/views/LoadingView.vue';
-import { locale } from '@n8n/design-system';
+import { locale, N8nCommandBar } from '@n8n/design-system';
import { setLanguage } from '@n8n/i18n';
// Note: no need to import en.json here; default 'en' is handled via setLanguage
import { useRootStore } from '@n8n/stores/useRootStore';
@@ -32,6 +32,8 @@ import { useRoute } from 'vue-router';
import { useStyles } from './composables/useStyles';
import { useExposeCssVar } from '@/composables/useExposeCssVar';
import { useFloatingUiOffsets } from '@/composables/useFloatingUiOffsets';
+import { useCommandBar } from './composables/useCommandBar';
+import { hasPermission } from './utils/rbac/permissions';
const route = useRoute();
const rootStore = useRootStore();
@@ -42,6 +44,18 @@ const usersStore = useUsersStore();
const settingsStore = useSettingsStore();
const ndvStore = useNDVStore();
+const {
+ initialize: initializeCommandBar,
+ isEnabled: isCommandBarEnabled,
+ items,
+ onCommandBarChange,
+ onCommandBarNavigateTo,
+} = useCommandBar();
+
+const showCommandBar = computed(
+ () => isCommandBarEnabled.value && hasPermission(['authenticated']),
+);
+
const { setAppZIndexes } = useStyles();
const { toastBottomOffset, askAiFloatingButtonBottomOffset } = useFloatingUiOffsets();
@@ -70,6 +84,12 @@ onMounted(async () => {
await updateGridWidth();
});
+watch(showCommandBar, (newVal) => {
+ if (newVal) {
+ void initializeCommandBar();
+ }
+});
+
onBeforeUnmount(() => {
window.removeEventListener('resize', updateGridWidth);
});
@@ -149,6 +169,13 @@ useExposeCssVar('--ask-assistant-floating-button-bottom-offset', askAiFloatingBu
+
+
diff --git a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue
index a01579629c6..890460137fe 100644
--- a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue
+++ b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue
@@ -853,6 +853,7 @@ const initialized = ref(false);
onMounted(() => {
props.eventBus.on('fitView', onFitView);
props.eventBus.on('nodes:select', onSelectNodes);
+ props.eventBus.on('nodes:selectAll', () => addSelectedNodes(graphNodes.value));
props.eventBus.on('tidyUp', onTidyUp);
window.addEventListener('blur', onWindowBlur);
});
diff --git a/packages/frontend/editor-ui/src/composables/commandBar/types.ts b/packages/frontend/editor-ui/src/composables/commandBar/types.ts
new file mode 100644
index 00000000000..0d7f8389bfe
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/commandBar/types.ts
@@ -0,0 +1,14 @@
+import type { CommandBarItem } from '@n8n/design-system/components/N8nCommandBar/types';
+import type { ComputedRef } from 'vue';
+
+export type { CommandBarItem };
+
+export interface CommandBarEventHandlers {
+ onCommandBarChange?: (query: string) => void;
+ onCommandBarNavigateTo?: (to: string | null) => void;
+}
+
+export interface CommandGroup {
+ commands: ComputedRef;
+ handlers?: CommandBarEventHandlers;
+}
diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useBaseCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useBaseCommands.ts
new file mode 100644
index 00000000000..cd5f1af3d84
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/commandBar/useBaseCommands.ts
@@ -0,0 +1,23 @@
+import { computed } from 'vue';
+import { useI18n } from '@n8n/i18n';
+import type { CommandGroup } from './types';
+
+export function useBaseCommands(): CommandGroup {
+ const i18n = useI18n();
+ const baseCommands = computed(() => {
+ return [
+ {
+ id: 'demo-action',
+ title: i18n.baseText('commandBar.demo.availableEverywhere'),
+ section: i18n.baseText('commandBar.sections.demo'),
+ handler: () => {
+ console.log('hello');
+ },
+ },
+ ];
+ });
+
+ return {
+ commands: baseCommands,
+ };
+}
diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useCredentialNavigationCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useCredentialNavigationCommands.ts
new file mode 100644
index 00000000000..9718b500069
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/commandBar/useCredentialNavigationCommands.ts
@@ -0,0 +1,172 @@
+import { computed, ref, type Ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { N8nIcon } from '@n8n/design-system';
+import { useI18n } from '@n8n/i18n';
+import debounce from 'lodash/debounce';
+import type { ICredentialsResponse } from '@/Interface';
+import { useCredentialsStore } from '@/stores/credentials.store';
+import { useProjectsStore } from '@/stores/projects.store';
+import { useUIStore } from '@/stores/ui.store';
+import type { CommandGroup, CommandBarItem } from './types';
+
+const ITEM_ID = {
+ CREATE_CREDENTIAL: 'create-credential',
+ OPEN_CREDENTIAL: 'open-credential',
+};
+
+export function useCredentialNavigationCommands(options: {
+ lastQuery: Ref;
+ activeNodeId: Ref;
+ currentProjectName: Ref;
+}): CommandGroup {
+ const i18n = useI18n();
+ const { lastQuery, activeNodeId, currentProjectName } = options;
+ const credentialsStore = useCredentialsStore();
+ const projectsStore = useProjectsStore();
+ const uiStore = useUIStore();
+
+ const route = useRoute();
+ const router = useRouter();
+
+ const credentialResults = ref([]);
+
+ const personalProjectId = computed(() => {
+ return projectsStore.myProjects.find((p) => p.type === 'personal')?.id;
+ });
+
+ function orderResultByCurrentProjectFirst(results: T[]) {
+ const currentProjectId =
+ typeof route.params.projectId === 'string' ? route.params.projectId : personalProjectId.value;
+ return results.sort((a, b) => {
+ if (a.homeProject?.id === currentProjectId) return -1;
+ if (b.homeProject?.id === currentProjectId) return 1;
+ return 0;
+ });
+ }
+
+ const fetchCredentials = debounce(async (query: string) => {
+ try {
+ const trimmed = (query || '').trim();
+ await credentialsStore.fetchAllCredentials();
+
+ const trimmedLower = trimmed.toLowerCase();
+ const filtered = credentialsStore.allCredentials.filter((credential) =>
+ credential.name.toLowerCase().includes(trimmedLower),
+ );
+
+ credentialResults.value = orderResultByCurrentProjectFirst(filtered);
+ } catch {
+ credentialResults.value = [];
+ }
+ }, 300);
+
+ const getCredentialTitle = (
+ credential: ICredentialsResponse,
+ includeOpenCredentialPrefix: boolean,
+ ) => {
+ let prefix = '';
+ if (credential.homeProject && credential.homeProject.type === 'personal') {
+ prefix = includeOpenCredentialPrefix
+ ? i18n.baseText('commandBar.credentials.openPrefixPersonal')
+ : i18n.baseText('commandBar.credentials.prefixPersonal');
+ } else {
+ prefix = includeOpenCredentialPrefix
+ ? i18n.baseText('commandBar.credentials.openPrefixProject', {
+ interpolate: { projectName: credential.homeProject?.name ?? '' },
+ })
+ : i18n.baseText('commandBar.credentials.prefixProject', {
+ interpolate: { projectName: credential.homeProject?.name ?? '' },
+ });
+ }
+ return prefix + (credential.name || i18n.baseText('commandBar.credentials.unnamed'));
+ };
+
+ const createCredentialCommand = (
+ credential: ICredentialsResponse,
+ includeOpenCredentialPrefix: boolean,
+ ): CommandBarItem => {
+ return {
+ id: credential.id,
+ title: getCredentialTitle(credential, includeOpenCredentialPrefix),
+ section: i18n.baseText('commandBar.sections.credentials'),
+ handler: () => {
+ uiStore.openExistingCredential(credential.id);
+ },
+ };
+ };
+
+ const openCredentialCommands = computed(() => {
+ return credentialResults.value.map((credential) => createCredentialCommand(credential, false));
+ });
+
+ const rootCredentialItems = computed(() => {
+ if (lastQuery.value.length <= 2) {
+ return [];
+ }
+ return credentialResults.value.map((credential) => createCredentialCommand(credential, true));
+ });
+
+ const credentialNavigationCommands = computed(() => {
+ return [
+ {
+ id: ITEM_ID.CREATE_CREDENTIAL,
+ title: i18n.baseText('commandBar.credentials.create', {
+ interpolate: { projectName: currentProjectName.value },
+ }),
+ section: i18n.baseText('commandBar.sections.credentials'),
+ icon: {
+ component: N8nIcon,
+ props: {
+ icon: 'plus',
+ },
+ },
+ handler: () => {
+ void router.push({
+ params: { credentialId: 'create' },
+ });
+ },
+ },
+ {
+ id: ITEM_ID.OPEN_CREDENTIAL,
+ title: i18n.baseText('commandBar.credentials.open'),
+ section: i18n.baseText('commandBar.sections.credentials'),
+ placeholder: i18n.baseText('commandBar.credentials.searchPlaceholder'),
+ children: openCredentialCommands.value,
+ icon: {
+ component: N8nIcon,
+ props: {
+ icon: 'arrow-right',
+ },
+ },
+ },
+ ...rootCredentialItems.value,
+ ];
+ });
+
+ function onCommandBarChange(query: string) {
+ lastQuery.value = query;
+
+ const trimmed = query.trim();
+ if (trimmed.length > 2 || activeNodeId.value === ITEM_ID.OPEN_CREDENTIAL) {
+ void fetchCredentials(trimmed);
+ }
+ }
+
+ function onCommandBarNavigateTo(to: string | null) {
+ activeNodeId.value = to;
+
+ if (to === ITEM_ID.OPEN_CREDENTIAL) {
+ void fetchCredentials('');
+ } else if (to === null) {
+ credentialResults.value = [];
+ }
+ }
+
+ return {
+ commands: credentialNavigationCommands,
+ handlers: {
+ onCommandBarChange,
+ onCommandBarNavigateTo,
+ },
+ };
+}
diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useDataTableNavigationCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useDataTableNavigationCommands.ts
new file mode 100644
index 00000000000..1a1549ce3fb
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/commandBar/useDataTableNavigationCommands.ts
@@ -0,0 +1,191 @@
+import { computed, ref, type Ref } from 'vue';
+import { useRouter, useRoute } from 'vue-router';
+import { useI18n } from '@n8n/i18n';
+import debounce from 'lodash/debounce';
+import { useDataStoreStore } from '@/features/dataStore/dataStore.store';
+import { useProjectsStore } from '@/stores/projects.store';
+import { DATA_STORE_DETAILS, PROJECT_DATA_STORES } from '@/features/dataStore/constants';
+import type { CommandGroup, CommandBarItem } from './types';
+import type { DataStore } from '@/features/dataStore/datastore.types';
+import { N8nIcon } from '@n8n/design-system';
+
+const ITEM_ID = {
+ OPEN_DATA_TABLE: 'open-data-table',
+ CREATE_DATA_TABLE: 'create-data-table',
+};
+
+export function useDataTableNavigationCommands(options: {
+ lastQuery: Ref;
+ activeNodeId: Ref;
+ currentProjectName: Ref;
+}): CommandGroup {
+ const i18n = useI18n();
+ const { lastQuery, activeNodeId, currentProjectName } = options;
+ const dataStoreStore = useDataStoreStore();
+ const projectsStore = useProjectsStore();
+
+ const router = useRouter();
+ const route = useRoute();
+
+ const dataTableResults = ref([]);
+
+ const currentProjectId = computed(() => {
+ return typeof route.params.projectId === 'string'
+ ? route.params.projectId
+ : personalProjectId.value;
+ });
+
+ const personalProjectId = computed(() => {
+ return projectsStore.myProjects.find((p) => p.type === 'personal')?.id;
+ });
+
+ function orderResultByCurrentProjectFirst(results: T[]) {
+ return results.sort((a, b) => {
+ if (a.project?.id === currentProjectId.value) return -1;
+ if (b.project?.id === currentProjectId.value) return 1;
+ return 0;
+ });
+ }
+
+ const fetchDataTables = debounce(async (query: string) => {
+ try {
+ const trimmed = (query || '').trim();
+
+ if (!currentProjectId.value) {
+ dataTableResults.value = [];
+ return;
+ }
+
+ await dataStoreStore.fetchDataStores(
+ currentProjectId.value,
+ 1,
+ 100, // TODO: pagination/lazy loading
+ );
+
+ const trimmedLower = trimmed.toLowerCase();
+ const filtered = dataStoreStore.dataStores.filter((dataTable) =>
+ dataTable.name.toLowerCase().includes(trimmedLower),
+ );
+
+ dataTableResults.value = orderResultByCurrentProjectFirst(filtered);
+ } catch {
+ dataTableResults.value = [];
+ }
+ }, 300);
+
+ const getDataTableTitle = (dataTable: DataStore, includeOpenDataTablePrefix: boolean) => {
+ let prefix = '';
+ if (dataTable.project && dataTable.project.type === 'personal') {
+ prefix = includeOpenDataTablePrefix
+ ? i18n.baseText('commandBar.dataTables.openPrefixPersonal')
+ : i18n.baseText('commandBar.dataTables.prefixPersonal');
+ } else {
+ prefix = includeOpenDataTablePrefix
+ ? i18n.baseText('commandBar.dataTables.openPrefixProject', {
+ interpolate: { projectName: dataTable.project?.name ?? '' },
+ })
+ : i18n.baseText('commandBar.dataTables.prefixProject', {
+ interpolate: { projectName: dataTable.project?.name ?? '' },
+ });
+ }
+ return prefix + (dataTable.name || i18n.baseText('commandBar.dataTables.unnamed'));
+ };
+
+ const createDataTableCommand = (
+ dataTable: DataStore,
+ includeOpenDataTablePrefix: boolean,
+ ): CommandBarItem => {
+ return {
+ id: dataTable.id,
+ title: getDataTableTitle(dataTable, includeOpenDataTablePrefix),
+ section: i18n.baseText('commandBar.sections.dataTables'),
+ handler: () => {
+ const targetRoute = router.resolve({
+ name: DATA_STORE_DETAILS,
+ params: {
+ projectId: dataTable.projectId,
+ id: dataTable.id,
+ },
+ });
+ window.location.href = targetRoute.fullPath;
+ },
+ };
+ };
+
+ const openDataTableCommands = computed(() => {
+ return dataTableResults.value.map((dataTable) => createDataTableCommand(dataTable, false));
+ });
+
+ const rootDataTableItems = computed(() => {
+ if (lastQuery.value.length <= 2) {
+ return [];
+ }
+ return dataTableResults.value.map((dataTable) => createDataTableCommand(dataTable, true));
+ });
+
+ const dataTableNavigationCommands = computed(() => {
+ return [
+ {
+ id: ITEM_ID.CREATE_DATA_TABLE,
+ title: i18n.baseText('commandBar.dataTables.create', {
+ interpolate: { projectName: currentProjectName.value },
+ }),
+ section: i18n.baseText('commandBar.sections.dataTables'),
+ icon: {
+ component: N8nIcon,
+ props: {
+ icon: 'plus',
+ },
+ },
+ handler: () => {
+ if (!currentProjectId.value) return;
+ void router.push({
+ name: PROJECT_DATA_STORES,
+ params: { projectId: currentProjectId.value, new: 'new' },
+ });
+ },
+ },
+ {
+ id: ITEM_ID.OPEN_DATA_TABLE,
+ title: i18n.baseText('commandBar.dataTables.open'),
+ section: i18n.baseText('commandBar.sections.dataTables'),
+ placeholder: i18n.baseText('commandBar.dataTables.searchPlaceholder'),
+ icon: {
+ component: N8nIcon,
+ props: {
+ icon: 'arrow-right',
+ },
+ },
+ children: openDataTableCommands.value,
+ },
+ ...rootDataTableItems.value,
+ ];
+ });
+
+ function onCommandBarChange(query: string) {
+ lastQuery.value = query;
+
+ const trimmed = query.trim();
+ if (trimmed.length > 2 || activeNodeId.value === ITEM_ID.OPEN_DATA_TABLE) {
+ void fetchDataTables(trimmed);
+ }
+ }
+
+ function onCommandBarNavigateTo(to: string | null) {
+ activeNodeId.value = to;
+
+ if (to === ITEM_ID.OPEN_DATA_TABLE) {
+ void fetchDataTables('');
+ } else if (to === null) {
+ dataTableResults.value = [];
+ }
+ }
+
+ return {
+ commands: dataTableNavigationCommands,
+ handlers: {
+ onCommandBarChange,
+ onCommandBarNavigateTo,
+ },
+ };
+}
diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useNodeCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useNodeCommands.ts
new file mode 100644
index 00000000000..90e67bfc0f1
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/commandBar/useNodeCommands.ts
@@ -0,0 +1,125 @@
+import { computed } from 'vue';
+import { useI18n } from '@n8n/i18n';
+import { useNodeTypesStore } from '@/stores/nodeTypes.store';
+import { useCredentialsStore } from '@/stores/credentials.store';
+import { useRootStore } from '@n8n/stores/useRootStore';
+import { useCanvasOperations } from '@/composables/useCanvasOperations';
+import { useActionsGenerator } from '@/components/Node/NodeCreator/composables/useActionsGeneration';
+import { canvasEventBus } from '@/event-bus/canvas';
+import { type CommandBarItem } from '@n8n/design-system/components/N8nCommandBar/types';
+import { getNodeIcon, getNodeIconUrl } from '@/utils/nodeIcon';
+import type { SimplifiedNodeType } from '@/Interface';
+import type { CommandGroup } from './types';
+
+function getIconSource(nodeType: SimplifiedNodeType | null, baseUrl: string) {
+ if (!nodeType) return {};
+ const iconUrl = getNodeIconUrl(nodeType);
+ if (iconUrl) {
+ return { path: baseUrl + iconUrl };
+ }
+ // Otherwise, extract it from icon prop
+ if (nodeType.icon) {
+ const icon = getNodeIcon(nodeType);
+ if (icon) {
+ const [type, path] = icon.split(':');
+ if (type === 'file') {
+ return {};
+ }
+ return { icon: path };
+ }
+ }
+ return {};
+}
+
+export function useNodeCommands(): CommandGroup {
+ const i18n = useI18n();
+ const { addNodes, setNodeActive, editableWorkflow } = useCanvasOperations();
+ const nodeTypesStore = useNodeTypesStore();
+ const credentialsStore = useCredentialsStore();
+ const rootStore = useRootStore();
+ const { generateMergedNodesAndActions } = useActionsGenerator();
+
+ const addNodeCommands = computed(() => {
+ const httpOnlyCredentials = credentialsStore.httpOnlyCredentialTypes;
+ const nodeTypes = nodeTypesStore.visibleNodeTypes;
+ const { mergedNodes } = generateMergedNodesAndActions(nodeTypes, httpOnlyCredentials);
+ return mergedNodes.map((node) => {
+ const { name, displayName } = node;
+ const src = getIconSource(node, rootStore.baseUrl);
+ return {
+ id: name,
+ title: i18n.baseText('commandBar.nodes.addNodeWithPrefix', {
+ interpolate: { nodeName: displayName },
+ }),
+ keywords: [
+ i18n.baseText('commandBar.nodes.keywords.insert'),
+ i18n.baseText('commandBar.nodes.keywords.add'),
+ i18n.baseText('commandBar.nodes.keywords.create'),
+ i18n.baseText('commandBar.nodes.keywords.node'),
+ ],
+ icon: src.path
+ ? {
+ html: ` `,
+ }
+ : undefined,
+ handler: async () => {
+ await addNodes([{ type: name }]);
+ },
+ };
+ });
+ });
+
+ const openNodeCommands = computed(() => {
+ return editableWorkflow.value.nodes.map((node) => {
+ const { id, name, type } = node;
+ const nodeType = nodeTypesStore.getNodeType(node.type, node.typeVersion);
+ const src = getIconSource(nodeType, rootStore.baseUrl);
+ return {
+ id,
+ title: i18n.baseText('commandBar.nodes.openNodeWithPrefix', {
+ interpolate: { nodeName: name },
+ }),
+ section: i18n.baseText('commandBar.sections.nodes'),
+ keywords: [type],
+ icon: src?.path
+ ? {
+ html: ` `,
+ }
+ : undefined,
+ handler: () => {
+ setNodeActive(id, 'command_bar');
+ },
+ placeholder: i18n.baseText('commandBar.nodes.searchPlaceholder'),
+ };
+ });
+ });
+
+ const nodeCommands = computed(() => {
+ return [
+ {
+ id: 'add-node',
+ title: i18n.baseText('commandBar.nodes.addNode'),
+ section: i18n.baseText('commandBar.sections.nodes'),
+ children: [...addNodeCommands.value],
+ },
+ {
+ id: 'add-sticky-note',
+ title: i18n.baseText('commandBar.nodes.addStickyNote'),
+ section: i18n.baseText('commandBar.sections.nodes'),
+ handler: () => {
+ canvasEventBus.emit('create:sticky');
+ },
+ },
+ {
+ id: 'open-node',
+ title: i18n.baseText('commandBar.nodes.openNode'),
+ section: i18n.baseText('commandBar.sections.nodes'),
+ children: [...openNodeCommands.value],
+ },
+ ];
+ });
+
+ return {
+ commands: nodeCommands,
+ };
+}
diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useTemplateCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useTemplateCommands.ts
new file mode 100644
index 00000000000..5d67e2af42e
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/commandBar/useTemplateCommands.ts
@@ -0,0 +1,43 @@
+import { computed } from 'vue';
+import { useI18n } from '@n8n/i18n';
+import { useTemplatesStore } from '@/stores/templates.store';
+import { useCanvasOperations } from '@/composables/useCanvasOperations';
+import type { CommandGroup } from './types';
+
+export function useTemplateCommands(): CommandGroup {
+ const i18n = useI18n();
+ const { openWorkflowTemplate } = useCanvasOperations();
+ const templatesStore = useTemplatesStore();
+
+ const importTemplateCommands = computed(() => {
+ const templateWorkflows = Object.values(templatesStore.workflows);
+ return templateWorkflows.map((template) => {
+ const { id, name } = template;
+ return {
+ id: id.toString(),
+ title: i18n.baseText('commandBar.templates.importWithPrefix', {
+ interpolate: { templateName: name },
+ }),
+ section: i18n.baseText('commandBar.sections.templates'),
+ handler: async () => {
+ await openWorkflowTemplate(id.toString());
+ },
+ };
+ });
+ });
+
+ const templateCommands = computed(() => {
+ return [
+ {
+ id: 'import-template',
+ title: i18n.baseText('commandBar.templates.import'),
+ children: [...importTemplateCommands.value],
+ section: i18n.baseText('commandBar.sections.templates'),
+ },
+ ];
+ });
+
+ return {
+ commands: templateCommands,
+ };
+}
diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useWorkflowCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useWorkflowCommands.ts
new file mode 100644
index 00000000000..9adaf6a09b3
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/commandBar/useWorkflowCommands.ts
@@ -0,0 +1,220 @@
+import { computed } from 'vue';
+import { useRouter } from 'vue-router';
+import { isResourceLocatorValue } from 'n8n-workflow';
+import { useI18n } from '@n8n/i18n';
+import { useRootStore } from '@n8n/stores/useRootStore';
+import { useTagsStore } from '@/stores/tags.store';
+import { useUIStore } from '@/stores/ui.store';
+import { useCanvasOperations } from '@/composables/useCanvasOperations';
+import { useTelemetry } from '@/composables/useTelemetry';
+import { useWorkflowSaving } from '@/composables/useWorkflowSaving';
+import { useRunWorkflow } from '@/composables/useRunWorkflow';
+import { useWorkflowHelpers } from '@/composables/useWorkflowHelpers';
+import { canvasEventBus } from '@/event-bus/canvas';
+import { DUPLICATE_MODAL_KEY, EXECUTE_WORKFLOW_NODE_TYPE, VIEWS } from '@/constants';
+import type { IWorkflowToShare } from '@/Interface';
+import { saveAs } from 'file-saver';
+import { useWorkflowsStore } from '@/stores/workflows.store';
+import { useWorkflowActivate } from '../useWorkflowActivate';
+import type { CommandGroup, CommandBarItem } from './types';
+import uniqBy from 'lodash/uniqBy';
+
+export function useWorkflowCommands(): CommandGroup {
+ const i18n = useI18n();
+ const { editableWorkflow } = useCanvasOperations();
+ const rootStore = useRootStore();
+ const uiStore = useUIStore();
+ const tagsStore = useTagsStore();
+ const workflowsStore = useWorkflowsStore();
+
+ const router = useRouter();
+
+ const workflowHelpers = useWorkflowHelpers();
+ const telemetry = useTelemetry();
+ const workflowSaving = useWorkflowSaving({ router });
+ const workflowActivate = useWorkflowActivate();
+
+ const credentialCommands = computed(() => {
+ const credentials = uniqBy(
+ editableWorkflow.value.nodes.map((node) => Object.values(node.credentials ?? {})).flat(),
+ (cred) => cred.id,
+ );
+ if (credentials.length === 0) {
+ return [];
+ }
+ return [
+ {
+ id: 'open-credential',
+ title: i18n.baseText('commandBar.workflow.openCredential'),
+ section: i18n.baseText('commandBar.sections.credentials'),
+ children: [
+ ...credentials.map((credential) => ({
+ id: credential.id as string,
+ title: credential.name,
+ handler: () => {
+ if (typeof credential.id === 'string') {
+ uiStore.openExistingCredential(credential.id);
+ }
+ },
+ })),
+ ],
+ },
+ ];
+ });
+
+ const subworkflowCommands = computed(() => {
+ const subworkflows = editableWorkflow.value.nodes
+ .filter((node) => node.type === EXECUTE_WORKFLOW_NODE_TYPE)
+ .map((node) => node?.parameters?.workflowId)
+ .filter(
+ (rlValue): rlValue is { value: string; cachedResultName: string } =>
+ isResourceLocatorValue(rlValue) &&
+ typeof rlValue.value === 'string' &&
+ typeof rlValue.cachedResultName === 'string',
+ )
+ .map(({ value, cachedResultName }) => ({ id: value, name: cachedResultName }));
+ if (subworkflows.length === 0) {
+ return [];
+ }
+ return [
+ {
+ id: 'open-sub-workflow',
+ title: i18n.baseText('commandBar.workflow.openSubworkflow'),
+ children: [
+ ...subworkflows.map((workflow) => ({
+ id: workflow.id,
+ title: workflow.name,
+ handler: () => {
+ const { href } = router.resolve({
+ name: VIEWS.WORKFLOW,
+ params: { name: workflow.id },
+ });
+ window.open(href, '_blank', 'noreferrer');
+ },
+ })),
+ ],
+ },
+ ];
+ });
+
+ const workflowCommands = computed(() => {
+ return [
+ {
+ id: 'test-workflow',
+ title: i18n.baseText('commandBar.workflow.test'),
+ section: i18n.baseText('commandBar.sections.workflow'),
+ keywords: [
+ i18n.baseText('commandBar.workflow.keywords.test'),
+ i18n.baseText('commandBar.workflow.keywords.execute'),
+ i18n.baseText('commandBar.workflow.keywords.run'),
+ i18n.baseText('commandBar.workflow.keywords.workflow'),
+ ],
+ handler: () => {
+ // Lazily instantiate useRunWorkflow only when the handler runs to avoid early initialization side effects
+ void useRunWorkflow({ router }).runEntireWorkflow('main');
+ },
+ },
+ {
+ id: 'save-workflow',
+ title: i18n.baseText('commandBar.workflow.save'),
+ section: i18n.baseText('commandBar.sections.workflow'),
+ handler: async () => {
+ const saved = await workflowSaving.saveCurrentWorkflow();
+ if (saved) {
+ canvasEventBus.emit('saved:workflow');
+ }
+ },
+ },
+ ...(workflowsStore.isWorkflowActive
+ ? [
+ {
+ id: 'deactivate-workflow',
+ title: i18n.baseText('commandBar.workflow.deactivate'),
+ section: i18n.baseText('commandBar.sections.workflow'),
+ handler: () => {
+ void workflowActivate.updateWorkflowActivation(workflowsStore.workflowId, false);
+ },
+ },
+ ]
+ : [
+ {
+ id: 'activate-workflow',
+ title: i18n.baseText('commandBar.workflow.activate'),
+ section: i18n.baseText('commandBar.sections.workflow'),
+ handler: () => {
+ void workflowActivate.updateWorkflowActivation(workflowsStore.workflowId, true);
+ },
+ },
+ ]),
+ {
+ id: 'select-all',
+ title: i18n.baseText('commandBar.workflow.selectAll'),
+ section: i18n.baseText('commandBar.sections.workflow'),
+ handler: () => {
+ canvasEventBus.emit('nodes:selectAll');
+ },
+ },
+ {
+ id: 'tidy-up-workflow',
+ title: i18n.baseText('commandBar.workflow.tidyUp'),
+ section: i18n.baseText('commandBar.sections.workflow'),
+ handler: () => {
+ canvasEventBus.emit('tidyUp', {
+ source: 'command-bar',
+ });
+ },
+ },
+ {
+ id: 'duplicate-workflow',
+ title: i18n.baseText('commandBar.workflow.duplicate'),
+ section: i18n.baseText('commandBar.sections.workflow'),
+ handler: () => {
+ uiStore.openModalWithData({
+ name: DUPLICATE_MODAL_KEY,
+ data: {
+ id: workflowsStore.workflowId,
+ name: editableWorkflow.value.name,
+ tags: editableWorkflow.value.tags,
+ },
+ });
+ },
+ },
+ {
+ id: 'download-workflow',
+ title: i18n.baseText('commandBar.workflow.download'),
+ section: i18n.baseText('commandBar.sections.workflow'),
+ handler: async () => {
+ const workflowData = await workflowHelpers.getWorkflowDataToSave();
+ const { tags, ...data } = workflowData;
+ const exportData: IWorkflowToShare = {
+ ...data,
+ meta: {
+ ...workflowData.meta,
+ instanceId: rootStore.instanceId,
+ },
+ tags: (tags ?? []).map((tagId) => {
+ return tagsStore.tagsById[tagId];
+ }),
+ };
+ const blob = new Blob([JSON.stringify(exportData, null, 2)], {
+ type: 'application/json;charset=utf-8',
+ });
+ let name = editableWorkflow.value.name || 'unsaved_workflow';
+ name = name.replace(/[^a-z0-9]/gi, '_');
+ telemetry.track('User exported workflow', { workflow_id: workflowData.id });
+ saveAs(blob, name + '.json');
+ },
+ },
+ ];
+ });
+
+ const allCommands = computed(() => [
+ ...workflowCommands.value,
+ ...credentialCommands.value,
+ ...subworkflowCommands.value,
+ ]);
+
+ return {
+ commands: allCommands,
+ };
+}
diff --git a/packages/frontend/editor-ui/src/composables/commandBar/useWorkflowNavigationCommands.ts b/packages/frontend/editor-ui/src/composables/commandBar/useWorkflowNavigationCommands.ts
new file mode 100644
index 00000000000..030fba55906
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/commandBar/useWorkflowNavigationCommands.ts
@@ -0,0 +1,209 @@
+import { computed, ref, type Ref } from 'vue';
+import { useRouter, useRoute } from 'vue-router';
+import { N8nIcon } from '@n8n/design-system';
+import { useI18n } from '@n8n/i18n';
+import { useNodeTypesStore } from '@/stores/nodeTypes.store';
+import { useCredentialsStore } from '@/stores/credentials.store';
+import { useActionsGenerator } from '@/components/Node/NodeCreator/composables/useActionsGeneration';
+import debounce from 'lodash/debounce';
+import { VIEWS } from '@/constants';
+import type { IWorkflowDb } from '@/Interface';
+import { useWorkflowsStore } from '@/stores/workflows.store';
+import { useProjectsStore } from '@/stores/projects.store';
+import type { CommandGroup, CommandBarItem } from './types';
+
+const ITEM_ID = {
+ CREATE_WORKFLOW: 'create-workflow',
+ OPEN_WORKFLOW: 'open-workflow',
+};
+
+export function useWorkflowNavigationCommands(options: {
+ lastQuery: Ref;
+ activeNodeId: Ref;
+ currentProjectName: Ref;
+}): CommandGroup {
+ const i18n = useI18n();
+ const { lastQuery, activeNodeId, currentProjectName } = options;
+ const nodeTypesStore = useNodeTypesStore();
+ const credentialsStore = useCredentialsStore();
+ const workflowsStore = useWorkflowsStore();
+ const projectsStore = useProjectsStore();
+
+ const router = useRouter();
+ const route = useRoute();
+
+ const { generateMergedNodesAndActions } = useActionsGenerator();
+
+ const workflowResults = ref([]);
+
+ const personalProjectId = computed(() => {
+ return projectsStore.myProjects.find((p) => p.type === 'personal')?.id;
+ });
+
+ function orderResultByCurrentProjectFirst(results: T[]) {
+ const currentProjectId =
+ typeof route.params.projectId === 'string' ? route.params.projectId : personalProjectId.value;
+ return results.sort((a, b) => {
+ if (a.homeProject?.id === currentProjectId) return -1;
+ if (b.homeProject?.id === currentProjectId) return 1;
+ return 0;
+ });
+ }
+
+ const fetchWorkflows = debounce(async (query: string) => {
+ try {
+ const trimmed = (query || '').trim();
+ const nameSearchPromise = workflowsStore.searchWorkflows({
+ name: trimmed,
+ });
+
+ // Find matching node types from available nodes
+ const httpOnlyCredentials = credentialsStore.httpOnlyCredentialTypes;
+ const visibleNodeTypes = nodeTypesStore.allNodeTypes;
+ const { mergedNodes } = generateMergedNodesAndActions(visibleNodeTypes, httpOnlyCredentials);
+ const trimmedLower = trimmed.toLowerCase();
+ const matchedNodeTypeNames = Array.from(
+ new Set(
+ mergedNodes
+ .filter(
+ (node) =>
+ node.displayName?.toLowerCase().includes(trimmedLower) ||
+ node.name?.toLowerCase().includes(trimmedLower),
+ )
+ .map((node) => node.name),
+ ),
+ );
+
+ const nodeTypeSearchPromise =
+ matchedNodeTypeNames.length > 0
+ ? workflowsStore.searchWorkflows({
+ // nodeTypes: matchedNodeTypeNames, TODO
+ })
+ : Promise.resolve([]);
+
+ const [byName, byNodeTypes] = await Promise.all([nameSearchPromise, nodeTypeSearchPromise]);
+
+ // Merge and dedupe by id
+ const merged = [...byName, ...byNodeTypes];
+ const uniqueById = Array.from(new Map(merged.map((w) => [w.id, w])).values());
+ workflowResults.value = orderResultByCurrentProjectFirst(uniqueById);
+ } catch {
+ workflowResults.value = [];
+ }
+ }, 300);
+
+ const getWorkflowTitle = (workflow: IWorkflowDb, includeOpenWorkflowPrefix: boolean) => {
+ let prefix = '';
+ if (workflow.homeProject && workflow.homeProject.type === 'personal') {
+ prefix = includeOpenWorkflowPrefix
+ ? i18n.baseText('commandBar.workflows.openPrefixPersonal')
+ : i18n.baseText('commandBar.workflows.prefixPersonal');
+ } else {
+ prefix = includeOpenWorkflowPrefix
+ ? i18n.baseText('commandBar.workflows.openPrefixProject', {
+ interpolate: { projectName: workflow.homeProject?.name ?? '' },
+ })
+ : i18n.baseText('commandBar.workflows.prefixProject', {
+ interpolate: { projectName: workflow.homeProject?.name ?? '' },
+ });
+ }
+ return prefix + (workflow.name || i18n.baseText('commandBar.workflows.unnamed'));
+ };
+
+ const createWorkflowCommand = (
+ workflow: IWorkflowDb,
+ includeOpenWorkflowPrefix: boolean,
+ ): CommandBarItem => {
+ return {
+ id: workflow.id,
+ title: getWorkflowTitle(workflow, includeOpenWorkflowPrefix),
+ section: i18n.baseText('commandBar.sections.workflows'),
+ handler: () => {
+ const targetRoute = router.resolve({
+ name: VIEWS.WORKFLOW,
+ params: { name: workflow.id },
+ });
+ window.location.href = targetRoute.fullPath;
+ },
+ };
+ };
+
+ const openWorkflowCommands = computed(() => {
+ return workflowResults.value.map((workflow) => createWorkflowCommand(workflow, false));
+ });
+
+ const rootWorkflowItems = computed(() => {
+ if (lastQuery.value.length <= 2) {
+ return [];
+ }
+ return workflowResults.value.map((workflow) => createWorkflowCommand(workflow, true));
+ });
+
+ const workflowNavigationCommands = computed(() => {
+ return [
+ {
+ id: ITEM_ID.CREATE_WORKFLOW,
+ title: i18n.baseText('commandBar.workflows.create', {
+ interpolate: { projectName: currentProjectName.value },
+ }),
+ section: i18n.baseText('commandBar.sections.workflows'),
+ icon: {
+ component: N8nIcon,
+ props: {
+ icon: 'plus',
+ },
+ },
+ handler: () => {
+ void router.push({
+ name: VIEWS.NEW_WORKFLOW,
+ query: {
+ projectId: route.params.projectId,
+ parentFolderId: route.params.folderId,
+ },
+ });
+ },
+ },
+ {
+ id: ITEM_ID.OPEN_WORKFLOW,
+ title: i18n.baseText('commandBar.workflows.open'),
+ section: i18n.baseText('commandBar.sections.workflows'),
+ placeholder: i18n.baseText('commandBar.workflows.searchPlaceholder'),
+ children: openWorkflowCommands.value,
+ icon: {
+ component: N8nIcon,
+ props: {
+ icon: 'arrow-right',
+ },
+ },
+ },
+ ...rootWorkflowItems.value,
+ ];
+ });
+
+ function onCommandBarChange(query: string) {
+ lastQuery.value = query;
+
+ const trimmed = query.trim();
+ if (trimmed.length > 2 || activeNodeId.value === ITEM_ID.OPEN_WORKFLOW) {
+ void fetchWorkflows(trimmed);
+ }
+ }
+
+ function onCommandBarNavigateTo(to: string | null) {
+ activeNodeId.value = to;
+
+ if (to === ITEM_ID.OPEN_WORKFLOW) {
+ void fetchWorkflows('');
+ } else if (to === null) {
+ workflowResults.value = [];
+ }
+ }
+
+ return {
+ commands: workflowNavigationCommands,
+ handlers: {
+ onCommandBarChange,
+ onCommandBarNavigateTo,
+ },
+ };
+}
diff --git a/packages/frontend/editor-ui/src/composables/useCanvasLayout.ts b/packages/frontend/editor-ui/src/composables/useCanvasLayout.ts
index 02329ee5e4e..4c846ff5df6 100644
--- a/packages/frontend/editor-ui/src/composables/useCanvasLayout.ts
+++ b/packages/frontend/editor-ui/src/composables/useCanvasLayout.ts
@@ -17,6 +17,7 @@ export type CanvasLayoutSource =
| 'keyboard-shortcut'
| 'canvas-button'
| 'context-menu'
+ | 'command-bar'
| 'import-workflow-data';
export type CanvasLayoutTargetData = {
nodes: Array>;
diff --git a/packages/frontend/editor-ui/src/composables/useCommandBar.ts b/packages/frontend/editor-ui/src/composables/useCommandBar.ts
new file mode 100644
index 00000000000..bbf3fc68dfe
--- /dev/null
+++ b/packages/frontend/editor-ui/src/composables/useCommandBar.ts
@@ -0,0 +1,132 @@
+import { computed, ref } from 'vue';
+import { useRouter, useRoute } from 'vue-router';
+import { useNodeTypesStore } from '@/stores/nodeTypes.store';
+import { useProjectsStore } from '@/stores/projects.store';
+import { COMMAND_BAR_EXPERIMENT, VIEWS } from '@/constants';
+import { type CommandBarItem } from '@n8n/design-system/components/N8nCommandBar/types';
+import { useNodeCommands } from './commandBar/useNodeCommands';
+import { useWorkflowCommands } from './commandBar/useWorkflowCommands';
+import { useWorkflowNavigationCommands } from './commandBar/useWorkflowNavigationCommands';
+import { useTemplateCommands } from './commandBar/useTemplateCommands';
+import { useBaseCommands } from './commandBar/useBaseCommands';
+import { useDataTableNavigationCommands } from './commandBar/useDataTableNavigationCommands';
+import { useCredentialNavigationCommands } from './commandBar/useCredentialNavigationCommands';
+import type { CommandGroup } from './commandBar/types';
+import { usePostHog } from '@/stores/posthog.store';
+
+export function useCommandBar() {
+ const nodeTypesStore = useNodeTypesStore();
+ const projectsStore = useProjectsStore();
+ const router = useRouter();
+ const route = useRoute();
+ const postHog = usePostHog();
+
+ const isEnabled = computed(() =>
+ postHog.isVariantEnabled(COMMAND_BAR_EXPERIMENT.name, COMMAND_BAR_EXPERIMENT.variant),
+ );
+
+ const activeNodeId = ref(null);
+ const lastQuery = ref('');
+
+ const personalProjectId = computed(() => {
+ return projectsStore.myProjects.find((p) => p.type === 'personal')?.id;
+ });
+
+ const currentProjectName = computed(() => {
+ if (!route.params.projectId || route.params.projectId === personalProjectId.value) {
+ return 'Personal';
+ }
+ return (
+ projectsStore.myProjects.find((p) => p.id === route.params.projectId)?.name ?? 'Personal'
+ );
+ });
+
+ const baseCommandGroup = useBaseCommands();
+ const nodeCommandGroup = useNodeCommands();
+ const workflowCommandGroup = useWorkflowCommands();
+ const workflowNavigationGroup = useWorkflowNavigationCommands({
+ lastQuery,
+ activeNodeId,
+ currentProjectName,
+ });
+ const templateCommandGroup = useTemplateCommands();
+ const dataTableNavigationGroup = useDataTableNavigationCommands({
+ lastQuery,
+ activeNodeId,
+ currentProjectName,
+ });
+ const credentialNavigationGroup = useCredentialNavigationCommands({
+ lastQuery,
+ activeNodeId,
+ currentProjectName,
+ });
+
+ const canvasViewGroups: CommandGroup[] = [
+ baseCommandGroup,
+ nodeCommandGroup,
+ workflowCommandGroup,
+ templateCommandGroup,
+ ];
+
+ const workflowsListViewGroups: CommandGroup[] = [
+ baseCommandGroup,
+ workflowNavigationGroup,
+ dataTableNavigationGroup,
+ ];
+
+ const credentialsListViewGroups: CommandGroup[] = [
+ credentialNavigationGroup,
+ workflowNavigationGroup,
+ dataTableNavigationGroup,
+ baseCommandGroup,
+ ];
+
+ const activeCommandGroups = computed(() => {
+ if (router.currentRoute.value.name === VIEWS.WORKFLOW) {
+ return canvasViewGroups;
+ } else if (
+ router.currentRoute.value.name === VIEWS.WORKFLOWS ||
+ router.currentRoute.value.name === VIEWS.PROJECTS_WORKFLOWS
+ ) {
+ return workflowsListViewGroups;
+ } else if (
+ router.currentRoute.value.name === VIEWS.CREDENTIALS ||
+ router.currentRoute.value.name === VIEWS.PROJECTS_CREDENTIALS
+ ) {
+ return credentialsListViewGroups;
+ }
+ return [baseCommandGroup];
+ });
+
+ const items = computed(() => {
+ return activeCommandGroups.value.flatMap((group) => group.commands.value);
+ });
+
+ function onCommandBarChange(query: string) {
+ for (const group of activeCommandGroups.value) {
+ if (group.handlers?.onCommandBarChange) {
+ group.handlers.onCommandBarChange(query);
+ }
+ }
+ }
+
+ function onCommandBarNavigateTo(to: string | null) {
+ for (const group of activeCommandGroups.value) {
+ if (group.handlers?.onCommandBarNavigateTo) {
+ group.handlers.onCommandBarNavigateTo(to);
+ }
+ }
+ }
+
+ async function initialize() {
+ await nodeTypesStore.loadNodeTypesIfNotLoaded();
+ }
+
+ return {
+ isEnabled,
+ items,
+ initialize,
+ onCommandBarChange,
+ onCommandBarNavigateTo,
+ };
+}
diff --git a/packages/frontend/editor-ui/src/constants.ts b/packages/frontend/editor-ui/src/constants.ts
index 46af328c219..b48e9eb26b0 100644
--- a/packages/frontend/editor-ui/src/constants.ts
+++ b/packages/frontend/editor-ui/src/constants.ts
@@ -779,6 +779,12 @@ export const NDV_IN_FOCUS_PANEL_EXPERIMENT = {
variant: 'variant',
};
+export const COMMAND_BAR_EXPERIMENT = {
+ name: 'command_bar',
+ control: 'control',
+ variant: 'variant',
+};
+
export const NDV_UI_OVERHAUL_EXPERIMENT = {
name: '029_ndv_ui_overhaul',
control: 'control',
diff --git a/packages/frontend/editor-ui/src/types/canvas.ts b/packages/frontend/editor-ui/src/types/canvas.ts
index a45c72b0d1d..4100aed1794 100644
--- a/packages/frontend/editor-ui/src/types/canvas.ts
+++ b/packages/frontend/editor-ui/src/types/canvas.ts
@@ -179,12 +179,14 @@ export type CanvasEventBusEvents = {
'saved:workflow': never;
'open:execution': IExecutionResponse;
'nodes:select': { ids: string[]; panIntoView?: boolean };
+ 'nodes:selectAll': never;
'nodes:action': {
ids: string[];
action: keyof CanvasNodeEventBusEvents;
payload?: CanvasNodeEventBusEvents[keyof CanvasNodeEventBusEvents];
};
tidyUp: { source: CanvasLayoutSource; nodeIdsFilter?: string[]; trackEvents?: boolean };
+ 'create:sticky': never;
};
export interface CanvasNodeInjectionData {
diff --git a/packages/frontend/editor-ui/src/types/telemetry.ts b/packages/frontend/editor-ui/src/types/telemetry.ts
index 0533e026e04..b479d2778b9 100644
--- a/packages/frontend/editor-ui/src/types/telemetry.ts
+++ b/packages/frontend/editor-ui/src/types/telemetry.ts
@@ -8,6 +8,7 @@ export type TelemetryNdvSource =
| 'canvas_zoomed_view'
| 'focus_panel'
| 'logs_view'
+ | 'command_bar'
| 'other';
export type TelemetryContext = Partial<{
diff --git a/packages/frontend/editor-ui/src/views/NodeView.vue b/packages/frontend/editor-ui/src/views/NodeView.vue
index 5c6570bc86e..5f65a17e134 100644
--- a/packages/frontend/editor-ui/src/views/NodeView.vue
+++ b/packages/frontend/editor-ui/src/views/NodeView.vue
@@ -1449,6 +1449,16 @@ function removeSourceControlEventBindings() {
sourceControlEventBus.off('pull', onSourceControlPull);
}
+/**
+ * Command bar
+ * */
+function addCommandBarEventBindings() {
+ canvasEventBus.on('create:sticky', onCreateSticky);
+}
+function removeCommandBarEventBindings() {
+ canvasEventBus.off('create:sticky', onCreateSticky);
+}
+
/**
* Post message events
*/
@@ -1914,10 +1924,11 @@ onMounted(() => {
addBeforeUnloadEventBindings();
addImportEventBindings();
addExecutionOpenedEventBindings();
+ addCommandBarEventBindings();
registerCustomActions();
});
-onActivated(async () => {
+onActivated(() => {
addUndoRedoEventBindings();
showAddFirstStepIfEnabled();
});
@@ -1934,6 +1945,7 @@ onBeforeUnmount(() => {
removeBeforeUnloadEventBindings();
removeImportEventBindings();
removeExecutionOpenedEventBindings();
+ removeCommandBarEventBindings();
unregisterCustomActions();
if (!isDemoRoute.value) {
pushConnectionStore.pushDisconnect();