diff --git a/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts b/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts
index 7ade06e3448..0c9e7a451c7 100644
--- a/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts
+++ b/packages/@n8n/api-types/src/schemas/source-controlled-file.schema.ts
@@ -57,6 +57,7 @@ export const SourceControlledFileSchema = z.object({
isRemoteArchived: z.boolean().optional(),
parentFolderId: z.string().nullable().optional(),
folderPath: z.array(z.string()).optional(),
+ remoteFolderPath: z.array(z.string()).optional(),
owner: ResourceOwnerSchema.optional(), // Resource owner can be a personal email or team information
publishingError: z.string().optional(),
publishingErrorDetails: workflowPublishBlockedDetailsSchema.optional(),
diff --git a/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts b/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts
index bf90cbe0435..dad281b6419 100644
--- a/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts
+++ b/packages/cli/src/modules/source-control.ee/__tests__/source-control-status.service.test.ts
@@ -1041,6 +1041,64 @@ describe('getStatus', () => {
expect(workflow?.parentFolderId).toBe('local-child');
expect(workflow?.folderPath).toEqual(['Local Parent', 'Local Child']);
});
+
+ it('exposes the remote folder path for a moved workflow so it stays visible under the source folder', async () => {
+ const local = createWorkflow({
+ id: 'wf-moved-foldered',
+ versionId: 'local-v1',
+ parentFolderId: 'local-child',
+ });
+ const remote = createWorkflow({
+ id: 'wf-moved-foldered',
+ versionId: 'remote-v2',
+ parentFolderId: 'remote-child',
+ });
+
+ sourceControlImportService.getLocalVersionIdsFromDb.mockResolvedValue([local]);
+ sourceControlImportService.getRemoteVersionIdsFromFiles.mockResolvedValue([remote]);
+ sourceControlImportService.getLocalFoldersAndMappingsFromDb.mockResolvedValue({
+ folders: [],
+ });
+ sourceControlImportService.getRemoteFoldersAndMappingsFromFile.mockResolvedValue({
+ folders: [
+ {
+ id: 'remote-child',
+ name: 'Remote Child',
+ parentFolderId: null,
+ homeProjectId: 'project1',
+ createdAt: '2023-01-01T00:00:00.000Z',
+ updatedAt: '2023-01-01T00:00:00.000Z',
+ },
+ ],
+ });
+
+ const folderData = new Map([
+ [
+ 'local-child',
+ { id: 'local-child', name: 'Local Child', parentFolder: { id: 'local-parent' } },
+ ],
+ ['local-parent', { id: 'local-parent', name: 'Local Parent', parentFolder: null }],
+ ]);
+ folderRepository.find.mockImplementation(async (options: any) => {
+ if (options?.where?.id?._value) {
+ const ids = options.where.id._value as string[];
+ return ids.map((id: string) => folderData.get(id)).filter(Boolean) as any;
+ }
+ return [];
+ });
+
+ const result = await sourceControlStatusService.getStatus(user, {
+ direction: 'push',
+ verbose: false,
+ preferLocalVersion: true,
+ });
+
+ const workflow = result.find((f) => f.id === 'wf-moved-foldered');
+ expect(workflow).toBeDefined();
+ expect(workflow?.status).toBe('modified');
+ expect(workflow?.folderPath).toEqual(['Local Parent', 'Local Child']);
+ expect(workflow?.remoteFolderPath).toEqual(['Remote Child']);
+ });
});
describe('owner changes', () => {
diff --git a/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts b/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts
index 84a014f7771..8901c040cff 100644
--- a/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts
+++ b/packages/cli/src/modules/source-control.ee/source-control-status.service.ee.ts
@@ -433,6 +433,12 @@ export class SourceControlStatusService {
preferredParentFolderId,
options.preferLocalVersion ? localFoldersById : remoteFoldersById,
);
+ // A move keeps only its new folderPath; expose the prior/remote path too so a
+ // filter on the source folder doesn't hide the pending move.
+ const remoteFolderPath = this.buildFolderPath(
+ remoteWorkflowWithSameId.parentFolderId,
+ remoteFoldersById,
+ );
const wfModified: SourceControlWorkflowVersionId = {
...localWorkflow,
@@ -462,6 +468,7 @@ export class SourceControlStatusService {
isRemoteArchived: archivedWorkflowIds.get(wfModified.id) ?? false,
parentFolderId: preferredParentFolderId,
folderPath: preferredFolderPath,
+ remoteFolderPath,
owner: wfModified.owner,
});
}
diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json
index 0d24744b2a0..8c2b89f0fc3 100644
--- a/packages/frontend/@n8n/i18n/src/locales/en.json
+++ b/packages/frontend/@n8n/i18n/src/locales/en.json
@@ -4180,6 +4180,7 @@
"settings.sourceControl.modals.push.success.description": "was committed and pushed to your remote repository | were committed and pushed to your remote repository",
"settings.sourceControl.modals.push.projectAdmin.callout": "If you want to push workflows from your personal space, move them to a project first.",
"settings.sourceControl.modals.push.currentWorkflow": "Current workflow",
+ "settings.sourceControl.modals.push.movedWorkflow": "Moved from {from} to {to}",
"settings.sourceControl.status.modified": "Modified",
"settings.sourceControl.status.deleted": "Deleted",
"settings.sourceControl.status.created": "New",
diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts
index f736b9428f1..ad6e4fdc3dc 100644
--- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts
+++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.test.ts
@@ -1498,6 +1498,60 @@ describe('SourceControlPushModal', () => {
expect(optionLabels).toEqual(['Alpha', 'Prod', 'Prod / Analytics', 'Prod / Billing']);
});
+ it('keeps a workflow moved out of a folder visible when filtering by its source folder', async () => {
+ // Moved from Production (prior) into Archive (current).
+ const status: SourceControlledFile[] = [
+ {
+ id: 'wf-moved',
+ name: 'Moved workflow',
+ type: 'workflow',
+ status: 'modified',
+ location: 'local',
+ conflict: true,
+ file: '/home/user/.n8n/git/workflows/wf-moved.json',
+ updatedAt: '2024-09-20T10:31:40.000Z',
+ folderPath: ['Archive'],
+ remoteFolderPath: ['Production'],
+ },
+ ];
+
+ sourceControlStore.getAggregatedStatus.mockResolvedValue(status);
+
+ const { getByTestId, getAllByTestId, getByText } = renderModal({
+ pinia,
+ props: {
+ data: {
+ eventBus,
+ status,
+ },
+ },
+ });
+
+ await waitFor(() => {
+ expect(getByText('Commit and push changes')).toBeInTheDocument();
+ });
+
+ await waitFor(() => {
+ expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1);
+ });
+
+ await userEvent.click(getByTestId('source-control-filter-dropdown'));
+ const folderSelect = getByTestId('source-control-folder-filter');
+ const folderCombobox = within(folderSelect).getByRole('combobox');
+ await userEvent.click(folderCombobox);
+
+ const dropdownId = folderCombobox.getAttribute('aria-controls');
+ await waitFor(() => {
+ const dropdown = document.getElementById(dropdownId as string);
+ expect(within(dropdown as HTMLElement).getByText('Production')).toBeInTheDocument();
+ });
+
+ const dropdown = document.getElementById(dropdownId as string) as HTMLElement;
+ await userEvent.click(within(dropdown).getByText('Production'));
+
+ expect(getAllByTestId('source-control-push-modal-file-checkbox')).toHaveLength(1);
+ });
+
test.each([
['credential', 'Credentials'],
['workflow', 'Workflows'],
diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue
index db1276ac7fe..10f3a3e5c34 100644
--- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue
+++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/components/SourceControlPushModal.vue
@@ -356,8 +356,12 @@ const sortedWorkflows = useSourceControlFileList({
return true;
}
- const workflowPath = (workflow.folderPath ?? []).join('/');
- return workflowPath === folderFilter || workflowPath.startsWith(`${folderFilter}/`);
+ // Match both the current and prior folder so a moved-out workflow stays visible.
+ const matchesFolder = (path: string[] | undefined) => {
+ const workflowPath = (path ?? []).join('/');
+ return workflowPath === folderFilter || workflowPath.startsWith(`${folderFilter}/`);
+ };
+ return matchesFolder(workflow.folderPath) || matchesFolder(workflow.remoteFolderPath);
},
});
@@ -527,6 +531,18 @@ function renderUpdatedAt(file: SourceControlledFile) {
return formatSourceControlUpdatedAt(file.updatedAt);
}
+// Non-empty only when the workflow's folder changed vs. the remote, i.e. it was moved.
+function renderMovedTooltip(file: SourceControlledFile) {
+ const to = (file.folderPath ?? []).join('/');
+ const from = (file.remoteFolderPath ?? []).join('/');
+ if (file.remoteFolderPath === undefined || from === to) {
+ return '';
+ }
+ return i18n.baseText('settings.sourceControl.modals.push.movedWorkflow', {
+ interpolate: { from: from || '/', to: to || '/' },
+ });
+}
+
async function onCommitKeyDownEnter() {
if (!isSubmitDisabled.value) {
await commitAndPush();
@@ -1221,12 +1237,18 @@ onMounted(async () => {
:show-badge-border="false"
/>
-
- {{ getStatusText(row.file.status) }}
-
+
+ {{ getStatusText(row.file.status) }}
+
+
{
]);
});
+ it('includes the remote/prior folder of a moved workflow so its source folder stays selectable', () => {
+ const options = buildFolderFilterOptions([
+ {
+ id: 'wf-moved',
+ name: 'Moved workflow',
+ type: 'workflow',
+ status: 'modified',
+ location: 'local',
+ conflict: true,
+ file: '/wf-moved.json',
+ updatedAt: '2025-01-01T00:00:00.000Z',
+ folderPath: ['Archive'],
+ remoteFolderPath: ['Production'],
+ },
+ ]);
+
+ expect(options).toEqual([
+ { label: 'Archive', value: 'Archive' },
+ { label: 'Production', value: 'Production' },
+ ]);
+ });
+
it('returns empty array when workflows have no folder paths', () => {
const options = buildFolderFilterOptions([
{
diff --git a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.utils.ts b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.utils.ts
index c881a1f3a82..ed64a467f16 100644
--- a/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.utils.ts
+++ b/packages/frontend/editor-ui/src/features/integrations/sourceControl.ee/sourceControl.utils.ts
@@ -215,12 +215,14 @@ export const buildFolderFilterOptions = (workflows: SourceControlledFile[]) => {
const folderPathSet = new Set();
for (const workflow of workflows) {
- const pathSegments = workflow.folderPath ?? [];
- let path = '';
+ // Include the prior/remote path so a moved workflow's source folder stays selectable.
+ for (const pathSegments of [workflow.folderPath, workflow.remoteFolderPath]) {
+ let path = '';
- for (const segment of pathSegments) {
- path = path ? `${path}/${segment}` : segment;
- folderPathSet.add(path);
+ for (const segment of pathSegments ?? []) {
+ path = path ? `${path}/${segment}` : segment;
+ folderPathSet.add(path);
+ }
}
}