From f0ea4ed1f072cef6acfc95245a65e1683962eb6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milorad=20FIlipovi=C4=87?= Date: Wed, 27 May 2026 20:36:37 +0200 Subject: [PATCH] feat(editor): Introduce new project/folder actions menu (#30614) --- .../mcp.settings.controller.api.test.ts | 40 +- .../__tests__/mcp.settings.controller.test.ts | 4 + .../__tests__/mcp.settings.service.test.ts | 38 +- .../src/modules/mcp/mcp.settings.service.ts | 14 +- .../frontend/@n8n/i18n/src/locales/en.json | 23 + .../src/app/components/WorkflowCard.test.ts | 3 + .../src/app/views/WorkflowsView.test.ts | 18 +- .../editor-ui/src/app/views/WorkflowsView.vue | 396 ++++++++++++++++-- .../src/features/ai/mcpAccess/mcp.api.ts | 2 + .../features/ai/mcpAccess/mcp.store.test.ts | 44 +- .../src/features/ai/mcpAccess/mcp.store.ts | 14 +- .../folders/components/FolderBreadcrumbs.vue | 92 +++- .../core/folders/components/FolderCard.vue | 132 +++++- .../folders/components/ProjectBreadcrumb.vue | 6 + .../core/folders/folders.constants.ts | 6 + .../testing/playwright/pages/WorkflowsPage.ts | 4 - .../tests/e2e/projects/folders-basic.spec.ts | 10 - 17 files changed, 728 insertions(+), 118 deletions(-) diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.api.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.api.test.ts index 690db15efba..dad2173443e 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.api.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.api.test.ts @@ -357,9 +357,11 @@ describe('PATCH /mcp/workflows/toggle-access', () => { expect(response.statusCode).toBe(200); expect(response.body.data.updatedCount).toBe(2); + expect(response.body.data.unchangedCount).toBe(0); expect(response.body.data.updatedIds.sort()).toEqual( [ownedByMember.id, ownedByMember2.id].sort(), ); + expect(response.body.data.unchangedIds).toEqual([]); expect(response.body.data.skippedCount).toBe(1); expect(await readAvailableInMCP(ownedByMember.id)).toBe(true); @@ -389,6 +391,8 @@ describe('PATCH /mcp/workflows/toggle-access', () => { expect(response.statusCode).toBe(200); expect(response.body.data.updatedIds).toEqual([live.id]); + expect(response.body.data.unchangedIds).toEqual([]); + expect(response.body.data.unchangedCount).toBe(0); expect(response.body.data.skippedCount).toBe(1); expect(await readAvailableInMCP(archived.id)).toBeUndefined(); }); @@ -407,7 +411,12 @@ describe('PATCH /mcp/workflows/toggle-access', () => { .send({ availableInMCP: true, projectId: project.id }); expect(response.statusCode).toBe(200); - expect(response.body.data).toEqual({ updatedCount: 2, skippedCount: 0, failedCount: 0 }); + expect(response.body.data).toEqual({ + updatedCount: 2, + unchangedCount: 0, + skippedCount: 0, + failedCount: 0, + }); expect(await readAvailableInMCP(projectWf1.id)).toBe(true); expect(await readAvailableInMCP(projectWf2.id)).toBe(true); expect(await readAvailableInMCP(unrelatedWf.id)).toBeUndefined(); @@ -437,7 +446,12 @@ describe('PATCH /mcp/workflows/toggle-access', () => { .send({ availableInMCP: true, folderId: rootFolder.id }); expect(response.statusCode).toBe(200); - expect(response.body.data).toEqual({ updatedCount: 2, skippedCount: 0, failedCount: 0 }); + expect(response.body.data).toEqual({ + updatedCount: 2, + unchangedCount: 0, + skippedCount: 0, + failedCount: 0, + }); expect(await readAvailableInMCP(workflowInRoot.id)).toBe(true); expect(await readAvailableInMCP(workflowInChild.id)).toBe(true); expect(await readAvailableInMCP(workflowOutsideFolder.id)).toBeUndefined(); @@ -470,9 +484,10 @@ describe('PATCH /mcp/workflows/toggle-access', () => { .send({ availableInMCP: true, folderId: folder.id }); expect(response.statusCode).toBe(200); - // Folder-scoped — `updatedIds` is omitted from the response. + // Folder-scoped — workflow id arrays are omitted from the response. expect(response.body.data).toEqual({ updatedCount: 1, + unchangedCount: 0, skippedCount: 0, failedCount: 0, }); @@ -492,6 +507,8 @@ describe('PATCH /mcp/workflows/toggle-access', () => { expect(response.statusCode).toBe(200); expect(response.body.data.updatedIds).toEqual([wf.id]); + expect(response.body.data.unchangedIds).toEqual([]); + expect(response.body.data.unchangedCount).toBe(0); expect(await readAvailableInMCP(wf.id)).toBe(false); }); @@ -514,13 +531,13 @@ describe('PATCH /mcp/workflows/toggle-access', () => { }); expect(response.statusCode).toBe(200); - expect(response.body.data.updatedCount).toBe(2); - expect(response.body.data.updatedIds.sort()).toEqual( - [alreadyEnabled.id, freshlyChanged.id].sort(), - ); + expect(response.body.data.updatedCount).toBe(1); + expect(response.body.data.unchangedCount).toBe(1); + expect(response.body.data.updatedIds).toEqual([freshlyChanged.id]); + expect(response.body.data.unchangedIds).toEqual([alreadyEnabled.id]); expect(response.body.data.skippedCount).toBe(0); - // Re-submitting the same request is a no-op but still reports both as updated. + // Re-submitting the same request is a no-op and reports both as unchanged. const second = await testServer .authAgentFor(toggleOwner) .patch('/mcp/workflows/toggle-access') @@ -530,7 +547,12 @@ describe('PATCH /mcp/workflows/toggle-access', () => { }); expect(second.statusCode).toBe(200); - expect(second.body.data.updatedCount).toBe(2); + expect(second.body.data.updatedCount).toBe(0); + expect(second.body.data.unchangedCount).toBe(2); + expect(second.body.data.updatedIds).toEqual([]); + expect(second.body.data.unchangedIds.sort()).toEqual( + [alreadyEnabled.id, freshlyChanged.id].sort(), + ); expect(second.body.data.skippedCount).toBe(0); }); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts index 5660eb5b674..53777de18d1 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.settings.controller.test.ts @@ -329,6 +329,8 @@ describe('McpSettingsController', () => { const bulkResult = { updatedCount: 2, updatedIds: ['wf-1', 'wf-2'], + unchangedCount: 0, + unchangedIds: [], skippedCount: 0, failedCount: 0, changedWorkflows: [ @@ -357,6 +359,8 @@ describe('McpSettingsController', () => { expect(result).toEqual({ updatedCount: 2, updatedIds: ['wf-1', 'wf-2'], + unchangedCount: 0, + unchangedIds: [], skippedCount: 0, failedCount: 0, }); diff --git a/packages/cli/src/modules/mcp/__tests__/mcp.settings.service.test.ts b/packages/cli/src/modules/mcp/__tests__/mcp.settings.service.test.ts index 79cead202e1..b0620856443 100644 --- a/packages/cli/src/modules/mcp/__tests__/mcp.settings.service.test.ts +++ b/packages/cli/src/modules/mcp/__tests__/mcp.settings.service.test.ts @@ -194,6 +194,8 @@ describe('McpSettingsService', () => { expect(result).toEqual({ updatedCount: 2, updatedIds: ['wf-1', 'wf-2'], + unchangedCount: 0, + unchangedIds: [], // wf-unauthorized was in the request but filtered out — counts as skipped. skippedCount: 1, failedCount: 0, @@ -285,6 +287,8 @@ describe('McpSettingsService', () => { expect(result).toEqual({ updatedCount: 1, updatedIds: ['wf-1'], + unchangedCount: 0, + unchangedIds: [], skippedCount: 1, failedCount: 0, changedWorkflows: [ @@ -321,11 +325,11 @@ describe('McpSettingsService', () => { expect(stubs.find).toHaveBeenCalledTimes(2); expect(stubs.find.mock.calls[0][1].select).toEqual(['id', 'settings']); expect(stubs.find.mock.calls[1][1].where.id.value).toEqual(['wf-1']); - // Both ids are reported as updated (the DB is in the requested state - // for both). No-ops go last in the list. expect(result).toEqual({ - updatedCount: 2, - updatedIds: ['wf-1', 'wf-2'], + updatedCount: 1, + updatedIds: ['wf-1'], + unchangedCount: 1, + unchangedIds: ['wf-2'], skippedCount: 0, failedCount: 0, changedWorkflows: [ @@ -358,8 +362,10 @@ describe('McpSettingsService', () => { expect(stubs.update).not.toHaveBeenCalled(); expect(stubs.find).toHaveBeenCalledTimes(1); expect(result).toEqual({ - updatedCount: 2, - updatedIds: ['wf-1', 'wf-2'], + updatedCount: 0, + updatedIds: [], + unchangedCount: 2, + unchangedIds: ['wf-1', 'wf-2'], skippedCount: 0, failedCount: 0, changedWorkflows: [], @@ -408,13 +414,14 @@ describe('McpSettingsService', () => { expect(stubs.manager.transaction).not.toHaveBeenCalled(); expect(result).toEqual({ updatedCount: 0, + unchangedCount: 0, skippedCount: 0, failedCount: 0, changedWorkflows: [], }); }); - test('omits updatedIds from the response when scoped by projectId', async () => { + test('omits workflow ids from the response when scoped by projectId', async () => { setupRepository([ { id: 'wf-1', settings: {} }, { id: 'wf-2', settings: {} }, @@ -430,6 +437,7 @@ describe('McpSettingsService', () => { expect(result).toEqual({ updatedCount: 2, + unchangedCount: 0, skippedCount: 0, failedCount: 0, changedWorkflows: [ @@ -446,9 +454,10 @@ describe('McpSettingsService', () => { ], }); expect(result).not.toHaveProperty('updatedIds'); + expect(result).not.toHaveProperty('unchangedIds'); }); - test('omits updatedIds from the response when scoped by folderId', async () => { + test('omits workflow ids from the response when scoped by folderId', async () => { setupRepository([{ id: 'wf-1', settings: {} }]); workflowFinderService.findAllWorkflowIdsForUser.mockResolvedValue(['wf-1']); @@ -461,6 +470,7 @@ describe('McpSettingsService', () => { expect(result).toEqual({ updatedCount: 1, + unchangedCount: 0, skippedCount: 0, failedCount: 0, changedWorkflows: [ @@ -472,6 +482,7 @@ describe('McpSettingsService', () => { ], }); expect(result).not.toHaveProperty('updatedIds'); + expect(result).not.toHaveProperty('unchangedIds'); }); test('resolves candidates via findAllWorkflowIdsForUser when scoped by folderId', async () => { @@ -515,6 +526,7 @@ describe('McpSettingsService', () => { expect(stubs.manager.transaction).not.toHaveBeenCalled(); expect(result).toEqual({ updatedCount: 0, + unchangedCount: 0, skippedCount: 0, failedCount: 0, changedWorkflows: [], @@ -535,14 +547,16 @@ describe('McpSettingsService', () => { expect(stubs.manager.transaction).not.toHaveBeenCalled(); expect(result).toEqual({ updatedCount: 0, + unchangedCount: 0, skippedCount: 0, failedCount: 0, changedWorkflows: [], }); expect(result).not.toHaveProperty('updatedIds'); + expect(result).not.toHaveProperty('unchangedIds'); }); - test('returns an empty updatedIds array when scoped by workflowIds and none are accessible', async () => { + test('returns empty id arrays when scoped by workflowIds and none are accessible', async () => { setupRepository([]); workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(new Set()); @@ -555,10 +569,12 @@ describe('McpSettingsService', () => { expect(result).toEqual({ updatedCount: 0, + unchangedCount: 0, skippedCount: 2, failedCount: 0, changedWorkflows: [], updatedIds: [], + unchangedIds: [], }); }); @@ -629,7 +645,7 @@ describe('McpSettingsService', () => { ); }); - test('surfaces chunk failures in updatedIds when scoped by workflowIds', async () => { + test('surfaces chunk failures in id arrays when scoped by workflowIds', async () => { const stubs = setupRepository([{ id: 'wf-1', settings: {} }]); workflowFinderService.findWorkflowIdsWithScopeForUser.mockResolvedValue(new Set(['wf-1'])); @@ -646,10 +662,12 @@ describe('McpSettingsService', () => { expect(result).toEqual({ updatedCount: 0, + unchangedCount: 0, skippedCount: 0, failedCount: 1, changedWorkflows: [], updatedIds: [], + unchangedIds: [], }); }); diff --git a/packages/cli/src/modules/mcp/mcp.settings.service.ts b/packages/cli/src/modules/mcp/mcp.settings.service.ts index 123878087c1..65c962cb4e1 100644 --- a/packages/cli/src/modules/mcp/mcp.settings.service.ts +++ b/packages/cli/src/modules/mcp/mcp.settings.service.ts @@ -26,10 +26,12 @@ const WORKFLOW_SETTINGS_FIELDS: Array = ['id', 'settings'] type BulkSetAvailableInMCPResult = { updatedCount: number; + unchangedCount: number; skippedCount: number; failedCount: number; changedWorkflows: WorkflowMCPAvailabilityChange[]; updatedIds?: string[]; + unchangedIds?: string[]; }; type WorkflowMCPAvailabilityChange = { @@ -98,10 +100,11 @@ export class McpSettingsService { if (candidateIds.length === 0) { return { updatedCount: 0, + unchangedCount: 0, skippedCount: baselineSize, failedCount: 0, changedWorkflows: [], - ...(isWorkflowIdsScope ? { updatedIds: [] } : {}), + ...(isWorkflowIdsScope ? { updatedIds: [], unchangedIds: [] } : {}), }; } @@ -188,14 +191,13 @@ export class McpSettingsService { } } - const confirmedIds = [...writtenIds, ...noOpIds]; - return { - updatedCount: confirmedIds.length, - skippedCount: Math.max(0, baselineSize - confirmedIds.length - failedCount), + updatedCount: writtenIds.length, + unchangedCount: noOpIds.length, + skippedCount: Math.max(0, baselineSize - writtenIds.length - noOpIds.length - failedCount), failedCount, changedWorkflows, - ...(isWorkflowIdsScope ? { updatedIds: confirmedIds } : {}), + ...(isWorkflowIdsScope ? { updatedIds: writtenIds, unchangedIds: noOpIds } : {}), }; } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index d6a59a5f72c..f74e04765e4 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -4061,6 +4061,29 @@ "workflows.heading": "Workflows", "workflows.add": "Add workflow", "workflows.addFolder": "Add folder", + "resourceActions.mcpAccess.manage": "Manage MCP access", + "resourceActions.mcpAccess.enable": "Enable MCP access", + "resourceActions.mcpAccess.disable": "Disable MCP access", + "resourceActions.mcpAccess.enable.tooltip": "Allow MCP access for all workflows in '{scopeName}'", + "resourceActions.mcpAccess.disable.tooltip": "Disable MCP access for all workflows in '{scopeName}'", + "resourceActions.mcpAccess.success.enabled.title": "MCP access enabled", + "resourceActions.mcpAccess.success.disabled.title": "MCP access disabled", + "resourceActions.mcpAccess.noChanges.title": "No changes needed", + "resourceActions.mcpAccess.partial.title": "Some workflows weren't updated", + "resourceActions.mcpAccess.noWorkflows.title": "No workflows to update", + "resourceActions.mcpAccess.outcome.message": "{summary}

Manage MCP settings", + "resourceActions.mcpAccess.summary.updated.enabled": "Enabled MCP access for 1 workflow in '{scopeName}' | Enabled MCP access for {count} workflows in '{scopeName}'", + "resourceActions.mcpAccess.summary.updated.disabled": "Disabled MCP access for 1 workflow in '{scopeName}' | Disabled MCP access for {count} workflows in '{scopeName}'", + "resourceActions.mcpAccess.summary.unchanged.enabled": "1 workflow in '{scopeName}' already has MCP access enabled | {count} workflows in '{scopeName}' already have MCP access enabled", + "resourceActions.mcpAccess.summary.unchanged.disabled": "1 workflow in '{scopeName}' already doesn't have MCP access | {count} workflows in '{scopeName}' already don't have MCP access", + "resourceActions.mcpAccess.summary.skipped": "1 workflow in '{scopeName}' couldn't be updated | {count} workflows in '{scopeName}' couldn't be updated", + "resourceActions.mcpAccess.noWorkflows.message": "There are no workflows in '{scopeName}' that can be updated.

Manage MCP settings", + "resourceActions.mcpAccess.error.enabled.title": "Error enabling MCP access", + "resourceActions.mcpAccess.error.disabled.title": "Error disabling MCP access", + "resourceActions.mcpAccess.error.enabled.message": "Could not enable MCP access for all workflows in '{scopeName}'.

Manage MCP settings", + "resourceActions.mcpAccess.error.disabled.message": "Could not disable MCP access for all workflows in '{scopeName}'.

Manage MCP settings", + "resourceActions.mcpAccess.disabled.title": "MCP access is off", + "resourceActions.mcpAccess.disabled.message": "Turn on instance-level MCP access before managing workflow access

Manage MCP settings", "workflows.publish": "Publish", "workflows.published": "Published", "workflows.publish.permissionDenied": "You don't have permission to publish this workflow", diff --git a/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts b/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts index ea619ca6f67..6db5d2ece49 100644 --- a/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts +++ b/packages/frontend/editor-ui/src/app/components/WorkflowCard.test.ts @@ -626,6 +626,7 @@ describe('WorkflowCard', () => { mcpStore.toggleWorkflowMcpAccess.mockResolvedValue({ updatedCount: 1, skippedCount: 0, + unchangedCount: 0, failedCount: 0, }); @@ -725,6 +726,7 @@ describe('WorkflowCard', () => { mcpStore.toggleWorkflowMcpAccess.mockResolvedValue({ updatedCount: 1, skippedCount: 0, + unchangedCount: 0, failedCount: 0, }); @@ -1009,6 +1011,7 @@ describe('WorkflowCard', () => { mcpStore.toggleWorkflowMcpAccess.mockResolvedValue({ updatedCount: 1, skippedCount: 0, + unchangedCount: 0, failedCount: 0, }); diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts b/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts index 4f28becba62..8d55ef8764a 100644 --- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts +++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.test.ts @@ -497,20 +497,28 @@ describe('Folders', () => { expect(getByTestId('folder-card-name')).toHaveTextContent(TEST_FOLDER_RESOURCE.name); }); - it('should show "Create folder" button when not in the overview or sharing pages', async () => { + it('should show folder actions menu when not in the overview or sharing pages', async () => { vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false); vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false); + const projectsStore = mockedStore(useProjectsStore); + projectsStore.currentProject = { + id: '1', + name: 'Project 1', + type: 'team', + scopes: ['folder:create'], + } as Project; workflowsListStore.fetchWorkflowsPage.mockResolvedValue([TEST_WORKFLOW_RESOURCE]); - const { getByTestId } = renderComponent({ + const { getByTestId, queryByTestId } = renderComponent({ pinia, }); await waitAllPromises(); - expect(getByTestId('add-folder-button')).toBeInTheDocument(); + expect(queryByTestId('add-folder-button')).not.toBeInTheDocument(); + expect(getByTestId('folder-breadcrumbs-actions')).toBeInTheDocument(); }); - it('should NOT show "Create folder" button when in overview subpage', async () => { + it('should NOT show standalone "Create folder" button when in overview subpage', async () => { vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(true); vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(false); @@ -523,7 +531,7 @@ describe('Folders', () => { expect(queryByTestId('add-folder-button')).not.toBeInTheDocument(); }); - it('should NOT show "Create folder" button when in shared subpage', async () => { + it('should NOT show standalone "Create folder" button when in shared subpage', async () => { vi.spyOn(projectPages, 'isOverviewSubPage', 'get').mockReturnValue(false); vi.spyOn(projectPages, 'isSharedSubPage', 'get').mockReturnValue(true); diff --git a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue index 353f0b8e729..994b58986c1 100644 --- a/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue +++ b/packages/frontend/editor-ui/src/app/views/WorkflowsView.vue @@ -3,7 +3,10 @@ import Draggable from '@/app/components/Draggable.vue'; import EmptySharedSectionActionBox from '@/features/core/folders/components/EmptySharedSectionActionBox.vue'; import FolderBreadcrumbs from '@/features/core/folders/components/FolderBreadcrumbs.vue'; import FolderCard from '@/features/core/folders/components/FolderCard.vue'; -import { FOLDER_LIST_ITEM_ACTIONS } from '@/features/core/folders/folders.constants'; +import { + FOLDER_LIST_ITEM_ACTIONS, + MCP_ACCESS_ACTIONS, +} from '@/features/core/folders/folders.constants'; import ResourcesListLayout from '@/app/components/layouts/ResourcesListLayout.vue'; import ProjectHeader from '@/features/collaboration/projects/components/ProjectHeader.vue'; import WorkflowCard from '@/app/components/WorkflowCard.vue'; @@ -72,6 +75,9 @@ import { useUsageStore } from '@/features/settings/usage/usage.store'; import { useUsersStore } from '@/features/settings/users/users.store'; import { useWorkflowsStore } from '@/app/stores/workflows.store'; import { useWorkflowsListStore } from '@/app/stores/workflowsList.store'; +import { MCP_SETTINGS_VIEW } from '@/features/ai/mcpAccess/mcp.constants'; +import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store'; +import type { ToggleWorkflowsMcpAccessResponse } from '@/features/ai/mcpAccess/mcp.api'; import { type Project, type ProjectSharingData, @@ -139,6 +145,7 @@ const sourceControlStore = useSourceControlStore(); const usersStore = useUsersStore(); const workflowsStore = useWorkflowsStore(); const workflowsListStore = useWorkflowsListStore(); +const mcpStore = useMCPStore(); const settingsStore = useSettingsStore(); const projectsStore = useProjectsStore(); const telemetry = useTelemetry(); @@ -183,6 +190,17 @@ const filters = ref({ const workflowListEventBus = createEventBus(); +type BreadcrumbAction = UserAction & { + children?: BreadcrumbAction[]; + tooltip?: string; +}; + +type McpAccessScope = { + type: 'folder' | 'project'; + id: string; + name: string; +}; + type ResourcesListLayoutExpose = { getScrollContainer?: () => HTMLElement | null; }; @@ -303,6 +321,10 @@ const currentFolderParent = computed(() => { : null; }); +const showMainBreadcrumbs = computed(() => { + return showFolders.value && (!currentFolderId.value || currentFolder.value !== null); +}); + const isDragging = computed(() => { return foldersStore.draggedElement !== null; }); @@ -324,18 +346,21 @@ useAutoScrollOnDrag({ }); const hasPermissionToCreateFolders = computed(() => { - if (!currentProject.value) return false; - return getResourcePermissions(currentProject.value.scopes).folder.create === true; + const project = currentProject.value ?? projectsStore.personalProject; + if (!project) return false; + return getResourcePermissions(project.scopes).folder.create === true; }); const hasPermissionToUpdateFolders = computed(() => { - if (!currentProject.value) return false; - return getResourcePermissions(currentProject.value.scopes).folder.update === true; + const project = currentProject.value ?? projectsStore.personalProject; + if (!project) return false; + return getResourcePermissions(project.scopes).folder.update === true; }); const hasPermissionToDeleteFolders = computed(() => { - if (!currentProject.value) return false; - return getResourcePermissions(currentProject.value.scopes).folder.delete === true; + const project = currentProject.value ?? projectsStore.personalProject; + if (!project) return false; + return getResourcePermissions(project.scopes).folder.delete === true; }); const hasPermissionToCreateWorkflows = computed(() => { @@ -343,20 +368,126 @@ const hasPermissionToCreateWorkflows = computed(() => { return getResourcePermissions(currentProject.value.scopes).workflow.create === true; }); -const currentProject = computed(() => projectsStore.currentProject); - -const projectName = computed(() => { - if (currentProject.value?.type === ProjectTypes.Personal) { - return i18n.baseText('projects.menu.personal'); - } - return currentProject.value?.name; +const hasPermissionToUpdateWorkflows = computed(() => { + const project = currentProject.value ?? projectsStore.personalProject; + if (!project) return false; + return getResourcePermissions(project.scopes).workflow.update === true; }); -const currentParentName = computed(() => { - if (currentFolder.value) { - return currentFolder.value.name; +const currentProject = computed(() => projectsStore.currentProject); + +const currentBreadcrumbsProject = computed( + () => currentProject.value ?? projectsStore.personalProject, +); + +const currentBreadcrumbsProjectName = computed(() => { + const project = currentBreadcrumbsProject.value; + if (!project) return undefined; + + return project.type === ProjectTypes.Personal + ? i18n.baseText('projects.menu.personal') + : project.name; +}); + +const currentParentName = computed( + () => currentFolder.value?.name ?? currentBreadcrumbsProjectName.value, +); + +const projectRootBreadcrumbsActions = computed>>(() => { + const project = currentBreadcrumbsProject.value; + if (!project) return []; + + const actions: Array> = [ + { + label: i18n.baseText('folders.actions.create'), + value: FOLDER_LIST_ITEM_ACTIONS.CREATE, + disabled: readOnlyEnv.value || !hasPermissionToCreateFolders.value, + }, + ]; + + if (project.type !== ProjectTypes.Personal) { + actions.push({ + label: favoritesStore.isFavorite(project.id, 'project') + ? i18n.baseText('favorites.remove') + : i18n.baseText('favorites.add'), + value: FOLDER_LIST_ITEM_ACTIONS.TOGGLE_FAVORITE, + disabled: false, + }); } - return projectName.value; + + return actions; +}); + +const mcpAccessScope = computed(() => { + if (currentFolderId.value) { + if (!currentFolder.value) return null; + + return { + type: 'folder' as const, + id: currentFolder.value.id, + name: currentFolder.value.name, + }; + } + + const project = currentBreadcrumbsProject.value; + const name = currentBreadcrumbsProjectName.value; + + if (!project?.id || !name) return null; + + return { + type: 'project' as const, + id: project.id, + name, + }; +}); + +const showMcpAccessActions = computed( + () => + mcpModuleActive.value && + mcpAccessScope.value !== null && + !projectPages.isOverviewSubPage && + !projectPages.isSharedSubPage && + !readOnlyEnv.value && + hasPermissionToUpdateWorkflows.value, +); + +const settingsLink = computed(() => router.resolve({ name: MCP_SETTINGS_VIEW }).href); + +const mcpAccessBreadcrumbsAction = computed(() => { + if (!showMcpAccessActions.value || !mcpAccessScope.value) return null; + + return { + label: i18n.baseText('resourceActions.mcpAccess.manage'), + value: MCP_ACCESS_ACTIONS.MANAGE, + disabled: false, + children: [ + { + label: i18n.baseText('resourceActions.mcpAccess.enable'), + value: MCP_ACCESS_ACTIONS.ENABLE, + disabled: false, + tooltip: i18n.baseText('resourceActions.mcpAccess.enable.tooltip', { + interpolate: { scopeName: mcpAccessScope.value.name }, + }), + }, + { + label: i18n.baseText('resourceActions.mcpAccess.disable'), + value: MCP_ACCESS_ACTIONS.DISABLE, + disabled: false, + tooltip: i18n.baseText('resourceActions.mcpAccess.disable.tooltip', { + interpolate: { scopeName: mcpAccessScope.value.name }, + }), + }, + ], + }; +}); + +const breadcrumbsActions = computed(() => { + const actions = currentFolder.value + ? mainBreadcrumbsActions.value + : projectRootBreadcrumbsActions.value; + const mcpAction = mcpAccessBreadcrumbsAction.value; + + return mcpAction ? [...actions, mcpAction] : actions; }); const personalProject = computed(() => { @@ -1216,9 +1347,15 @@ const onBreadcrumbItemClick = (item: PathItem) => { // These render next to the breadcrumbs and are applied to the current folder/project const onBreadCrumbsAction = async (action: string) => { switch (action) { + case MCP_ACCESS_ACTIONS.ENABLE: + await toggleMcpAccess(true); + break; + case MCP_ACCESS_ACTIONS.DISABLE: + await toggleMcpAccess(false); + break; case FOLDER_LIST_ITEM_ACTIONS.CREATE: - if (!route.params.projectId) return; - const currentParent = currentFolder.value?.name || projectName.value; + if (!currentBreadcrumbsProject.value) return; + const currentParent = currentFolder.value?.name || currentBreadcrumbsProjectName.value; if (!currentParent) return; await createFolder({ id: (route.params.folderId as string) ?? '-1', @@ -1241,6 +1378,8 @@ const onBreadCrumbsAction = async (action: string) => { case FOLDER_LIST_ITEM_ACTIONS.TOGGLE_FAVORITE: if (currentFolder.value) { await favoritesStore.toggleFavorite(currentFolder.value.id, 'folder'); + } else if (currentBreadcrumbsProject.value) { + await favoritesStore.toggleFavorite(currentBreadcrumbsProject.value.id, 'project'); } break; case FOLDER_LIST_ITEM_ACTIONS.RENAME: @@ -1263,12 +1402,198 @@ const onBreadCrumbsAction = async (action: string) => { } }; +function getMcpAccessTarget(scope: McpAccessScope | null = mcpAccessScope.value) { + if (!scope) return null; + + return scope.type === 'folder' ? { folderId: scope.id } : { projectId: scope.id }; +} + +function openSettingsFromToast(event?: MouseEvent) { + if (!(event?.target instanceof HTMLAnchorElement)) return; + + event.preventDefault(); + void router.push(settingsLink.value); +} + +function getMCPAccessUpdatedSummary(enabled: boolean, count: number, scopeName: string) { + return enabled + ? i18n.baseText('resourceActions.mcpAccess.summary.updated.enabled', { + adjustToNumber: count, + interpolate: { count: String(count), scopeName }, + }) + : i18n.baseText('resourceActions.mcpAccess.summary.updated.disabled', { + adjustToNumber: count, + interpolate: { count: String(count), scopeName }, + }); +} + +function getMCPAccessUnchangedSummary(enabled: boolean, count: number, scopeName: string) { + return enabled + ? i18n.baseText('resourceActions.mcpAccess.summary.unchanged.enabled', { + adjustToNumber: count, + interpolate: { count: String(count), scopeName }, + }) + : i18n.baseText('resourceActions.mcpAccess.summary.unchanged.disabled', { + adjustToNumber: count, + interpolate: { count: String(count), scopeName }, + }); +} + +function getMCPAccessSkippedSummary(count: number, scopeName: string) { + return i18n.baseText('resourceActions.mcpAccess.summary.skipped', { + adjustToNumber: count, + interpolate: { count: String(count), scopeName }, + }); +} + +function getMCPAccessOutcomeMessage( + enabled: boolean, + response: ToggleWorkflowsMcpAccessResponse, + scopeName: string, +) { + const summaries: string[] = []; + + if (response.updatedCount > 0) { + summaries.push(getMCPAccessUpdatedSummary(enabled, response.updatedCount, scopeName)); + } + + if (response.unchangedCount > 0) { + summaries.push(getMCPAccessUnchangedSummary(enabled, response.unchangedCount, scopeName)); + } + + if (response.skippedCount > 0) { + summaries.push(getMCPAccessSkippedSummary(response.skippedCount, scopeName)); + } + + if (summaries.length === 0) { + return i18n.baseText('resourceActions.mcpAccess.noWorkflows.message', { + interpolate: { link: settingsLink.value, scopeName }, + }); + } + + return i18n.baseText('resourceActions.mcpAccess.outcome.message', { + interpolate: { link: settingsLink.value, summary: `${summaries.join('. ')}.` }, + }); +} + +function showMCPAccessOutcomeToast( + enabled: boolean, + response: ToggleWorkflowsMcpAccessResponse, + scopeName: string, +) { + const hasUpdated = response.updatedCount > 0; + const hasSkipped = response.skippedCount > 0; + const hasUnchanged = response.unchangedCount > 0; + let title = i18n.baseText('resourceActions.mcpAccess.noWorkflows.title'); + let type: 'info' | 'success' | 'warning' = 'info'; + + if (hasSkipped) { + title = i18n.baseText('resourceActions.mcpAccess.partial.title'); + type = 'warning'; + } else if (hasUpdated) { + title = enabled + ? i18n.baseText('resourceActions.mcpAccess.success.enabled.title') + : i18n.baseText('resourceActions.mcpAccess.success.disabled.title'); + type = 'success'; + } else if (hasUnchanged) { + title = i18n.baseText('resourceActions.mcpAccess.noChanges.title'); + } + + toast.showToast({ + title, + message: getMCPAccessOutcomeMessage(enabled, response, scopeName), + onClick: openSettingsFromToast, + type, + }); +} + +function showMCPAccessErrorToast(enabled: boolean, scopeName: string) { + const title = enabled + ? i18n.baseText('resourceActions.mcpAccess.error.enabled.title') + : i18n.baseText('resourceActions.mcpAccess.error.disabled.title'); + const message = enabled + ? i18n.baseText('resourceActions.mcpAccess.error.enabled.message', { + interpolate: { + link: settingsLink.value, + scopeName, + }, + }) + : i18n.baseText('resourceActions.mcpAccess.error.disabled.message', { + interpolate: { + link: settingsLink.value, + scopeName, + }, + }); + + toast.showToast({ + title, + message, + onClick: openSettingsFromToast, + type: 'error', + duration: 0, + }); +} + +function showMCPAccessDisabledToast() { + toast.showToast({ + title: i18n.baseText('resourceActions.mcpAccess.disabled.title'), + message: i18n.baseText('resourceActions.mcpAccess.disabled.message', { + interpolate: { link: settingsLink.value }, + }), + onClick: openSettingsFromToast, + type: 'warning', + }); +} + +async function toggleMcpAccess( + enabled: boolean, + scope: McpAccessScope | null = mcpAccessScope.value, +) { + if (!mcpEnabled.value) { + showMCPAccessDisabledToast(); + return; + } + + if (!scope) return; + + const target = getMcpAccessTarget(scope); + if (!target) return; + + try { + const response = await mcpStore.toggleWorkflowsMcpAccess(target, enabled); + await fetchWorkflows(); + + if (response.failedCount > 0) { + showMCPAccessErrorToast(enabled, scope.name); + return; + } + + showMCPAccessOutcomeToast(enabled, response, scope.name); + } catch { + showMCPAccessErrorToast(enabled, scope.name); + } +} + // Folder card action handlers // These render on each folder card and are applied to the clicked folder const onFolderCardAction = async (payload: { action: string; folderId: string }) => { const clickedFolder = foldersStore.getCachedFolder(payload.folderId); if (!clickedFolder) return; switch (payload.action) { + case MCP_ACCESS_ACTIONS.ENABLE: + await toggleMcpAccess(true, { + type: 'folder', + id: clickedFolder.id, + name: clickedFolder.name, + }); + break; + case MCP_ACCESS_ACTIONS.DISABLE: + await toggleMcpAccess(false, { + type: 'folder', + id: clickedFolder.id, + name: clickedFolder.name, + }); + break; case FOLDER_LIST_ITEM_ACTIONS.CREATE: await createFolder( { @@ -1316,6 +1641,9 @@ const createFolder = async ( parent: { id: string; name: string; type: 'project' | 'folder' }, options: { openAfterCreate: boolean } = { openAfterCreate: false }, ) => { + const projectId = currentBreadcrumbsProject.value?.id; + if (!projectId) return; + const promptResponsePromise = message.prompt( i18n.baseText('folders.add.to.parent.message', { interpolate: { parent: parent.name } }), { @@ -1331,13 +1659,13 @@ const createFolder = async ( try { const newFolder = await foldersStore.createFolder( folderName, - route.params.projectId as string, + projectId, parent.type === 'folder' ? parent.id : undefined, ); const newFolderURL = router.resolve({ name: VIEWS.PROJECTS_FOLDERS, - params: { projectId: route.params.projectId, folderId: newFolder.id }, + params: { projectId, folderId: newFolder.id }, }).href; toast.showToast({ title: i18n.baseText('folders.add.success.title'), @@ -1362,7 +1690,7 @@ const createFolder = async ( // Navigate to parent folder id option specified by the caller await router.push({ name: VIEWS.PROJECTS_FOLDERS, - params: { projectId: route.params.projectId, folderId: parent.id }, + params: { projectId, folderId: parent.id }, }); } else { // If we are on an empty list, just add the new folder to the list @@ -1374,7 +1702,7 @@ const createFolder = async ( resource: 'folder', createdAt: newFolder.createdAt, updatedAt: newFolder.updatedAt, - homeProject: projectsStore.currentProject as ProjectSharingData, + homeProject: currentBreadcrumbsProject.value as ProjectSharingData, workflowCount: 0, subFolderCount: 0, }, @@ -1437,8 +1765,8 @@ const createFolderInCurrent = async () => { }); return; } - if (!route.params.projectId) return; - const currentParent = currentFolder.value?.name || projectName.value; + if (!currentBreadcrumbsProject.value) return; + const currentParent = currentFolder.value?.name || currentBreadcrumbsProjectName.value; if (!currentParent) return; await createFolder({ id: (route.params.folderId as string) ?? '-1', @@ -1823,11 +2151,8 @@ const onNameSubmit = async (name: string) => { /> -