feat(editor): In progress version of the Command bar (no-changelog) (#20198)

Co-authored-by: Jaakko Husso <jaakko@n8n.io>
This commit is contained in:
Svetoslav Dekov 2025-10-01 14:31:52 +03:00 committed by GitHub
parent 071dcd836d
commit b2a4acdabb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1992 additions and 3 deletions

View File

@ -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: '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/></svg>',
},
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:
'<n8n-command-bar v-bind="args" @input-change="onInputChange" @navigate-to="onNavigateTo" @load-more="onLoadMore" />',
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: `
<div>
<p style="margin-bottom: 20px; color: var(--color-text-base);">
Press <kbd style="background: var(--color-background-base); padding: 2px 6px; border-radius: 3px;"> + K</kbd>
or <kbd style="background: var(--color-background-base); padding: 2px 6px; border-radius: 3px;">Ctrl + K</kbd>
to open the command bar. Use arrow keys to navigate and Enter to select.
</p>
<n8n-command-bar
placeholder="Try pressing Cmd/Ctrl + K!"
:items="items"
@input-change="onInputChange"
@navigate-to="onNavigateTo"
@load-more="onLoadMore"
/>
</div>
`,
methods: {
onInputChange: action('input-change'),
onNavigateTo: action('navigate-to'),
onLoadMore: action('load-more'),
},
});
export const SectionGrouping: StoryFn = () => ({
components: {
N8nCommandBar,
},
data: () => ({
items: sampleItems,
}),
template: `
<div>
<p style="margin-bottom: 20px; color: var(--color-text-base);">
This example shows how items are grouped by sections:
<br/> <strong>Recent items</strong> (no section) appear first
<br/> Then items are grouped by <strong>Actions</strong>, <strong>Navigation</strong>, <strong>Tools</strong>, and <strong>Settings</strong> sections
</p>
<n8n-command-bar
placeholder="Search commands... (sections will be grouped)"
:items="items"
@input-change="onInputChange"
@navigate-to="onNavigateTo"
@load-more="onLoadMore"
/>
</div>
`,
methods: {
onInputChange: action('input-change'),
onNavigateTo: action('navigate-to'),
onLoadMore: action('load-more'),
},
});

View File

@ -0,0 +1,403 @@
<script lang="ts" setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import N8nCommandBarItem from './CommandBarItem.vue';
import type { CommandBarItem } from './types';
import N8nBadge from '../N8nBadge';
interface CommandBarProps {
placeholder?: string;
context?: string;
items: CommandBarItem[];
}
defineOptions({ name: 'N8nCommandBar' });
const props = withDefaults(defineProps<CommandBarProps>(), {
placeholder: 'Type a command...',
context: '',
});
const emit = defineEmits<{
inputChange: [value: string];
navigateTo: [parentId: string | null];
loadMore: [parentId: string];
}>();
const isOpen = ref(false);
const inputRef = ref<HTMLInputElement>();
const selectedIndex = ref(-1);
const inputValue = ref('');
const currentParentId = ref<string | null>(null);
const currentParent = computed(() => {
return props.items.find((item) => item.id === currentParentId.value);
});
const currentItems = computed(() => {
return currentParent.value ? (currentParent.value.children ?? []) : props.items;
});
const currentPlaceholder = computed(() => {
return currentParent.value?.placeholder ?? props.placeholder;
});
const commandBarRef = ref<HTMLElement>();
const itemsListRef = ref<HTMLElement>();
const filteredItems = computed(() => {
let items = currentItems.value;
if (inputValue.value) {
const query = inputValue.value.toLowerCase();
items = items.filter((item) => {
const searchText = [item.title, ...(item.keywords ?? [])]
.filter(Boolean)
.join(' ')
.toLowerCase();
return searchText.includes(query);
});
}
return items;
});
const groupedItems = computed(() => {
const items = filteredItems.value;
const ungrouped: CommandBarItem[] = [];
const sections: Record<string, CommandBarItem[]> = {};
items.forEach((item) => {
if (item.section) {
if (!sections[item.section]) {
sections[item.section] = [];
}
sections[item.section].push(item);
} else {
ungrouped.push(item);
}
});
return {
ungrouped,
sections: Object.entries(sections).map(([title, items]) => ({
title,
items,
})),
};
});
const flattenedItems = computed(() => {
const result: CommandBarItem[] = [];
result.push(...groupedItems.value.ungrouped);
groupedItems.value.sections.forEach((section) => {
result.push(...section.items);
});
return result;
});
const getGlobalIndex = (item: CommandBarItem): number => {
return flattenedItems.value.findIndex((flatItem) => flatItem.id === item.id);
};
const scrollSelectedIntoView = () => {
if (selectedIndex.value < 0) return;
void nextTick(() => {
if (selectedIndex.value === 0) {
itemsListRef.value?.scrollTo({
top: 0,
behavior: 'smooth',
});
return;
} else if (selectedIndex.value === flattenedItems.value.length - 1) {
itemsListRef.value?.scrollTo({
top: itemsListRef.value.scrollHeight,
behavior: 'smooth',
});
return;
}
const selectedItem = flattenedItems.value[selectedIndex.value];
if (!selectedItem) return;
const selectedElement = document.querySelector(`[data-item-id="${selectedItem.id}"]`);
if (selectedElement) {
selectedElement.scrollIntoView({
behavior: 'smooth',
block: 'nearest',
});
}
});
};
const openCommandBar = async () => {
isOpen.value = true;
selectedIndex.value = 0;
inputValue.value = '';
await nextTick();
inputRef.value?.focus();
};
const closeCommandBar = () => {
isOpen.value = false;
selectedIndex.value = -1;
inputValue.value = '';
currentParentId.value = null;
};
const handleScroll = (event: Event) => {
if (!(event.target instanceof HTMLElement)) return;
const target = event.target;
const { scrollTop, scrollHeight, clientHeight } = target;
if (scrollHeight - scrollTop - clientHeight < 50) {
if (currentParent.value?.hasMoreChildren) {
emit('loadMore', currentParent.value.id);
}
}
};
const navigateToChildren = (item: CommandBarItem) => {
currentParentId.value = item.id;
selectedIndex.value = 0;
inputValue.value = '';
emit('navigateTo', item.id);
};
const navigateBack = () => {
if (!currentParent.value) return;
currentParentId.value = null;
selectedIndex.value = 0;
inputValue.value = '';
emit('navigateTo', null);
};
const selectItem = (item: CommandBarItem) => {
if (item.children) {
navigateToChildren(item);
return;
}
if (item.handler) {
void item.handler();
}
if (item.href) {
window.location.href = item.href;
}
closeCommandBar();
};
const handleKeydown = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'k') {
event.preventDefault();
void openCommandBar();
return;
}
if (!isOpen.value) return;
switch (event.key) {
case 'Escape':
event.preventDefault();
void closeCommandBar();
break;
case 'ArrowDown':
event.preventDefault();
selectedIndex.value = Math.min(selectedIndex.value + 1, flattenedItems.value.length - 1);
scrollSelectedIntoView();
break;
case 'ArrowUp':
event.preventDefault();
selectedIndex.value = Math.max(selectedIndex.value - 1, 0);
scrollSelectedIntoView();
break;
case 'ArrowLeft':
if (!inputValue.value && currentParent.value) {
event.preventDefault();
void navigateBack();
}
break;
case 'ArrowRight':
if (selectedIndex.value >= 0 && flattenedItems.value[selectedIndex.value]) {
const selectedItem = flattenedItems.value[selectedIndex.value];
if (selectedItem.children) {
event.preventDefault();
void navigateToChildren(selectedItem);
}
}
break;
case 'Enter':
event.preventDefault();
if (selectedIndex.value >= 0 && flattenedItems.value[selectedIndex.value]) {
void selectItem(flattenedItems.value[selectedIndex.value]);
}
break;
}
};
const handleClickOutside = (event: MouseEvent) => {
if (!isOpen.value) return;
if (commandBarRef.value && !commandBarRef.value.contains(event.target as Node)) {
closeCommandBar();
}
};
watch(inputValue, (newValue) => {
emit('inputChange', newValue);
selectedIndex.value = 0;
});
onMounted(() => {
document.addEventListener('keydown', handleKeydown);
document.addEventListener('click', handleClickOutside);
});
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown);
document.removeEventListener('click', handleClickOutside);
});
</script>
<template>
<Teleport to="body">
<Transition name="command-bar" appear>
<div v-if="isOpen" ref="commandBarRef" :class="$style.commandBar">
<div v-if="context" :class="$style.contextContainer">
<N8nBadge size="small">{{ context }}</N8nBadge>
</div>
<input
ref="inputRef"
v-model="inputValue"
:placeholder="currentPlaceholder"
:class="$style.input"
type="text"
/>
<div
v-if="flattenedItems.length > 0"
ref="itemsListRef"
:class="$style.itemsList"
@scroll="handleScroll"
>
<template v-for="item in groupedItems.ungrouped" :key="item.id">
<N8nCommandBarItem
:item="item"
:is-selected="getGlobalIndex(item) === selectedIndex"
@select="selectItem"
/>
</template>
<template v-for="section in groupedItems.sections" :key="section.title">
<div :class="$style.sectionHeader">{{ section.title }}</div>
<div v-for="item in section.items" :key="item.id">
<N8nCommandBarItem
:item="item"
:is-selected="getGlobalIndex(item) === selectedIndex"
@select="selectItem"
/>
</div>
</template>
</div>
<div v-else-if="inputValue && flattenedItems.length === 0" :class="$style.noResults">
No results found
</div>
</div>
</Transition>
</Teleport>
</template>
<style lang="scss" module>
.commandBar {
position: fixed;
top: 20vh;
left: 50%;
transform: translateX(-50%);
background: var(--color-background-xlight);
border: var(--border-base);
border-radius: var(--border-radius-large);
box-shadow: var(--box-shadow-dark);
width: 100%;
max-width: 600px;
z-index: 1000;
}
.input {
width: 100%;
border: none;
outline: none;
background: transparent;
font-size: var(--font-size-m);
font-family: var(--font-family);
color: var(--color-text-base);
padding: var(--spacing-m) var(--spacing-l);
border-bottom: var(--border-base);
&::placeholder {
color: var(--color-text-light);
}
}
.itemsList {
max-height: 300px;
overflow-y: auto;
padding-bottom: var(--spacing-s);
}
.sectionHeader {
padding: var(--spacing-xs) var(--spacing-l);
font-size: var(--font-size-2xs);
font-weight: var(--font-weight-regular);
color: var(--color-text-light);
}
.noResults {
padding: var(--spacing-l);
text-align: center;
color: var(--color-text-light);
font-size: var(--font-size-s);
}
.contextContainer {
padding: var(--spacing-xs) var(--spacing-l) 0;
}
</style>
<style lang="scss">
/* Global transition classes for command bar animations */
.command-bar-enter-active {
transition:
opacity 0.2s ease-out,
transform 0.2s ease-out;
}
.command-bar-leave-active {
transition:
opacity 0.15s ease-in,
transform 0.15s ease-in;
}
.command-bar-enter-from {
opacity: 0;
transform: translateX(-50%) translateY(-20px) scale(0.95);
}
.command-bar-leave-to {
opacity: 0;
transform: translateX(-50%) translateY(-10px) scale(0.98);
}
.command-bar-enter-to,
.command-bar-leave-from {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
</style>

View File

@ -0,0 +1,109 @@
<script lang="ts" setup>
import type { CommandBarItem } from './types';
const props = defineProps<{
item: CommandBarItem;
isSelected: boolean;
}>();
const emit = defineEmits<{
select: [item: CommandBarItem];
}>();
const handleSelect = () => {
emit('select', props.item);
};
</script>
<template>
<div
:key="item.id"
:data-item-id="item.id"
:class="[$style.item, { [$style.selected]: isSelected }]"
@click.stop="handleSelect"
>
<div v-if="item.icon" :class="$style.icon">
<span v-if="item.icon && 'html' in item.icon" v-n8n-html="item.icon.html"></span>
<component
:is="item.icon.component"
v-else-if="item.icon && 'component' in item.icon"
v-bind="item.icon.props"
/>
</div>
<div :class="$style.content">
<div :class="$style.title">{{ item.title }}</div>
</div>
</div>
</template>
<style lang="scss" module>
.item {
display: flex;
gap: var(--spacing-2xs);
align-items: center;
height: var(--spacing-2xl);
padding: 0 var(--spacing-s);
cursor: pointer;
position: relative;
margin-left: var(--border-width-base);
transition: background-color 0.1s ease;
&::before {
content: '';
position: absolute;
left: calc(-1 * var(--border-width-base));
top: 0;
bottom: 0;
width: calc(2 * var(--border-width-base));
background-color: transparent;
transition: background-color 0.1s ease;
}
&:hover {
background-color: var(--color-foreground-base);
&::before {
background-color: var(--color-foreground-dark);
}
}
&.selected {
background-color: var(--color-foreground-base);
&::before {
background-color: var(--color-primary);
}
}
}
.icon {
display: flex;
align-items: center;
justify-content: center;
width: var(--spacing-l);
height: var(--spacing-l);
flex-shrink: 0;
}
.content {
flex: 1;
min-width: 0;
}
.title {
font-size: var(--font-size-s);
font-weight: var(--font-weight-regular);
color: var(--color-text-dark);
line-height: var(--font-line-height-compact);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
display: flex;
align-items: center;
gap: var(--spacing-2xs);
flex-shrink: 0;
}
</style>

View File

@ -0,0 +1,3 @@
import N8nCommandBar from './CommandBar.vue';
export default N8nCommandBar;

View File

@ -0,0 +1,14 @@
import type { Component } from 'vue';
export interface CommandBarItem {
id: string;
title: string;
icon?: { html: string } | { component: Component; props?: Record<string, unknown> };
section?: string;
keywords?: string[];
handler?: () => void | Promise<void>;
href?: string;
children?: CommandBarItem[];
placeholder?: string;
hasMoreChildren?: boolean;
}

View File

@ -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';

View File

@ -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"
}

View File

@ -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
<div :id="APP_MODALS_ELEMENT_ID" :class="$style.modals">
<Modals />
</div>
<N8nCommandBar
v-if="showCommandBar"
:items="items"
@input-change="onCommandBarChange"
@navigate-to="onCommandBarNavigateTo"
/>
<Telemetry />
<AskAssistantFloatingButton v-if="assistantStore.isFloatingButtonShown" />
</div>

View File

@ -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);
});

View File

@ -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<CommandBarItem[]>;
handlers?: CommandBarEventHandlers;
}

View File

@ -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,
};
}

View File

@ -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<string>;
activeNodeId: Ref<string | null>;
currentProjectName: Ref<string>;
}): 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<ICredentialsResponse[]>([]);
const personalProjectId = computed(() => {
return projectsStore.myProjects.find((p) => p.type === 'personal')?.id;
});
function orderResultByCurrentProjectFirst<T extends ICredentialsResponse>(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<CommandBarItem[]>(() => {
return credentialResults.value.map((credential) => createCredentialCommand(credential, false));
});
const rootCredentialItems = computed<CommandBarItem[]>(() => {
if (lastQuery.value.length <= 2) {
return [];
}
return credentialResults.value.map((credential) => createCredentialCommand(credential, true));
});
const credentialNavigationCommands = computed<CommandBarItem[]>(() => {
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,
},
};
}

View File

@ -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<string>;
activeNodeId: Ref<string | null>;
currentProjectName: Ref<string>;
}): CommandGroup {
const i18n = useI18n();
const { lastQuery, activeNodeId, currentProjectName } = options;
const dataStoreStore = useDataStoreStore();
const projectsStore = useProjectsStore();
const router = useRouter();
const route = useRoute();
const dataTableResults = ref<DataStore[]>([]);
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<T extends DataStore>(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<CommandBarItem[]>(() => {
return dataTableResults.value.map((dataTable) => createDataTableCommand(dataTable, false));
});
const rootDataTableItems = computed<CommandBarItem[]>(() => {
if (lastQuery.value.length <= 2) {
return [];
}
return dataTableResults.value.map((dataTable) => createDataTableCommand(dataTable, true));
});
const dataTableNavigationCommands = computed<CommandBarItem[]>(() => {
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,
},
};
}

View File

@ -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<CommandBarItem[]>(() => {
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: `<img src="${src.path}" style="width: 24px;object-fit: contain;height: 24px;" />`,
}
: undefined,
handler: async () => {
await addNodes([{ type: name }]);
},
};
});
});
const openNodeCommands = computed<CommandBarItem[]>(() => {
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: `<img src="${src.path}" style="width: 24px;object-fit: contain;height: 24px;" />`,
}
: undefined,
handler: () => {
setNodeActive(id, 'command_bar');
},
placeholder: i18n.baseText('commandBar.nodes.searchPlaceholder'),
};
});
});
const nodeCommands = computed<CommandBarItem[]>(() => {
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,
};
}

View File

@ -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,
};
}

View File

@ -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<CommandBarItem[]>(() => {
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<CommandBarItem[]>(() => {
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<CommandBarItem[]>(() => {
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,
};
}

View File

@ -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<string>;
activeNodeId: Ref<string | null>;
currentProjectName: Ref<string>;
}): 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<IWorkflowDb[]>([]);
const personalProjectId = computed(() => {
return projectsStore.myProjects.find((p) => p.type === 'personal')?.id;
});
function orderResultByCurrentProjectFirst<T extends IWorkflowDb>(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<CommandBarItem[]>(() => {
return workflowResults.value.map((workflow) => createWorkflowCommand(workflow, false));
});
const rootWorkflowItems = computed<CommandBarItem[]>(() => {
if (lastQuery.value.length <= 2) {
return [];
}
return workflowResults.value.map((workflow) => createWorkflowCommand(workflow, true));
});
const workflowNavigationCommands = computed<CommandBarItem[]>(() => {
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,
},
};
}

View File

@ -17,6 +17,7 @@ export type CanvasLayoutSource =
| 'keyboard-shortcut'
| 'canvas-button'
| 'context-menu'
| 'command-bar'
| 'import-workflow-data';
export type CanvasLayoutTargetData = {
nodes: Array<GraphNode<CanvasNodeData>>;

View File

@ -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<string | null>(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<CommandGroup[]>(() => {
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<CommandBarItem[]>(() => {
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,
};
}

View File

@ -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',

View File

@ -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 {

View File

@ -8,6 +8,7 @@ export type TelemetryNdvSource =
| 'canvas_zoomed_view'
| 'focus_panel'
| 'logs_view'
| 'command_bar'
| 'other';
export type TelemetryContext = Partial<{

View File

@ -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();