feat(editor): Workflows Diff UI (no-changelog) (#17452)

This commit is contained in:
Raúl Gómez Morales
2025-07-22 13:50:18 +02:00
committed by GitHub
parent f2ca2df90c
commit 9f45c284db
36 changed files with 3285 additions and 119 deletions

View File

@@ -122,7 +122,8 @@
"js-base64": "patches/js-base64.patch",
"ics": "patches/ics.patch",
"minifaker": "patches/minifaker.patch",
"z-vue-scan": "patches/z-vue-scan.patch"
"z-vue-scan": "patches/z-vue-scan.patch",
"v-code-diff": "patches/v-code-diff.patch"
}
}
}

View File

@@ -78,6 +78,7 @@ import IconLucideEyeOff from '~icons/lucide/eye-off';
import IconLucideFile from '~icons/lucide/file';
import IconLucideFileArchive from '~icons/lucide/file-archive';
import IconLucideFileCode from '~icons/lucide/file-code';
import IconLucideFileDiff from '~icons/lucide/file-diff';
import IconLucideFileDown from '~icons/lucide/file-down';
import IconLucideFileInput from '~icons/lucide/file-input';
import IconLucideFileOutput from '~icons/lucide/file-output';
@@ -474,6 +475,7 @@ export const updatedIconSet = {
file: IconLucideFile,
'file-archive': IconLucideFileArchive,
'file-code': IconLucideFileCode,
'file-diff': IconLucideFileDiff,
'file-down': IconLucideFileDown,
'file-input': IconLucideFileInput,
'file-output': IconLucideFileOutput,

View File

@@ -5,6 +5,7 @@ interface RadioOption {
label: string;
value: Value;
disabled?: boolean;
data?: Record<string, string | number | boolean | undefined>;
}
interface RadioButtonsProps {

View File

@@ -2560,6 +2560,8 @@
"workflowSettings.showMessage.saveSettings.title": "Workflow settings saved",
"workflowSettings.timeoutAfter": "Timeout After",
"workflowSettings.timeoutWorkflow": "Timeout Workflow",
"workflowSettings.executionTimeout": "Timeout Workflow",
"workflowSettings.tags": "Tags",
"workflowSettings.timezone": "Timezone",
"workflowSettings.timeSavedPerExecution": "Estimated time saved",
"workflowSettings.timeSavedPerExecution.hint": "Minutes per production execution",

View File

@@ -85,6 +85,7 @@
"timeago.js": "^4.0.2",
"typescript": "catalog:",
"uuid": "catalog:",
"v-code-diff": "^1.13.1",
"v3-infinite-loading": "^1.2.2",
"vue": "catalog:frontend",
"vue-agile": "^2.0.0",

View File

@@ -9,6 +9,7 @@ import type {
SourceControlStatus,
SshKeyTypes,
} from '@/types/sourceControl.types';
import type { IWorkflowDb } from '@/Interface';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { TupleToUnion } from '@/utils/typeHelpers';
@@ -56,6 +57,17 @@ export const getStatus = async (context: IRestApiContext): Promise<SourceControl
return await makeRestApiRequest(context, 'GET', `${sourceControlApiRoot}/status`);
};
export const getRemoteWorkflow = async (
context: IRestApiContext,
workflowId: string,
): Promise<{ content: IWorkflowDb; type: 'workflow' }> => {
return await makeRestApiRequest(
context,
'GET',
`${sourceControlApiRoot}/remote-content/workflow/${workflowId}`,
);
};
export const getAggregatedStatus = async (
context: IRestApiContext,
options: {

View File

@@ -160,7 +160,7 @@ function getCustomClass() {
@opened="onOpened"
>
<template v-if="$slots.header" #header>
<slot v-if="!loading" name="header" />
<slot v-if="!loading" name="header" v-bind="{ closeDialog }" />
</template>
<template v-else-if="title" #title>
<div :class="centerTitle ? $style.centerTitle : ''">

View File

@@ -1,85 +1,87 @@
<script setup lang="ts">
import {
ABOUT_MODAL_KEY,
CHAT_EMBED_MODAL_KEY,
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
CHANGE_PASSWORD_MODAL_KEY,
CHAT_EMBED_MODAL_KEY,
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
COMMUNITY_PACKAGE_INSTALL_MODAL_KEY,
COMMUNITY_PLUS_ENROLLMENT_MODAL,
CONTACT_PROMPT_MODAL_KEY,
CREDENTIAL_EDIT_MODAL_KEY,
API_KEY_CREATE_OR_EDIT_MODAL_KEY,
CREDENTIAL_SELECT_MODAL_KEY,
DEBUG_PAYWALL_MODAL_KEY,
DELETE_FOLDER_MODAL_KEY,
DELETE_USER_MODAL_KEY,
DUPLICATE_MODAL_KEY,
INVITE_USER_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
ANNOTATION_TAGS_MANAGER_MODAL_KEY,
NPS_SURVEY_MODAL_KEY,
NEW_ASSISTANT_SESSION_MODAL,
VERSIONS_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
IMPORT_CURL_MODAL_KEY,
LOG_STREAM_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
EXTERNAL_SECRETS_PROVIDER_MODAL_KEY,
DEBUG_PAYWALL_MODAL_KEY,
FROM_AI_PARAMETERS_MODAL_KEY,
IMPORT_CURL_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
INVITE_USER_MODAL_KEY,
LOG_STREAM_MODAL_KEY,
MFA_SETUP_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_RESTORE,
SETUP_CREDENTIALS_MODAL_KEY,
MOVE_FOLDER_MODAL_KEY,
NEW_ASSISTANT_SESSION_MODAL,
NPS_SURVEY_MODAL_KEY,
PERSONALIZATION_MODAL_KEY,
PROJECT_MOVE_RESOURCE_MODAL,
PROMPT_MFA_CODE_MODAL_KEY,
COMMUNITY_PLUS_ENROLLMENT_MODAL,
DELETE_FOLDER_MODAL_KEY,
MOVE_FOLDER_MODAL_KEY,
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
FROM_AI_PARAMETERS_MODAL_KEY,
IMPORT_WORKFLOW_URL_MODAL_KEY,
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
SETUP_CREDENTIALS_MODAL_KEY,
SOURCE_CONTROL_PULL_MODAL_KEY,
SOURCE_CONTROL_PUSH_MODAL_KEY,
TAGS_MANAGER_MODAL_KEY,
VERSIONS_MODAL_KEY,
WHATS_NEW_MODAL_KEY,
WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY,
WORKFLOW_ACTIVE_MODAL_KEY,
WORKFLOW_DIFF_MODAL_KEY,
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
WORKFLOW_HISTORY_VERSION_RESTORE,
WORKFLOW_SETTINGS_MODAL_KEY,
WORKFLOW_SHARE_MODAL_KEY,
} from '@/constants';
import AboutModal from '@/components/AboutModal.vue';
import ChatEmbedModal from '@/components/ChatEmbedModal.vue';
import CommunityPackageManageConfirmModal from '@/components/CommunityPackageManageConfirmModal.vue';
import CommunityPackageInstallModal from '@/components/CommunityPackageInstallModal.vue';
import ActivationModal from '@/components/ActivationModal.vue';
import ApiKeyCreateOrEditModal from '@/components/ApiKeyCreateOrEditModal.vue';
import NewAssistantSessionModal from '@/components/AskAssistant/Chat/NewAssistantSessionModal.vue';
import ChangePasswordModal from '@/components/ChangePasswordModal.vue';
import ChatEmbedModal from '@/components/ChatEmbedModal.vue';
import CommunityPackageInstallModal from '@/components/CommunityPackageInstallModal.vue';
import CommunityPackageManageConfirmModal from '@/components/CommunityPackageManageConfirmModal.vue';
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
import ContactPromptModal from '@/components/ContactPromptModal.vue';
import CredentialEdit from '@/components/CredentialEdit/CredentialEdit.vue';
import InviteUsersModal from '@/components/InviteUsersModal.vue';
import CredentialsSelectModal from '@/components/CredentialsSelectModal.vue';
import DuplicateWorkflowDialog from '@/components/DuplicateWorkflowDialog.vue';
import ModalRoot from '@/components/ModalRoot.vue';
import PersonalizationModal from '@/components/PersonalizationModal.vue';
import WorkflowTagsManager from '@/components/TagsManager/WorkflowTagsManager.vue';
import AnnotationTagsManager from '@/components/TagsManager/AnnotationTagsManager.ee.vue';
import UpdatesPanel from '@/components/UpdatesPanel.vue';
import NpsSurvey from '@/components/NpsSurvey.vue';
import WorkflowSettings from '@/components/WorkflowSettings.vue';
import DeleteUserModal from '@/components/DeleteUserModal.vue';
import ActivationModal from '@/components/ActivationModal.vue';
import ImportCurlModal from '@/components/ImportCurlModal.vue';
import ApiKeyCreateOrEditModal from '@/components/ApiKeyCreateOrEditModal.vue';
import MfaSetupModal from '@/components/MfaSetupModal.vue';
import WorkflowShareModal from '@/components/WorkflowShareModal.ee.vue';
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue';
import ExternalSecretsProviderModal from '@/components/ExternalSecretsProviderModal.ee.vue';
import DebugPaywallModal from '@/components/DebugPaywallModal.vue';
import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue';
import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentialsModal/SetupWorkflowCredentialsModal.vue';
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
import NewAssistantSessionModal from '@/components/AskAssistant/Chat/NewAssistantSessionModal.vue';
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
import CommunityPlusEnrollmentModal from '@/components/CommunityPlusEnrollmentModal.vue';
import WorkflowActivationConflictingWebhookModal from '@/components/WorkflowActivationConflictingWebhookModal.vue';
import DeleteUserModal from '@/components/DeleteUserModal.vue';
import DuplicateWorkflowDialog from '@/components/DuplicateWorkflowDialog.vue';
import ExternalSecretsProviderModal from '@/components/ExternalSecretsProviderModal.ee.vue';
import FromAiParametersModal from '@/components/FromAiParametersModal.vue';
import ImportCurlModal from '@/components/ImportCurlModal.vue';
import ImportWorkflowUrlModal from '@/components/ImportWorkflowUrlModal.vue';
import InviteUsersModal from '@/components/InviteUsersModal.vue';
import MfaSetupModal from '@/components/MfaSetupModal.vue';
import ModalRoot from '@/components/ModalRoot.vue';
import NpsSurvey from '@/components/NpsSurvey.vue';
import PersonalizationModal from '@/components/PersonalizationModal.vue';
import ProjectMoveResourceModal from '@/components/Projects/ProjectMoveResourceModal.vue';
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
import SetupWorkflowCredentialsModal from '@/components/SetupWorkflowCredentialsModal/SetupWorkflowCredentialsModal.vue';
import SourceControlPullModal from '@/components/SourceControlPullModal.ee.vue';
import SourceControlPushModal from '@/components/SourceControlPushModal.ee.vue';
import AnnotationTagsManager from '@/components/TagsManager/AnnotationTagsManager.ee.vue';
import WorkflowTagsManager from '@/components/TagsManager/WorkflowTagsManager.vue';
import UpdatesPanel from '@/components/UpdatesPanel.vue';
import WorkflowActivationConflictingWebhookModal from '@/components/WorkflowActivationConflictingWebhookModal.vue';
import WorkflowHistoryVersionRestoreModal from '@/components/WorkflowHistory/WorkflowHistoryVersionRestoreModal.vue';
import WorkflowSettings from '@/components/WorkflowSettings.vue';
import WorkflowShareModal from '@/components/WorkflowShareModal.ee.vue';
import WorkflowDiffModal from '@/features/workflow-diff/WorkflowDiffModal.vue';
import type { EventBus } from '@n8n/utils/event-bus';
import PromptMfaCodeModal from './PromptMfaCodeModal/PromptMfaCodeModal.vue';
</script>
<template>
@@ -241,6 +243,12 @@ import type { EventBus } from '@n8n/utils/event-bus';
</template>
</ModalRoot>
<ModalRoot :name="WORKFLOW_DIFF_MODAL_KEY">
<template #default="{ modalName, data }">
<WorkflowDiffModal :modal-name="modalName" :data="data" />
</template>
</ModalRoot>
<ModalRoot :name="EXTERNAL_SECRETS_PROVIDER_MODAL_KEY">
<template #default="{ modalName, data }">
<ExternalSecretsProviderModal :modal-name="modalName" :data="data" />

View File

@@ -2,8 +2,8 @@
import type { SimplifiedNodeType } from '@/Interface';
import { getNodeIconSource, type NodeIconSource } from '@/utils/nodeIcon';
import { N8nNodeIcon } from '@n8n/design-system';
import { computed } from 'vue';
import type { VersionNode } from '@n8n/rest-api-client/api/versions';
import { computed } from 'vue';
type Props = {
size?: number;
@@ -72,7 +72,6 @@ const nodeTypeName = computed(() =>
:type="iconType"
:src="src"
:name="iconName"
:color="iconColor"
:disabled="disabled"
:size="size"
:circle="circle"
@@ -80,8 +79,13 @@ const nodeTypeName = computed(() =>
:show-tooltip="showTooltip"
:tooltip-position="tooltipPosition"
:badge="badge"
:class="$style.nodeIcon"
@click="emit('click')"
></N8nNodeIcon>
</template>
<style lang="scss" module></style>
<style lang="scss" module>
.nodeIcon {
--node-icon-color: var(--canvas-node-icon-color, v-bind(iconColor));
}
</style>

View File

@@ -1,27 +1,29 @@
<script lang="ts" setup>
import Modal from './Modal.vue';
import { SOURCE_CONTROL_PULL_MODAL_KEY, VIEWS } from '@/constants';
import type { EventBus } from '@n8n/utils/event-bus';
import { useI18n } from '@n8n/i18n';
import { useLoadingService } from '@/composables/useLoadingService';
import { useToast } from '@/composables/useToast';
import { SOURCE_CONTROL_PULL_MODAL_KEY, VIEWS, WORKFLOW_DIFF_MODAL_KEY } from '@/constants';
import { sourceControlEventBus } from '@/event-bus/source-control';
import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useUIStore } from '@/stores/ui.store';
import { computed } from 'vue';
import { sourceControlEventBus } from '@/event-bus/source-control';
import groupBy from 'lodash/groupBy';
import orderBy from 'lodash/orderBy';
import { N8nBadge, N8nText, N8nLink, N8nButton } from '@n8n/design-system';
import { RouterLink } from 'vue-router';
import {
getPullPriorityByStatus,
getStatusText,
getStatusTheme,
getPullPriorityByStatus,
notifyUserAboutPullWorkFolderOutcome,
} from '@/utils/sourceControlUtils';
import { type SourceControlledFile, SOURCE_CONTROL_FILE_TYPE } from '@n8n/api-types';
import { N8nBadge, N8nButton, N8nLink, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import groupBy from 'lodash/groupBy';
import orderBy from 'lodash/orderBy';
import { computed } from 'vue';
import { RouterLink } from 'vue-router';
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import { type SourceControlledFile, SOURCE_CONTROL_FILE_TYPE } from '@n8n/api-types';
import Modal from './Modal.vue';
type SourceControlledFileType = SourceControlledFile['type'];
@@ -102,6 +104,15 @@ async function pullWorkfolder() {
loadingService.stopLoading();
}
}
const workflowDiffEventBus = createEventBus();
function openDiffModal(id: string) {
uiStore.openModalWithData({
name: WORKFLOW_DIFF_MODAL_KEY,
data: { eventBus: workflowDiffEventBus, workflowId: id, direction: 'pull' },
});
}
</script>
<template>
@@ -161,6 +172,14 @@ async function pullWorkfolder() {
<N8nBadge :theme="getStatusTheme(item.status)" :class="$style.listBadge">
{{ getStatusText(item.status) }}
</N8nBadge>
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF">
<N8nIconButton
v-if="item.type === SOURCE_CONTROL_FILE_TYPE.workflow"
icon="git-branch"
type="secondary"
@click="openDiffModal(item.id)"
/>
</EnvFeatureFlag>
</div>
</DynamicScrollerItem>
</template>

View File

@@ -255,9 +255,15 @@ describe('SourceControlPushModal', () => {
const submitButton = getByTestId('source-control-push-modal-submit');
const commitMessage = 'commit message';
expect(submitButton).toBeDisabled();
expect(getByRole('alert').textContent).toContain('Variables: at least one new or modified.');
expect(getByRole('alert').textContent).toContain('Tags: at least one new or modified.');
expect(getByRole('alert').textContent).toContain('Folders: at least one new or modified.');
expect(getByRole('alert').textContent).toContain(
[
'Changes to variables, tags and folders',
'Variables : at least one new or modified.',
'Tags : at least one new or modified.',
'Folders : at least one new or modified. ',
].join(' '),
);
await userEvent.type(getByTestId('source-control-push-modal-commit'), commitMessage);

View File

@@ -3,7 +3,8 @@ import ProjectCardBadge from '@/components/Projects/ProjectCardBadge.vue';
import { useLoadingService } from '@/composables/useLoadingService';
import { useTelemetry } from '@/composables/useTelemetry';
import { useToast } from '@/composables/useToast';
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS } from '@/constants';
import { SOURCE_CONTROL_PUSH_MODAL_KEY, VIEWS, WORKFLOW_DIFF_MODAL_KEY } from '@/constants';
import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
import type { WorkflowResource } from '@/Interface';
import { useProjectsStore } from '@/stores/projects.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
@@ -36,6 +37,7 @@ import {
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { EventBus } from '@n8n/utils/event-bus';
import { createEventBus } from '@n8n/utils/event-bus';
import { refDebounced, useStorage } from '@vueuse/core';
import dateformat from 'dateformat';
import orderBy from 'lodash/orderBy';
@@ -560,6 +562,15 @@ function castType(type: string): ResourceType {
function castProject(project: ProjectListItem) {
return { homeProject: project } as unknown as WorkflowResource;
}
const workflowDiffEventBus = createEventBus();
function openDiffModal(id: string) {
uiStore.openModalWithData({
name: WORKFLOW_DIFF_MODAL_KEY,
data: { eventBus: workflowDiffEventBus, workflowId: id, direction: 'push' },
});
}
</script>
<template>
@@ -623,8 +634,7 @@ function castProject(project: ProjectListItem) {
:key="option.label"
data-test-id="source-control-status-filter-option"
v-bind="option"
>
</N8nOption>
/>
</N8nSelect>
<N8nInputLabel
:label="i18n.baseText('forms.resourceFiltersDropdown.owner')"
@@ -681,8 +691,8 @@ function castProject(project: ProjectListItem) {
>
<div>{{ tab.label }}</div>
<N8nText tag="div" color="text-light">
{{ tab.selected }} / {{ tab.total }} selected</N8nText
>
{{ tab.selected }} / {{ tab.total }} selected
</N8nText>
</button>
</template>
</div>
@@ -809,6 +819,14 @@ function castProject(project: ProjectListItem) {
<N8nBadge :theme="getStatusTheme(file.status)">
{{ getStatusText(file.status) }}
</N8nBadge>
<EnvFeatureFlag name="SOURCE_CONTROL_WORKFLOW_DIFF">
<N8nIconButton
v-if="file.type === SOURCE_CONTROL_FILE_TYPE.workflow"
icon="git-branch"
type="secondary"
@click="openDiffModal(file.id)"
/>
</EnvFeatureFlag>
</span>
</N8nCheckbox>
</DynamicScrollerItem>
@@ -825,8 +843,8 @@ function castProject(project: ProjectListItem) {
<N8nText bold size="medium">Changes to variables, tags and folders </N8nText>
<br />
<template v-for="{ title, content } in userNotices" :key="title">
<N8nText bold size="small">{{ title }}</N8nText>
<N8nText size="small">: {{ content }}. </N8nText>
<N8nText bold size="small"> {{ title }}</N8nText>
<N8nText size="small"> : {{ content }}. </N8nText>
</template>
</N8nNotice>

View File

@@ -45,7 +45,7 @@ exports[`VirtualSchema.vue > renders preview schema when enabled and available 1
/>
</div>
<div
class="n8n-node-icon icon icon"
class="n8n-node-icon nodeIcon icon nodeIcon icon"
data-v-882a318e=""
>
<div
@@ -331,7 +331,7 @@ exports[`VirtualSchema.vue > renders previous nodes schema for AI tools 1`] = `
/>
</div>
<div
class="n8n-node-icon icon icon"
class="n8n-node-icon nodeIcon icon nodeIcon icon"
data-v-882a318e=""
>
<div
@@ -415,7 +415,7 @@ exports[`VirtualSchema.vue > renders schema for empty objects and arrays 1`] = `
/>
</div>
<div
class="n8n-node-icon icon icon-trigger icon icon-trigger"
class="n8n-node-icon nodeIcon icon icon-trigger nodeIcon icon icon-trigger"
data-v-882a318e=""
>
<div
@@ -1304,7 +1304,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = `
/>
</div>
<div
class="n8n-node-icon icon icon-trigger icon icon-trigger"
class="n8n-node-icon nodeIcon icon icon-trigger nodeIcon icon icon-trigger"
data-v-882a318e=""
>
<div
@@ -1707,7 +1707,7 @@ exports[`VirtualSchema.vue > renders schema with spaces and dots 1`] = `
/>
</div>
<div
class="n8n-node-icon icon icon"
class="n8n-node-icon nodeIcon icon nodeIcon icon"
data-v-882a318e=""
>
<div
@@ -1837,7 +1837,7 @@ exports[`VirtualSchema.vue > renders variables and context section 1`] = `
/>
</div>
<div
class="n8n-node-icon icon icon-trigger icon icon-trigger"
class="n8n-node-icon nodeIcon icon icon-trigger nodeIcon icon icon-trigger"
data-v-882a318e=""
>
<div
@@ -2659,7 +2659,7 @@ exports[`VirtualSchema.vue > should expand all nodes when searching 1`] = `
/>
</div>
<div
class="n8n-node-icon icon icon-trigger icon icon-trigger"
class="n8n-node-icon nodeIcon icon icon-trigger nodeIcon icon icon-trigger"
data-v-882a318e=""
>
<div
@@ -2797,7 +2797,7 @@ exports[`VirtualSchema.vue > should expand all nodes when searching 1`] = `
/>
</div>
<div
class="n8n-node-icon icon icon"
class="n8n-node-icon nodeIcon icon nodeIcon icon"
data-v-882a318e=""
>
<div

View File

@@ -14,12 +14,13 @@ import type {
CanvasConnection,
CanvasEventBusEvents,
CanvasNode,
CanvasNodeData,
CanvasNodeMoveEvent,
ConnectStartEvent,
CanvasNodeData,
} from '@/types';
import { CanvasNodeRenderType } from '@/types';
import { updateViewportToContainNodes, getMousePosition, GRID_SIZE } from '@/utils/nodeViewUtils';
import { isOutsideSelected } from '@/utils/htmlUtils';
import { getMousePosition, GRID_SIZE, updateViewportToContainNodes } from '@/utils/nodeViewUtils';
import { isPresent } from '@/utils/typesUtils';
import { useDeviceSupport } from '@n8n/composables/useDeviceSupport';
import { useShortKeyPress } from '@n8n/composables/useShortKeyPress';
@@ -49,12 +50,11 @@ import {
useCssModule,
watch,
} from 'vue';
import { useViewportAutoAdjust } from './composables/useViewportAutoAdjust';
import CanvasBackground from './elements/background/CanvasBackground.vue';
import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue';
import Edge from './elements/edges/CanvasEdge.vue';
import Node from './elements/nodes/CanvasNode.vue';
import { useViewportAutoAdjust } from './composables/useViewportAutoAdjust';
import { isOutsideSelected } from '@/utils/htmlUtils';
import { useExperimentalNdvStore } from './experimental/experimentalNdv.store';
const $style = useCssModule();
@@ -918,6 +918,7 @@ provide(CanvasKey, {
</template>
<template #edge-canvas-edge="edgeProps">
<slot name="edge" v-bind="{ edgeProps, arrowHeadMarkerId }">
<Edge
v-bind="edgeProps"
:marker-end="`url(#${arrowHeadMarkerId})`"
@@ -928,6 +929,7 @@ provide(CanvasKey, {
@delete="onDeleteConnection"
@update:label:hovered="onUpdateEdgeLabelHovered(edgeProps.id, $event)"
/>
</slot>
</template>
<template #connection-line="connectionLineProps">
@@ -936,7 +938,9 @@ provide(CanvasKey, {
<CanvasArrowHeadMarker :id="arrowHeadMarkerId" />
<slot name="canvas-background" v-bind="{ viewport }">
<CanvasBackground :viewport="viewport" :striped="readOnly" />
</slot>
<Transition name="minimap">
<MiniMap

View File

@@ -100,9 +100,10 @@ describe('CanvasEdge', () => {
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).toHaveStyle({
stroke: 'var(--color-foreground-xdark)',
});
// Since v-bind in CSS creates dynamic styles, we should test that the edge element exists
// and has the expected class rather than testing the specific CSS property
expect(edge).toBeInTheDocument();
expect(edge).toHaveClass('edge');
});
it('should correctly style a pinned connection', () => {
@@ -112,9 +113,10 @@ describe('CanvasEdge', () => {
const edge = container.querySelector('.vue-flow__edge-path');
expect(edge).toHaveStyle({
stroke: 'var(--color-secondary)',
});
// Since v-bind in CSS creates dynamic styles, we should test that the edge element exists
// and has the expected class rather than testing the specific CSS property
expect(edge).toBeInTheDocument();
expect(edge).toHaveClass('edge');
});
it('should render a correct bezier path', () => {

View File

@@ -75,9 +75,12 @@ const edgeColor = computed(() => {
const edgeStyle = computed(() => ({
...props.style,
...(isMainConnection.value ? {} : { strokeDasharray: '8,8' }),
stroke: delayedHovered.value ? 'var(--color-primary)' : edgeColor.value,
}));
const edgeStroke = computed(() =>
delayedHovered.value ? 'var(--color-primary)' : edgeColor.value,
);
const edgeClasses = computed(() => ({
[$style.edge]: true,
hovered: delayedHovered.value,
@@ -135,7 +138,10 @@ function onEdgeLabelMouseLeave() {
data-test-id="edge"
:data-source-node-name="data.source?.node"
:data-target-node-name="data.target?.node"
v-bind="$attrs"
>
<slot name="highlight" v-bind="{ segments }" />
<BaseEdge
v-for="(segment, index) in segments"
:id="`${id}-${index}`"
@@ -175,6 +181,7 @@ function onEdgeLabelMouseLeave() {
transition:
stroke 0.3s ease,
fill 0.3s ease;
stroke: var(--canvas-edge-color, v-bind(edgeStroke));
stroke-width: calc(2 * var(--canvas-zoom-compensation-factor, 1));
stroke-linecap: square;
}

View File

@@ -8,7 +8,7 @@ exports[`CanvasNodeDefault > configurable > should render configurable node corr
>
<!--v-if-->
<div
class="n8n-node-icon icon icon"
class="n8n-node-icon nodeIcon icon nodeIcon icon"
shrink="false"
>
<div
@@ -53,7 +53,7 @@ exports[`CanvasNodeDefault > configuration > should render configurable configur
>
<!--v-if-->
<div
class="n8n-node-icon icon icon"
class="n8n-node-icon nodeIcon icon nodeIcon icon"
shrink="false"
>
<div
@@ -98,7 +98,7 @@ exports[`CanvasNodeDefault > configuration > should render configuration node co
>
<!--v-if-->
<div
class="n8n-node-icon icon icon"
class="n8n-node-icon nodeIcon icon nodeIcon icon"
shrink="false"
>
<div
@@ -143,7 +143,7 @@ exports[`CanvasNodeDefault > should render node correctly 1`] = `
>
<!--v-if-->
<div
class="n8n-node-icon icon icon"
class="n8n-node-icon nodeIcon icon nodeIcon icon"
shrink="false"
>
<div
@@ -188,7 +188,7 @@ exports[`CanvasNodeDefault > trigger > should render trigger node correctly 1`]
>
<!--v-if-->
<div
class="n8n-node-icon icon icon"
class="n8n-node-icon nodeIcon icon nodeIcon icon"
shrink="false"
>
<div

View File

@@ -85,6 +85,7 @@ export const WORKFLOW_ACTIVATION_CONFLICTING_WEBHOOK_MODAL_KEY =
export const FROM_AI_PARAMETERS_MODAL_KEY = 'fromAiParameters';
export const WORKFLOW_EXTRACTION_NAME_MODAL_KEY = 'workflowExtractionName';
export const WHATS_NEW_MODAL_KEY = 'whatsNew';
export const WORKFLOW_DIFF_MODAL_KEY = 'workflowDiff';
export const COMMUNITY_PACKAGE_MANAGE_ACTIONS = {
UNINSTALL: 'uninstall',

View File

@@ -0,0 +1,104 @@
import { describe, it, expect } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
import DiffBadge from '@/features/workflow-diff/DiffBadge.vue';
import { NodeDiffStatus } from '@/features/workflow-diff/useWorkflowDiff';
const renderComponent = createComponentRenderer(DiffBadge);
describe('DiffBadge', () => {
it('should render "N" label for Added status', () => {
const { getByText, container } = renderComponent({
props: {
type: NodeDiffStatus.Added,
},
});
expect(getByText('N')).toBeInTheDocument();
const badge = container.querySelector('.diffBadge');
expect(badge).toBeInTheDocument();
});
it('should render "D" label for Deleted status', () => {
const { getByText } = renderComponent({
props: {
type: NodeDiffStatus.Deleted,
},
});
expect(getByText('D')).toBeInTheDocument();
});
it('should render "M" label for Modified status', () => {
const { getByText } = renderComponent({
props: {
type: NodeDiffStatus.Modified,
},
});
expect(getByText('M')).toBeInTheDocument();
});
it('should render empty label for Equal status', () => {
const { container } = renderComponent({
props: {
type: NodeDiffStatus.Eq,
},
});
const badge = container.querySelector('.diffBadge');
expect(badge).toBeInTheDocument();
expect(badge?.textContent?.trim()).toBe('');
});
it('should have correct CSS class', () => {
const { container } = renderComponent({
props: {
type: NodeDiffStatus.Added,
},
});
const badge = container.querySelector('.diffBadge');
expect(badge).toHaveClass('diffBadge');
});
it('should apply correct styles for different status types', () => {
// Test Added status (green)
const { container: addedContainer } = renderComponent({
props: {
type: NodeDiffStatus.Added,
},
});
const addedBadge = addedContainer.querySelector('.diffBadge');
expect(addedBadge).toBeInTheDocument();
// Test Deleted status (red)
const { container: deletedContainer } = renderComponent({
props: {
type: NodeDiffStatus.Deleted,
},
});
const deletedBadge = deletedContainer.querySelector('.diffBadge');
expect(deletedBadge).toBeInTheDocument();
// Test Modified status (orange)
const { container: modifiedContainer } = renderComponent({
props: {
type: NodeDiffStatus.Modified,
},
});
const modifiedBadge = modifiedContainer.querySelector('.diffBadge');
expect(modifiedBadge).toBeInTheDocument();
});
it('should handle unknown status types gracefully', () => {
const { container } = renderComponent({
props: {
type: 'unknown' as NodeDiffStatus,
},
});
const badge = container.querySelector('.diffBadge');
expect(badge).toBeInTheDocument();
expect(badge?.textContent?.trim()).toBe('');
});
});

View File

@@ -0,0 +1,55 @@
<script setup lang="ts">
import { NodeDiffStatus } from '@/features/workflow-diff/useWorkflowDiff';
import { computed } from 'vue';
const props = defineProps<{
type: NodeDiffStatus;
}>();
const label = computed(() => {
switch (props.type) {
case NodeDiffStatus.Added:
return 'N';
case NodeDiffStatus.Deleted:
return 'D';
case NodeDiffStatus.Modified:
return 'M';
default:
return '';
}
});
const backgroundColor = computed(() => {
switch (props.type) {
case NodeDiffStatus.Added:
return 'var(--color-node-icon-green)';
case NodeDiffStatus.Deleted:
return 'var(--color-node-icon-red)';
case NodeDiffStatus.Modified:
return 'var(--color-node-icon-orange)';
default:
return '';
}
});
</script>
<template>
<div :class="$style.diffBadge">
{{ label }}
</div>
</template>
<style module>
.diffBadge {
background-color: v-bind(backgroundColor);
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-text-xlight);
font-size: var(--font-size-3xs);
font-weight: 700;
width: 16px;
height: 16px;
border-radius: 4px;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, vi } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
import HighlightedEdge from '@/features/workflow-diff/HighlightedEdge.vue';
import type { CanvasEdgeProps } from '@/components/canvas/elements/edges/CanvasEdge.vue';
import { Position } from '@vue-flow/core';
import { NodeConnectionTypes } from 'n8n-workflow';
// Mock the Edge component
vi.mock('@/components/canvas/elements/edges/CanvasEdge.vue', () => ({
default: {
name: 'CanvasEdge',
template:
'<div class="canvas-edge"><slot name="highlight" v-bind="{ segments: mockSegments }" /></div>',
setup() {
return {
mockSegments: [['M0,0 L100,100', 'test-marker']],
};
},
},
}));
// Mock BaseEdge from vue-flow
vi.mock('@vue-flow/core', () => ({
BaseEdge: {
name: 'BaseEdge',
props: ['style', 'path', 'interactionWidth'],
template: '<path class="base-edge" :d="path" />',
},
Position: {
Left: 'left',
Top: 'top',
Right: 'right',
Bottom: 'bottom',
},
}));
const renderComponent = createComponentRenderer(HighlightedEdge, {
global: {
stubs: {
Edge: {
template:
'<div class="canvas-edge"><slot name="highlight" v-bind="{ segments: mockSegments }" /></div>',
setup() {
return {
mockSegments: [['M0,0 L100,100', 'test-marker']],
};
},
},
BaseEdge: {
name: 'BaseEdge',
props: ['style', 'path', 'interactionWidth'],
template: '<path class="base-edge" :d="path" />',
},
},
},
});
describe('HighlightedEdge', () => {
const mockProps: Partial<CanvasEdgeProps> = {
id: 'edge-1',
source: 'node-1',
target: 'node-2',
sourceX: 0,
sourceY: 0,
sourcePosition: Position.Right,
targetX: 100,
targetY: 100,
targetPosition: Position.Left,
data: {
status: undefined,
source: {
index: 0,
type: NodeConnectionTypes.Main,
},
target: {
index: 0,
type: NodeConnectionTypes.Main,
},
},
};
it('should render the component', () => {
const { container } = renderComponent({
props: mockProps,
});
expect(container.querySelector('.canvas-edge')).toBeInTheDocument();
});
it('should pass props to the Edge component', () => {
const { container } = renderComponent({
props: mockProps,
});
const edgeElement = container.querySelector('.canvas-edge');
expect(edgeElement).toBeInTheDocument();
});
it('should render BaseEdge components in highlight slot', () => {
const { container } = renderComponent({
props: mockProps,
});
const baseEdges = container.querySelectorAll('.base-edge');
expect(baseEdges).toHaveLength(1);
});
it('should render with readonly and non-selectable properties', () => {
const { container } = renderComponent({
props: mockProps,
});
// The Edge component should be rendered (we're testing the wrapper behavior)
expect(container.querySelector('.canvas-edge')).toBeInTheDocument();
});
it('should handle edge props correctly', () => {
const customProps: Partial<CanvasEdgeProps> = {
...mockProps,
id: 'custom-edge',
source: 'custom-source',
target: 'custom-target',
};
const { container } = renderComponent({
props: customProps,
});
expect(container.querySelector('.canvas-edge')).toBeInTheDocument();
});
it('should render with minimal props', () => {
const minimalProps: Partial<CanvasEdgeProps> = {
id: 'minimal-edge',
source: 'src',
target: 'tgt',
};
const { container } = renderComponent({
props: minimalProps,
});
expect(container.querySelector('.canvas-edge')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import type { CanvasEdgeProps } from '@/components/canvas/elements/edges/CanvasEdge.vue';
import Edge from '@/components/canvas/elements/edges/CanvasEdge.vue';
import { BaseEdge } from '@vue-flow/core';
const props = defineProps<CanvasEdgeProps>();
</script>
<template>
<Edge v-bind="props" read-only :selected="false" :selectable="false">
<template #highlight="{ segments }">
<BaseEdge
v-for="segment in segments"
:key="segment[0]"
:style="{
strokeWidth: 15,
stroke: 'var(--edge-highlight-color)',
}"
:path="segment[0]"
:interaction-width="0"
/>
</template>
</Edge>
</template>

View File

@@ -0,0 +1,202 @@
import { describe, it, expect, vi } from 'vitest';
import { createComponentRenderer } from '@/__tests__/render';
import NodeDiff from '@/features/workflow-diff/NodeDiff.vue';
// Mock the v-code-diff library
vi.mock('v-code-diff', () => ({
CodeDiff: {
name: 'CodeDiff',
props: [
'oldString',
'newString',
'outputFormat',
'language',
'hideStat',
'hideHeader',
'forceInlineComparison',
'diffStyle',
],
template: '<div class="code-diff-mock" :data-old="oldString" :data-new="newString" />',
},
}));
const renderComponent = createComponentRenderer(NodeDiff, {
global: {
stubs: {
CodeDiff: {
name: 'CodeDiff',
props: [
'oldString',
'newString',
'outputFormat',
'language',
'hideStat',
'hideHeader',
'forceInlineComparison',
'diffStyle',
],
template: '<div class="code-diff-mock" :data-old="oldString" :data-new="newString" />',
},
},
},
});
describe('NodeDiff', () => {
it('should render with required props', () => {
const { container } = renderComponent({
props: {
oldString: '{"name": "old"}',
newString: '{"name": "new"}',
},
});
const codeDiff = container.querySelector('.code-diff-mock');
expect(codeDiff).toBeInTheDocument();
expect(codeDiff?.getAttribute('data-old')).toBe('{"name": "old"}');
expect(codeDiff?.getAttribute('data-new')).toBe('{"name": "new"}');
});
it('should use default props when not provided', () => {
const { container } = renderComponent({
props: {
oldString: 'old content',
newString: 'new content',
},
});
const codeDiff = container.querySelector('.code-diff-mock');
expect(codeDiff).toBeInTheDocument();
});
it('should pass custom output format', () => {
const { container } = renderComponent({
props: {
oldString: 'old',
newString: 'new',
outputFormat: 'side-by-side',
},
});
expect(container.querySelector('.code-diff-mock')).toBeInTheDocument();
});
it('should pass custom language', () => {
const { container } = renderComponent({
props: {
oldString: 'console.log("old")',
newString: 'console.log("new")',
language: 'javascript',
},
});
expect(container.querySelector('.code-diff-mock')).toBeInTheDocument();
});
it('should handle empty strings', () => {
const { container } = renderComponent({
props: {
oldString: '',
newString: '',
},
});
const codeDiff = container.querySelector('.code-diff-mock');
expect(codeDiff).toBeInTheDocument();
expect(codeDiff?.getAttribute('data-old')).toBe('');
expect(codeDiff?.getAttribute('data-new')).toBe('');
});
it('should handle one empty string', () => {
const { container } = renderComponent({
props: {
oldString: '',
newString: '{"added": "content"}',
},
});
const codeDiff = container.querySelector('.code-diff-mock');
expect(codeDiff).toBeInTheDocument();
expect(codeDiff?.getAttribute('data-old')).toBe('');
expect(codeDiff?.getAttribute('data-new')).toBe('{"added": "content"}');
});
it('should handle JSON strings', () => {
const oldJson = '{"id": "node1", "name": "Original Node", "type": "http"}';
const newJson = '{"id": "node1", "name": "Updated Node", "type": "webhook"}';
const { container } = renderComponent({
props: {
oldString: oldJson,
newString: newJson,
},
});
const codeDiff = container.querySelector('.code-diff-mock');
expect(codeDiff).toBeInTheDocument();
expect(codeDiff?.getAttribute('data-old')).toBe(oldJson);
expect(codeDiff?.getAttribute('data-new')).toBe(newJson);
});
it('should pass all optional props', () => {
const { container } = renderComponent({
props: {
oldString: 'old',
newString: 'new',
outputFormat: 'side-by-side',
language: 'typescript',
hideStat: true,
hideHeader: false,
forceInlineComparison: true,
diffStyle: 'char',
},
});
expect(container.querySelector('.code-diff-mock')).toBeInTheDocument();
});
it('should have correct CSS class', () => {
const { container } = renderComponent({
props: {
oldString: 'test',
newString: 'test2',
},
});
const wrapper = container.querySelector('.code-diff');
expect(wrapper).toBeInTheDocument();
});
it('should handle complex JSON differences', () => {
const oldComplex = JSON.stringify({
id: 'node1',
parameters: {
url: 'https://old.com',
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
position: [100, 200],
});
const newComplex = JSON.stringify({
id: 'node1',
parameters: {
url: 'https://new.com',
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer token' },
},
position: [150, 250],
});
const { container } = renderComponent({
props: {
oldString: oldComplex,
newString: newComplex,
},
});
const codeDiff = container.querySelector('.code-diff-mock');
expect(codeDiff).toBeInTheDocument();
expect(codeDiff?.getAttribute('data-old')).toBe(oldComplex);
expect(codeDiff?.getAttribute('data-new')).toBe(newComplex);
});
});

View File

@@ -0,0 +1,34 @@
<script setup lang="ts">
import { CodeDiff } from 'v-code-diff';
const props = withDefaults(
defineProps<{
oldString: string;
newString: string;
outputFormat?: 'side-by-side' | 'line-by-line';
language?: string;
hideStat?: boolean;
hideHeader?: boolean;
forceInlineComparison?: boolean;
diffStyle?: 'word' | 'char';
}>(),
{
outputFormat: 'line-by-line',
language: 'json',
hideHeader: true,
diffStyle: 'word',
},
);
</script>
<template>
<CodeDiff v-bind="props" class="code-diff" />
</template>
<style scoped>
.code-diff {
height: 100%;
margin: 0;
border: none;
border-radius: 0;
}
</style>

View File

@@ -0,0 +1,89 @@
<script setup lang="ts">
import CanvasBackground from '@/components/canvas/elements/background/CanvasBackground.vue';
import { useInjectViewportSync } from '@/features/workflow-diff/useViewportSync';
import type { CanvasConnection, CanvasNode } from '@/types';
import { useVueFlow } from '@vue-flow/core';
import { watch } from 'vue';
const props = defineProps<{
id: string;
nodes: CanvasNode[];
connections: CanvasConnection[];
}>();
const {
setViewport,
onViewportChange: onLocalViewportChange,
onNodeClick,
fitView,
findNode,
addSelectedNodes,
onPaneClick,
} = useVueFlow({ id: props.id });
const { triggerViewportChange, onViewportChange, selectedDetailId, triggerNodeClick } =
useInjectViewportSync();
/**
* Flag to ignore viewport changes triggered by remote updates,
* preventing infinite sync loops between canvases
*/
let isApplyingRemoteUpdate = false;
onLocalViewportChange((vp) => {
if (isApplyingRemoteUpdate) return;
triggerViewportChange({
from: props.id,
viewport: vp,
});
});
onViewportChange(({ from, viewport }) => {
if (from === props.id) return; // Ignore self
isApplyingRemoteUpdate = true;
void setViewport(viewport);
requestAnimationFrame(() => (isApplyingRemoteUpdate = false));
});
onNodeClick(({ node }) => triggerNodeClick(node.id));
onPaneClick(() => {
setTimeout(() => {
// prevent pane clicks from deselecting nodes
const node = findNode(selectedDetailId.value);
if (!node) {
addSelectedNodes([]);
return;
}
addSelectedNodes([node]);
}, 0);
});
watch(selectedDetailId, (id) => {
const node = findNode(id);
if (!node) {
addSelectedNodes([]); // Clear selection if node not found
return;
}
addSelectedNodes([node]); // Add node to selection
const desiredPixelPadding = node.dimensions.height * 5;
const nodeBoundingSize = Math.max(node.dimensions.width, node.dimensions.height);
const paddingRatio = desiredPixelPadding / nodeBoundingSize;
void fitView({ nodes: [node.id], padding: paddingRatio, duration: 500 });
});
</script>
<template>
<div style="width: 100%; height: 100%; position: relative">
<Canvas :id :nodes :connections :read-only="true" style="width: 100%; height: 100%">
<template #node="{ nodeProps }">
<slot name="node" v-bind="{ nodeProps }" />
</template>
<template #edge="{ edgeProps, arrowHeadMarkerId }">
<slot name="edge" v-bind="{ edgeProps, arrowHeadMarkerId }" />
</template>
<template #canvas-background="{ viewport }">
<CanvasBackground :striped="false" :viewport="viewport" />
</template>
</Canvas>
</div>
</template>

View File

@@ -0,0 +1,305 @@
import { waitFor } from '@testing-library/dom';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import WorkflowDiffModal from '@/features/workflow-diff/WorkflowDiffModal.vue';
import { createTestingPinia } from '@pinia/testing';
import { createEventBus } from '@n8n/utils/event-bus';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
import { ref } from 'vue';
import { createTestWorkflow } from '@/__tests__/mocks';
const eventBus = createEventBus();
vi.mock('@/features/workflow-diff/useViewportSync', () => ({
useProvideViewportSync: () => ({
selectedDetailId: vi.fn(),
onNodeClick: vi.fn(),
}),
}));
vi.mock('@/features/workflow-diff/useWorkflowDiff', () => ({
useWorkflowDiff: () => ({
source: { nodes: [], connections: [] },
target: { nodes: [], connections: [] },
nodesDiff: ref(new Map()),
connectionsDiff: ref(new Map()),
}),
NodeDiffStatus: {
Added: 'added',
Deleted: 'deleted',
Modified: 'modified',
Eq: 'equal',
} as const,
}));
const mockWorkflow = createTestWorkflow({
id: 'test-workflow-id',
name: 'Test Workflow',
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
typeVersion: 1,
position: [250, 300],
parameters: {},
},
{
id: 'node2',
name: 'End',
type: 'n8n-nodes-base.end',
typeVersion: 1,
position: [450, 300],
parameters: {},
},
],
connections: {},
active: true,
settings: {
executionOrder: 'v1' as const,
},
createdAt: '2024-01-01T00:00:00.000Z',
updatedAt: '2024-01-02T00:00:00.000Z',
versionId: 'version-1',
});
const renderModal = createComponentRenderer(WorkflowDiffModal, {
global: {
stubs: {
Modal: {
template: `
<div>
<slot name="header" v-bind="{ closeDialog: () => {} }" />
<slot name="content" />
</div>
`,
},
SyncedWorkflowCanvas: {
template: '<div><slot name="node" /><slot name="edge" /></div>',
},
WorkflowDiffAside: {
template: '<div><slot name="default" v-bind="{ outputFormat: \'unified\' }" /></div>',
},
Node: {
template: '<div class="canvas-node" />',
},
HighlightedEdge: {
template: '<div class="canvas-edge" />',
},
NodeDiff: {
template: '<div class="node-diff" />',
},
DiffBadge: {
template: '<span class="diff-badge" />',
},
NodeIcon: {
template: '<span class="node-icon" />',
},
},
},
});
describe('WorkflowDiffModal', () => {
let nodeTypesStore: MockedStore<typeof useNodeTypesStore>;
let sourceControlStore: MockedStore<typeof useSourceControlStore>;
let workflowsStore: MockedStore<typeof useWorkflowsStore>;
beforeEach(() => {
vi.clearAllMocks();
createTestingPinia();
nodeTypesStore = mockedStore(useNodeTypesStore);
sourceControlStore = mockedStore(useSourceControlStore);
workflowsStore = mockedStore(useWorkflowsStore);
nodeTypesStore.loadNodeTypesIfNotLoaded.mockResolvedValue();
sourceControlStore.getRemoteWorkflow.mockResolvedValue({
content: mockWorkflow,
type: 'workflow',
});
workflowsStore.fetchWorkflow.mockResolvedValue(mockWorkflow);
sourceControlStore.preferences.branchName = 'main';
});
it('should mount successfully', async () => {
const { container } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'push',
},
},
});
// Component should render with the basic structure
expect(container.querySelector('.header')).toBeInTheDocument();
expect(container.querySelector('h1')).toBeInTheDocument();
});
it('should initialize with correct props', () => {
const { container } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'push',
},
},
});
// Component should render with correct modal name and structure
const modal = container.querySelector('[name="workflowDiff"]');
expect(modal).toBeInTheDocument();
expect(modal?.getAttribute('width')).toBe('100%');
expect(modal?.getAttribute('height')).toBe('100%');
});
it('should display changes button', async () => {
const { getByText } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'push',
},
},
});
await waitFor(() => {
expect(getByText('Changes')).toBeInTheDocument();
});
});
it('should open changes dropdown when clicking Changes button', async () => {
const { getByText } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'push',
},
},
});
const changesButton = getByText('Changes');
await userEvent.click(changesButton);
await waitFor(() => {
expect(getByText('Nodes')).toBeInTheDocument();
expect(getByText('Connectors')).toBeInTheDocument();
expect(getByText('Settings')).toBeInTheDocument();
});
});
it('should render workflow panels', () => {
const { container } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'push',
},
},
});
// Component should have workflow diff panels
const panels = container.querySelectorAll('.workflowDiffPanel');
expect(panels).toHaveLength(2); // Source and target workflow panels
});
it('should render navigation buttons', () => {
const { container } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'push',
},
},
});
// Check that navigation buttons are rendered
const prevButton = container.querySelector('[data-icon="chevron-left"]');
const nextButton = container.querySelector('[data-icon="chevron-right"]');
expect(prevButton).toBeInTheDocument();
expect(nextButton).toBeInTheDocument();
});
it('should handle settings diff', () => {
const localWorkflow = { ...mockWorkflow, settings: { executionOrder: 'v1' as const } };
const remoteWorkflow = { ...mockWorkflow, settings: { executionOrder: 'v0' as const } };
sourceControlStore.getRemoteWorkflow.mockResolvedValue({
content: remoteWorkflow,
type: 'workflow',
});
workflowsStore.fetchWorkflow.mockResolvedValue(localWorkflow);
const { getByText } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'push',
},
},
});
expect(getByText('Changes')).toBeInTheDocument();
});
it('should render back button', () => {
const { container } = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'push',
},
},
});
const backButton = container.querySelector('[data-icon="arrow-left"]');
expect(backButton).toBeInTheDocument();
});
it('should handle different workflow directions', () => {
const pullComponent = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'pull',
},
},
});
const pushComponent = renderModal({
pinia: createTestingPinia(),
props: {
data: {
eventBus,
workflowId: 'test-workflow-id',
direction: 'push',
},
},
});
// Both components should render successfully
expect(pullComponent.container.querySelector('.header')).toBeInTheDocument();
expect(pushComponent.container.querySelector('.header')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
import NodeIcon from '@/components/NodeIcon.vue';
import type { INodeUi } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { N8nHeading, N8nIconButton, N8nResizeWrapper } from '@n8n/design-system';
import { computed, ref } from 'vue';
const props = defineProps<{
node: INodeUi;
}>();
const nodeTypesStore = useNodeTypesStore();
const nodeType = computed(() =>
nodeTypesStore.getNodeType(props.node.type, props.node.typeVersion),
);
const panelWidth = ref(350);
function onResize({ width }: { width: number }) {
panelWidth.value = width;
}
const outputFormat = ref<'side-by-side' | 'line-by-line'>('line-by-line');
function toggleOutputFormat() {
outputFormat.value = outputFormat.value === 'line-by-line' ? 'side-by-side' : 'line-by-line';
}
const emit = defineEmits<{
close: [];
}>();
</script>
<template>
<N8nResizeWrapper
:class="$style.workflowDiffAside"
:width="panelWidth"
:min-width="260"
:supported-directions="['left']"
:grid-size="8"
outset
@resize="onResize"
>
<div
style="display: flex; flex-direction: row; align-items: center; gap: 8px; padding: 12px 10px"
>
<NodeIcon class="ml-xs" :node-type :size="16" />
<N8nHeading size="small" color="text-dark" bold>
{{ node.name }}
</N8nHeading>
<N8nIconButton
icon="file-diff"
type="secondary"
class="ml-auto"
@click="toggleOutputFormat"
></N8nIconButton>
<N8nIconButton icon="x" type="secondary" text @click="emit('close')"></N8nIconButton>
</div>
<slot v-bind="{ outputFormat, toggleOutputFormat }" />
</N8nResizeWrapper>
</template>
<style module>
.workflowDiffAside {
width: calc(v-bind(panelWidth) * 1px);
display: flex;
flex-direction: column;
height: 100%;
border-left: 1px solid var(--color-foreground-base);
border-top: 1px solid var(--color-foreground-base);
}
</style>

View File

@@ -0,0 +1,788 @@
<script setup lang="ts">
import Node from '@/components/canvas/elements/nodes/CanvasNode.vue';
import Modal from '@/components/Modal.vue';
import NodeIcon from '@/components/NodeIcon.vue';
import { WORKFLOW_DIFF_MODAL_KEY } from '@/constants';
import DiffBadge from '@/features/workflow-diff/DiffBadge.vue';
import NodeDiff from '@/features/workflow-diff/NodeDiff.vue';
import SyncedWorkflowCanvas from '@/features/workflow-diff/SyncedWorkflowCanvas.vue';
import { useProvideViewportSync } from '@/features/workflow-diff/useViewportSync';
import { NodeDiffStatus, useWorkflowDiff } from '@/features/workflow-diff/useWorkflowDiff';
import type { IWorkflowDb } from '@/Interface';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { useSourceControlStore } from '@/stores/sourceControl.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { N8nButton, N8nHeading, N8nIconButton, N8nRadioButtons, N8nText } from '@n8n/design-system';
import type { BaseTextKey } from '@n8n/i18n';
import { useI18n } from '@n8n/i18n';
import type { EventBus } from '@n8n/utils/event-bus';
import { useAsyncState } from '@vueuse/core';
import { ElDropdown, ElDropdownItem, ElDropdownMenu } from 'element-plus';
import type { IWorkflowSettings } from 'n8n-workflow';
import { computed, ref, useCssModule } from 'vue';
import HighlightedEdge from './HighlightedEdge.vue';
import WorkflowDiffAside from './WorkflowDiffAside.vue';
const props = defineProps<{
data: { eventBus: EventBus; workflowId: string; direction: 'push' | 'pull' };
}>();
const { selectedDetailId, onNodeClick } = useProvideViewportSync();
const $style = useCssModule();
const nodeTypesStore = useNodeTypesStore();
const sourceControlStore = useSourceControlStore();
const i18n = useI18n();
const workflowsStore = useWorkflowsStore();
const manualAsyncConfiguration = {
resetOnExecute: true,
shallow: false,
immediate: false,
} as const;
const remote = useAsyncState<{ workflow?: IWorkflowDb; remote: boolean } | undefined, [], false>(
async () => {
try {
const { workflowId } = props.data;
const { content: workflow } = await sourceControlStore.getRemoteWorkflow(workflowId);
return { workflow, remote: true };
} catch {
return { workflow: undefined, remote: true };
}
},
undefined,
manualAsyncConfiguration,
);
const local = useAsyncState<{ workflow?: IWorkflowDb; remote: boolean } | undefined, [], false>(
async () => {
try {
const { workflowId } = props.data;
const workflow = await workflowsStore.fetchWorkflow(workflowId);
return { workflow, remote: false };
} catch {
return { workflow: undefined, remote: false };
}
},
undefined,
manualAsyncConfiguration,
);
useAsyncState(async () => {
await Promise.all([nodeTypesStore.loadNodeTypesIfNotLoaded()]);
await Promise.all([remote.execute(), local.execute()]);
return true;
}, false);
const sourceWorkFlow = computed(() => (props.data.direction === 'push' ? remote : local));
const targetWorkFlow = computed(() => (props.data.direction === 'push' ? local : remote));
const { source, target, nodesDiff, connectionsDiff } = useWorkflowDiff(
computed(() => sourceWorkFlow.value.state.value?.workflow),
computed(() => targetWorkFlow.value.state.value?.workflow),
);
type SettingsChange = {
name: string;
before: string;
after: string;
};
const settingsDiff = computed(() => {
const sourceSettings: IWorkflowSettings =
sourceWorkFlow.value.state.value?.workflow?.settings ?? {};
const targetSettings: IWorkflowSettings =
targetWorkFlow.value.state.value?.workflow?.settings ?? {};
const allKeys = new Set<keyof IWorkflowSettings>(
[...Object.keys(sourceSettings), ...Object.keys(targetSettings)].filter(
(key): key is keyof IWorkflowSettings => key in sourceSettings || key in targetSettings,
),
);
const settings = Array.from(allKeys).reduce<SettingsChange[]>((acc, key) => {
const val1 = sourceSettings[key];
const val2 = targetSettings[key];
if (val1 !== val2) {
acc.push({
name: key,
before: String(val1),
after: String(val2),
});
}
return acc;
}, []);
const sourceTags = (sourceWorkFlow.value.state.value?.workflow?.tags ?? []).map((tag) =>
typeof tag === 'string' ? tag : tag.name,
);
const targetTags = (targetWorkFlow.value.state.value?.workflow?.tags ?? []).map((tag) =>
typeof tag === 'string' ? tag : tag.name,
);
if (sourceTags.join('') !== targetTags.join('')) {
settings.push({
name: 'tags',
before: JSON.stringify(sourceTags, null, 2),
after: JSON.stringify(targetTags, null, 2),
});
}
return settings;
});
function getNodeStatusClass(id: string) {
const status = nodesDiff.value?.get(id)?.status ?? 'equal';
return $style[status];
}
function getEdgeStatusClass(id: string) {
const status = connectionsDiff.value.get(id)?.status ?? NodeDiffStatus.Eq;
return $style[`edge-${status}`];
}
const nodeChanges = computed(() => {
if (!nodesDiff.value) return [];
return [...nodesDiff.value.values()]
.filter((change) => change.status !== NodeDiffStatus.Eq)
.map((change) => ({
...change,
type: nodeTypesStore.getNodeType(change.node.type, change.node.typeVersion),
}));
});
function nextNodeChange() {
const currentIndex = nodeChanges.value.findIndex(
(change) => change.node.id === selectedDetailId.value,
);
const nextIndex = (currentIndex + 1) % nodeChanges.value.length;
selectedDetailId.value = nodeChanges.value[nextIndex]?.node.id;
}
function previousNodeChange() {
const currentIndex = nodeChanges.value.findIndex(
(change) => change.node.id === selectedDetailId.value,
);
const previousIndex = (currentIndex - 1 + nodeChanges.value.length) % nodeChanges.value.length;
selectedDetailId.value = nodeChanges.value[previousIndex]?.node.id;
}
const activeTab = ref<'nodes' | 'connectors' | 'settings'>();
const tabs = computed(() => [
{
value: 'nodes' as const,
label: 'Nodes',
disabled: nodeChanges.value.length === 0,
data: {
count: nodeChanges.value.length,
},
},
{
value: 'connectors' as const,
label: 'Connectors',
disabled: connectionsDiff.value.size === 0,
data: {
count: connectionsDiff.value.size,
},
},
{
value: 'settings' as const,
label: 'Settings',
disabled: settingsDiff.value.length === 0,
data: {
count: settingsDiff.value.length,
},
},
]);
function setActiveTab(active: boolean) {
if (!active) {
activeTab.value = undefined;
return;
}
const value = tabs.value.find((tab) => !tab.disabled)?.value ?? 'nodes';
activeTab.value = value;
}
const selectedNode = computed(() => {
if (!selectedDetailId.value) return undefined;
const node = nodesDiff.value.get(selectedDetailId.value)?.node;
if (!node) return undefined;
return node;
});
const nodeDiffs = computed(() => {
if (!selectedDetailId.value) {
return {
oldString: '',
newString: '',
};
}
const sourceNode = sourceWorkFlow.value?.state.value?.workflow?.nodes.find(
(node) => node.id === selectedDetailId.value,
);
const targetNode = targetWorkFlow.value?.state.value?.workflow?.nodes.find(
(node) => node.id === selectedDetailId.value,
);
return {
oldString: JSON.stringify(sourceNode, null, 2) ?? '',
newString: JSON.stringify(targetNode, null, 2) ?? '',
};
});
function handleBeforeClose() {
selectedDetailId.value = undefined;
}
const changesCount = computed(
() => nodeChanges.value.length + connectionsDiff.value.size + settingsDiff.value.length,
);
onNodeClick((nodeId) => {
const status = nodesDiff.value.get(nodeId)?.status;
if (status && status !== NodeDiffStatus.Eq) {
selectedDetailId.value = nodeId;
}
});
const modifiers = [
{
name: 'preventOverflow',
options: {
boundary: 'viewport',
padding: 8,
},
},
{
name: 'offset',
options: {
offset: [80, 8],
},
},
];
</script>
<template>
<Modal
:event-bus="data.eventBus"
:name="WORKFLOW_DIFF_MODAL_KEY"
:custom-class="$style.workflowDiffModal"
height="100%"
width="100%"
max-width="100%"
max-height="100%"
@before-close="handleBeforeClose"
>
<template #header="{ closeDialog }">
<div :class="$style.header">
<div :class="$style.headerLeft">
<N8nIconButton
icon="arrow-left"
type="secondary"
class="mr-xs"
@click="closeDialog"
></N8nIconButton>
<N8nHeading tag="h1" size="xlarge">
{{
sourceWorkFlow.state.value?.workflow?.name ||
targetWorkFlow.state.value?.workflow?.name
}}
</N8nHeading>
</div>
<div>
<ElDropdown
trigger="click"
:popper-options="{
placement: 'bottom-end',
modifiers,
}"
:popper-class="$style.popper"
class="mr-xs"
@visible-change="setActiveTab"
>
<N8nButton type="secondary">
<div v-if="changesCount" :class="$style.circleBadge">
{{ changesCount }}
</div>
Changes
</N8nButton>
<template #dropdown>
<ElDropdownMenu :hide-on-click="false">
<div :class="$style.dropdownContent">
<N8nRadioButtons
v-model="activeTab"
:options="tabs"
:class="$style.tabs"
class="mb-xs"
>
<template #option="{ label, data: optionData }">
{{ label }}
<span v-if="optionData?.count" class="ml-4xs">
({{ optionData.count }})
</span>
</template>
</N8nRadioButtons>
<div>
<ul v-if="activeTab === 'nodes'">
<ElDropdownItem
v-for="change in nodeChanges"
:key="change.node.id"
:class="{
[$style.clickableChange]: true,
[$style.clickableChangeActive]: selectedDetailId === change.node.id,
}"
@click.prevent="selectedDetailId = change.node.id"
>
<DiffBadge :type="change.status" />
<NodeIcon :node-type="change.type" :size="16" />
{{ change.node.name }}
</ElDropdownItem>
</ul>
<ul v-if="activeTab === 'connectors'" :class="$style.changes">
<li v-for="change in connectionsDiff" :key="change[0]">
<div>
<DiffBadge :type="change[1].status" />
</div>
<div style="flex: 1">
<ul :class="$style.changesNested">
<ElDropdownItem
:class="{
[$style.clickableChange]: true,
[$style.clickableChangeActive]:
selectedDetailId === change[1].connection.source?.id,
}"
@click.prevent="selectedDetailId = change[1].connection.source?.id"
>
<NodeIcon :node-type="change[1].connection.sourceType" :size="16" />
{{ change[1].connection.source?.name }}
</ElDropdownItem>
<div :class="$style.separator"></div>
<ElDropdownItem
:class="{
[$style.clickableChange]: true,
[$style.clickableChangeActive]:
selectedDetailId === change[1].connection.target?.id,
}"
@click.prevent="selectedDetailId = change[1].connection.target?.id"
>
<NodeIcon :node-type="change[1].connection.targetType" :size="16" />
{{ change[1].connection.target?.name }}
</ElDropdownItem>
</ul>
</div>
</li>
</ul>
<ul v-if="activeTab === 'settings'">
<li v-for="setting in settingsDiff" :key="setting.name">
<N8nText color="text-dark" size="medium" tag="div" bold>{{
i18n.baseText(`workflowSettings.${setting.name}` as BaseTextKey)
}}</N8nText>
<NodeDiff
:old-string="setting.before"
:new-string="setting.after"
:class="$style.noNumberDiff"
/>
</li>
</ul>
</div>
</div>
</ElDropdownMenu>
</template>
</ElDropdown>
<N8nIconButton
icon="chevron-left"
type="secondary"
:class="$style.navigationButton"
@click="previousNodeChange"
></N8nIconButton>
<N8nIconButton
icon="chevron-right"
type="secondary"
:class="$style.navigationButton"
@click="nextNodeChange"
></N8nIconButton>
</div>
</div>
</template>
<template #content>
<div :class="$style.workflowDiffContent">
<div :class="$style.workflowDiff">
<div :class="$style.workflowDiffPanel">
<template v-if="sourceWorkFlow.state.value">
<N8nText color="text-dark" size="small" :class="$style.sourceBadge">
<N8nIcon v-if="sourceWorkFlow.state.value.remote" icon="git-branch" />
{{
sourceWorkFlow.state.value.remote
? `Remote (${sourceControlStore.preferences.branchName})`
: 'Local'
}}
</N8nText>
<template v-if="sourceWorkFlow.state.value.workflow">
<SyncedWorkflowCanvas
id="top"
:nodes="source.nodes"
:connections="source.connections"
>
<template #node="{ nodeProps }">
<Node v-bind="nodeProps" :class="{ [getNodeStatusClass(nodeProps.id)]: true }">
<template #toolbar />
</Node>
</template>
<template #edge="{ edgeProps, arrowHeadMarkerId }">
<HighlightedEdge
v-bind="edgeProps"
:marker-end="`url(#${arrowHeadMarkerId})`"
:class="{ [getEdgeStatusClass(edgeProps.id)]: true }"
/>
</template>
</SyncedWorkflowCanvas>
</template>
<template v-else>
<div :class="$style.emptyWorkflow">
<template v-if="targetWorkFlow.state.value?.remote">
<N8nText color="text-dark" size="large"> Deleted workflow </N8nText>
<N8nText color="text-base"> The workflow was deleted on the database </N8nText>
</template>
<template v-else>
<N8nText color="text-dark" size="large"> Deleted workflow </N8nText>
<N8nText color="text-base"> The workflow was deleted on remote </N8nText>
</template>
</div>
</template>
</template>
</div>
<div :class="$style.workflowDiffPanel">
<template v-if="targetWorkFlow.state.value">
<N8nText color="text-dark" size="small" :class="$style.sourceBadge">
<N8nIcon v-if="targetWorkFlow.state.value.remote" icon="git-branch" />
{{
targetWorkFlow.state.value.remote
? `Remote (${sourceControlStore.preferences.branchName})`
: 'Local'
}}
</N8nText>
<template v-if="targetWorkFlow.state.value.workflow">
<SyncedWorkflowCanvas
id="bottom"
:nodes="target.nodes"
:connections="target.connections"
>
<template #node="{ nodeProps }">
<Node v-bind="nodeProps" :class="{ [getNodeStatusClass(nodeProps.id)]: true }">
<template #toolbar />
</Node>
</template>
<template #edge="{ edgeProps, arrowHeadMarkerId }">
<HighlightedEdge
v-bind="edgeProps"
:marker-end="`url(#${arrowHeadMarkerId})`"
:class="{ [getEdgeStatusClass(edgeProps.id)]: true }"
/>
</template>
</SyncedWorkflowCanvas>
</template>
<template v-else>
<div :class="$style.emptyWorkflow">
<template v-if="targetWorkFlow.state.value?.remote">
<N8nText color="text-dark" size="large"> Deleted workflow </N8nText>
<N8nText color="text-base"> The workflow was deleted on remote </N8nText>
</template>
<template v-else>
<N8nText color="text-dark" size="large"> Deleted workflow </N8nText>
<N8nText color="text-base"> The workflow was deleted on the data base </N8nText>
</template>
</div>
</template>
</template>
</div>
</div>
<WorkflowDiffAside
v-if="selectedNode"
:node="selectedNode"
@close="selectedDetailId = undefined"
>
<template #default="{ outputFormat }">
<NodeDiff v-bind="nodeDiffs" :output-format="outputFormat" />
</template>
</WorkflowDiffAside>
</div>
</template>
</Modal>
</template>
<style module>
.workflowDiffModal {
margin-bottom: 0;
border-radius: 0;
:global(.el-dialog__body) {
padding: 0;
}
:global(.el-dialog__header) {
padding: 11px 16px;
border-bottom: 1px solid var(--color-border);
}
:global(.el-dialog__headerbtn) {
display: none;
}
}
.sourceBadge {
position: absolute;
top: 12px;
left: 12px;
z-index: 1;
border-radius: 4px;
border: 1px solid var(--color-foreground-light);
background: var(--color-foreground-xlight);
display: flex;
height: 30px;
padding: 0 12px;
align-items: center;
gap: 8px;
align-self: stretch;
}
.tabs {
display: flex;
flex-direction: row;
:global(.n8n-radio-button) {
flex: 1;
}
:global(.n8n-radio-button > div) {
justify-content: center;
}
}
.popper {
box-shadow: 0px 6px 16px 0px rgba(68, 28, 23, 0.06);
:global(.el-popper__arrow) {
display: none;
}
}
.changes {
list-style: none;
margin: 0;
padding: 0;
> li {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px;
}
}
.separator {
width: 1px;
height: 10px;
background-color: var(--color-foreground-xdark);
margin: -5px 23px;
position: relative;
z-index: 1;
}
.clickableChange {
display: flex;
align-items: center;
gap: 8px;
border-radius: 4px;
}
.clickableChangeActive {
background-color: var(--color-background-medium);
}
.deleted,
.added,
.modified {
position: relative;
&::before {
position: absolute;
bottom: 0px;
left: 0px;
border-bottom-left-radius: 6px;
border-top-right-radius: 2px;
color: var(--color-text-xlight);
font-family: var(--font-family-monospace);
font-size: 8px;
font-weight: 700;
z-index: 1;
width: 16px;
height: 16px;
display: inline-flex;
justify-content: center;
align-items: center;
}
&[data-node-type='n8n-nodes-base.stickyNote'],
&[data-node-type='n8n-nodes-base.manualTrigger'] {
&::before {
left: auto;
right: 0px;
border-top-right-radius: 0;
border-top-left-radius: 2px;
border-bottom-left-radius: 0;
border-bottom-right-radius: 6px;
}
}
}
.deleted {
--canvas-node--background: rgba(234, 31, 48, 0.2);
--canvas-node--border-color: var(--color-node-icon-red);
--color-sticky-background: rgba(234, 31, 48, 0.2);
--color-sticky-border: var(--color-node-icon-red);
&::before {
content: 'D';
background-color: var(--color-node-icon-red);
}
:global(.canvas-node-handle-main-output > div) {
background-color: var(--color-node-icon-red);
}
:global(.canvas-node-handle-main-input .target) {
background-color: var(--color-node-icon-red);
}
}
.added {
--canvas-node--border-color: var(--color-node-icon-green);
--canvas-node--background: rgba(14, 171, 84, 0.2);
--color-sticky-background: rgba(14, 171, 84, 0.2);
--color-sticky-border: var(--color-node-icon-green);
position: relative;
&::before {
content: 'N';
background-color: var(--color-node-icon-green);
}
:global(.canvas-node-handle-main-output > div) {
background-color: var(--color-node-icon-green);
}
:global(.canvas-node-handle-main-input .target) {
background-color: var(--color-node-icon-green);
}
:global(.canvas-node-handle-main-input .target) {
background-color: var(--color-node-icon-green);
}
}
.equal {
opacity: 0.5;
position: relative;
pointer-events: none;
cursor: default;
--color-sticky-background: rgba(126, 129, 134, 0.2);
--canvas-node-icon-color: var(--color-foreground-xdark);
--color-sticky-border: var(--color-foreground-xdark);
&:deep(img) {
filter: contrast(0) grayscale(100%);
}
}
.modified {
--canvas-node--border-color: var(--color-node-icon-orange);
--canvas-node--background: rgba(255, 150, 90, 0.2);
--color-sticky-background: rgba(255, 150, 90, 0.2);
--color-sticky-border: var(--color-node-icon-orange);
position: relative;
&::before {
content: 'M';
background-color: var(--color-node-icon-orange);
}
:global(.canvas-node-handle-main-output .source) {
--color-foreground-xdark: var(--color-node-icon-orange);
}
:global(.canvas-node-handle-main-input .target) {
background-color: var(--color-node-icon-orange);
}
}
.edge-deleted {
--canvas-edge-color: var(--color-node-icon-red);
--edge-highlight-color: rgba(234, 31, 48, 0.2);
}
.edge-added {
--canvas-edge-color: var(--color-node-icon-green);
--edge-highlight-color: rgba(14, 171, 84, 0.2);
}
.edge-equal {
opacity: 0.5;
}
.noNumberDiff {
min-height: 41px;
margin-bottom: 10px !important;
:global(.blob-num) {
display: none;
}
}
.circleBadge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background-color: var(--color-background-medium);
color: var(--color-text-dark);
font-size: 10px;
font-weight: bold;
line-height: 1;
}
.dropdownContent {
min-width: 300px;
padding: 2px 12px;
}
.workflowDiffContent {
display: flex;
height: 100%;
}
.workflowDiff {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
}
.workflowDiffPanel {
flex: 1;
position: relative;
border-top: 1px solid #ddd;
}
.emptyWorkflow {
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
}
.headerLeft {
display: flex;
align-items: center;
}
.navigationButton {
height: 34px !important;
width: 34px !important;
}
</style>

View File

@@ -0,0 +1,233 @@
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { defineComponent } from 'vue';
import { useProvideViewportSync, useInjectViewportSync } from './useViewportSync';
import type { ViewportSyncReturn } from './useViewportSync';
import { render } from '@testing-library/vue';
describe('useViewportSync', () => {
let requestAnimationFrameSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
vi.useFakeTimers();
requestAnimationFrameSpy = vi.spyOn(global, 'requestAnimationFrame') as ReturnType<
typeof vi.spyOn
>;
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
const createProviderWrapper = (): ViewportSyncReturn => {
let result: ViewportSyncReturn | undefined;
const TestComponent = defineComponent({
setup() {
result = useProvideViewportSync();
return {};
},
template: '<div></div>',
});
render(TestComponent);
if (!result) {
throw new Error('Failed to initialize viewport sync provider');
}
return result;
};
describe('useProvideViewportSync', () => {
it('should provide viewport sync state and functions', () => {
const result = createProviderWrapper();
expect(result).toHaveProperty('onViewportChange');
expect(result).toHaveProperty('triggerViewportChange');
expect(result).toHaveProperty('selectedDetailId');
expect(result).toHaveProperty('syncIsEnabled');
expect(typeof result.onViewportChange).toBe('function');
expect(typeof result.triggerViewportChange).toBe('function');
expect(result.selectedDetailId.value).toBeUndefined();
expect(result.syncIsEnabled.value).toBe(true);
});
it('should allow setting selectedDetailId', () => {
const { selectedDetailId } = createProviderWrapper();
selectedDetailId.value = 'detail-123';
expect(selectedDetailId.value).toBe('detail-123');
});
it('should allow toggling syncIsEnabled', () => {
const { syncIsEnabled } = createProviderWrapper();
expect(syncIsEnabled.value).toBe(true);
syncIsEnabled.value = false;
expect(syncIsEnabled.value).toBe(false);
});
});
describe('useInjectViewportSync', () => {
it('should throw error when called without provider', () => {
const TestComponent = defineComponent({
setup() {
expect(() => {
useInjectViewportSync();
}).toThrow('Please call "useProvideViewportSync" on the appropriate parent component');
return {};
},
template: '<div></div>',
});
render(TestComponent);
});
});
describe('triggerViewportChange', () => {
it('should trigger viewport change when sync is enabled', () => {
const { onViewportChange, triggerViewportChange } = createProviderWrapper();
const mockListener = vi.fn();
onViewportChange(mockListener);
const update = {
from: 'test-source',
viewport: { x: 100, y: 200, zoom: 1.5 },
};
triggerViewportChange(update);
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
const frameCallback = requestAnimationFrameSpy.mock.calls[0][0] as (time: number) => void;
frameCallback(0);
expect(mockListener).toHaveBeenCalledWith(update);
});
it('should not trigger viewport change when sync is disabled', () => {
const { onViewportChange, triggerViewportChange, syncIsEnabled } = createProviderWrapper();
const mockListener = vi.fn();
onViewportChange(mockListener);
syncIsEnabled.value = false;
const update = {
from: 'test-source',
viewport: { x: 100, y: 200, zoom: 1.5 },
};
triggerViewportChange(update);
expect(requestAnimationFrameSpy).not.toHaveBeenCalled();
expect(mockListener).not.toHaveBeenCalled();
});
it('should batch multiple viewport changes in single frame', () => {
const { onViewportChange, triggerViewportChange } = createProviderWrapper();
const mockListener = vi.fn();
onViewportChange(mockListener);
const update1 = {
from: 'test-source-1',
viewport: { x: 100, y: 200, zoom: 1.5 },
};
const update2 = {
from: 'test-source-2',
viewport: { x: 300, y: 400, zoom: 2.0 },
};
triggerViewportChange(update1);
triggerViewportChange(update2);
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
const frameCallback = requestAnimationFrameSpy.mock.calls[0][0] as (time: number) => void;
frameCallback(0);
expect(mockListener).toHaveBeenCalledTimes(1);
expect(mockListener).toHaveBeenCalledWith(update2);
});
it('should reset scheduledFrameId after frame execution', () => {
const { onViewportChange, triggerViewportChange } = createProviderWrapper();
const mockListener = vi.fn();
onViewportChange(mockListener);
const update1 = {
from: 'test-source-1',
viewport: { x: 100, y: 200, zoom: 1.5 },
};
const update2 = {
from: 'test-source-2',
viewport: { x: 300, y: 400, zoom: 2.0 },
};
triggerViewportChange(update1);
const frameCallback = requestAnimationFrameSpy.mock.calls[0][0] as (time: number) => void;
frameCallback(0);
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1);
triggerViewportChange(update2);
expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(2);
});
it('should handle case when no pending update exists during frame callback', () => {
const { onViewportChange, triggerViewportChange } = createProviderWrapper();
const mockListener = vi.fn();
onViewportChange(mockListener);
const update = {
from: 'test-source',
viewport: { x: 100, y: 200, zoom: 1.5 },
};
triggerViewportChange(update);
const frameCallback = requestAnimationFrameSpy.mock.calls[0][0] as (time: number) => void;
frameCallback(0);
expect(mockListener).toHaveBeenCalledTimes(1);
frameCallback(0);
expect(mockListener).toHaveBeenCalledTimes(1);
});
});
describe('multiple listeners', () => {
it('should notify all listeners when viewport changes', () => {
const { onViewportChange, triggerViewportChange } = createProviderWrapper();
const mockListener1 = vi.fn();
const mockListener2 = vi.fn();
onViewportChange(mockListener1);
onViewportChange(mockListener2);
const update = {
from: 'test-source',
viewport: { x: 100, y: 200, zoom: 1.5 },
};
triggerViewportChange(update);
const frameCallback = requestAnimationFrameSpy.mock.calls[0][0] as (time: number) => void;
frameCallback(0);
expect(mockListener1).toHaveBeenCalledWith(update);
expect(mockListener2).toHaveBeenCalledWith(update);
});
});
});

View File

@@ -0,0 +1,80 @@
import { createInjectionState, createEventHook } from '@vueuse/core';
import { ref } from 'vue';
import type { Ref } from 'vue';
type Viewport = {
x: number;
y: number;
zoom: number;
};
type ViewportUpdate = {
from: string;
viewport: Viewport;
};
export type ViewportSyncReturn = {
onViewportChange: (
handler: (update: { from: string; viewport: { x: number; y: number; zoom: number } }) => void,
) => void;
triggerViewportChange: (update: {
from: string;
viewport: { x: number; y: number; zoom: number };
}) => void;
onNodeClick: (handler: (update: string) => void) => void;
triggerNodeClick: (update: string) => void;
selectedDetailId: Ref<string | undefined>;
syncIsEnabled: Ref<boolean>;
};
const [useProvideViewportSync, useInject] = createInjectionState<[], ViewportSyncReturn>(() => {
const onViewportChange = createEventHook<ViewportUpdate>();
const onNodeClick = createEventHook<string>();
const selectedDetailId = ref<string>();
const syncIsEnabled = ref(true);
/**
* Batches viewport sync using requestAnimationFrame to avoid flooding listeners
* Provides a better performance by reducing the number of updates
* and ensuring that all updates are processed in a single frame.
* it's better than throttling because it allows for smoother updates
* and avoids the "jank" that can occur with throttling.
*/
let scheduledFrameId: number | null = null;
let pendingUpdate: ViewportUpdate | null = null;
function triggerViewportChange(update: ViewportUpdate) {
if (!syncIsEnabled.value) {
return;
}
pendingUpdate = update;
scheduledFrameId ??= requestAnimationFrame(() => {
if (pendingUpdate) {
void onViewportChange.trigger(pendingUpdate);
pendingUpdate = null;
}
scheduledFrameId = null;
});
}
return {
onViewportChange: onViewportChange.on,
triggerViewportChange,
onNodeClick: onNodeClick.on,
triggerNodeClick: onNodeClick.trigger,
selectedDetailId,
syncIsEnabled,
};
});
function useInjectViewportSync() {
const state = useInject();
if (!state) {
throw new Error('Please call "useProvideViewportSync" on the appropriate parent component');
}
return state;
}
export { useProvideViewportSync, useInjectViewportSync };

View File

@@ -0,0 +1,638 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref, computed } from 'vue';
import {
NodeDiffStatus,
compareNodes,
compareWorkflowsNodes,
mapConnections,
useWorkflowDiff,
} from './useWorkflowDiff';
import type { CanvasConnection, CanvasNode, ExecutionOutputMap } from '@/types';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import type { IConnections } from 'n8n-workflow';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
// Mock modules at top level
vi.mock('@/stores/workflows.store', () => ({
useWorkflowsStore: () => ({
getWorkflow: vi.fn().mockReturnValue({
id: 'test-workflow',
nodes: [],
connections: {},
}),
}),
}));
vi.mock('@/stores/nodeTypes.store', () => ({
useNodeTypesStore: () => ({
getNodeType: vi.fn().mockReturnValue({
name: 'Test Node Type',
version: 1,
}),
}),
}));
vi.mock('@/composables/useCanvasMapping', () => ({
useCanvasMapping: vi.fn().mockReturnValue({
additionalNodePropertiesById: computed(() => ({})),
nodeExecutionRunDataOutputMapById: computed(() => ({})),
nodeExecutionWaitingForNextById: computed(() => ({})),
nodeIssuesById: computed(() => ({})),
nodeHasIssuesById: computed(() => ({})),
nodes: computed(() => []),
connections: computed(() => []),
}),
}));
describe('useWorkflowDiff', () => {
describe('NodeDiffStatus', () => {
it('should have correct enum values', () => {
expect(NodeDiffStatus.Eq).toBe('equal');
expect(NodeDiffStatus.Modified).toBe('modified');
expect(NodeDiffStatus.Added).toBe('added');
expect(NodeDiffStatus.Deleted).toBe('deleted');
});
});
describe('compareNodes', () => {
const createTestNode = (overrides: Partial<TestNode> = {}): TestNode => ({
id: 'test-node-1',
name: 'Test Node',
type: 'test-type',
typeVersion: 1,
webhookId: 'webhook-123',
credentials: { test: 'credential' },
parameters: { param1: 'value1' },
position: [100, 200],
disabled: false,
...overrides,
});
type TestNode = {
id: string;
name: string;
type: string;
typeVersion: number;
webhookId: string;
credentials: Record<string, unknown>;
parameters: Record<string, unknown>;
position: [number, number];
disabled: boolean;
};
it('should return true for identical nodes', () => {
const node1 = createTestNode();
const node2 = createTestNode();
const result = compareNodes(node1, node2);
expect(result).toBe(true);
});
it('should return false when nodes have different names', () => {
const node1 = createTestNode({ name: 'Node 1' });
const node2 = createTestNode({ name: 'Node 2' });
const result = compareNodes(node1, node2);
expect(result).toBe(false);
});
it('should return false when nodes have different types', () => {
const node1 = createTestNode({ type: 'type1' });
const node2 = createTestNode({ type: 'type2' });
const result = compareNodes(node1, node2);
expect(result).toBe(false);
});
it('should return false when nodes have different typeVersions', () => {
const node1 = createTestNode({ typeVersion: 1 });
const node2 = createTestNode({ typeVersion: 2 });
const result = compareNodes(node1, node2);
expect(result).toBe(false);
});
it('should return false when nodes have different webhookIds', () => {
const node1 = createTestNode({ webhookId: 'webhook1' });
const node2 = createTestNode({ webhookId: 'webhook2' });
const result = compareNodes(node1, node2);
expect(result).toBe(false);
});
it('should return false when nodes have different credentials', () => {
const node1 = createTestNode({ credentials: { test: 'cred1' } });
const node2 = createTestNode({ credentials: { test: 'cred2' } });
const result = compareNodes(node1, node2);
expect(result).toBe(false);
});
it('should return false when nodes have different parameters', () => {
const node1 = createTestNode({ parameters: { param1: 'value1' } });
const node2 = createTestNode({ parameters: { param1: 'value2' } });
const result = compareNodes(node1, node2);
expect(result).toBe(false);
});
it('should ignore properties not in comparison list', () => {
const node1 = createTestNode({ position: [100, 200], disabled: false });
const node2 = createTestNode({ position: [300, 400], disabled: true });
const result = compareNodes(node1, node2);
expect(result).toBe(true);
});
it('should handle undefined base node', () => {
const node2 = createTestNode();
const result = compareNodes(undefined, node2);
expect(result).toBe(false);
});
it('should handle undefined target node', () => {
const node1 = createTestNode();
const result = compareNodes(node1, undefined);
expect(result).toBe(false);
});
it('should handle both nodes being undefined', () => {
const result = compareNodes(undefined, undefined);
expect(result).toBe(true);
});
});
describe('compareWorkflowsNodes', () => {
const createTestNode = (id: string, overrides: Partial<TestNode> = {}): TestNode => ({
id,
name: `Node ${id}`,
type: 'test-type',
typeVersion: 1,
webhookId: `webhook-${id}`,
credentials: {},
parameters: {},
...overrides,
});
type TestNode = {
id: string;
name: string;
type: string;
typeVersion: number;
webhookId: string;
credentials: Record<string, unknown>;
parameters: Record<string, unknown>;
};
it('should detect equal nodes', () => {
const baseNodes = [createTestNode('1'), createTestNode('2')];
const targetNodes = [createTestNode('1'), createTestNode('2')];
const diff = compareWorkflowsNodes(baseNodes, targetNodes);
expect(diff.size).toBe(2);
expect(diff.get('1')?.status).toBe(NodeDiffStatus.Eq);
expect(diff.get('2')?.status).toBe(NodeDiffStatus.Eq);
});
it('should detect modified nodes', () => {
const baseNodes = [createTestNode('1', { name: 'Original Name' })];
const targetNodes = [createTestNode('1', { name: 'Modified Name' })];
const diff = compareWorkflowsNodes(baseNodes, targetNodes);
expect(diff.size).toBe(1);
expect(diff.get('1')?.status).toBe(NodeDiffStatus.Modified);
expect(diff.get('1')?.node).toEqual(baseNodes[0]);
});
it('should detect added nodes', () => {
const baseNodes = [createTestNode('1')];
const targetNodes = [createTestNode('1'), createTestNode('2')];
const diff = compareWorkflowsNodes(baseNodes, targetNodes);
expect(diff.size).toBe(2);
expect(diff.get('1')?.status).toBe(NodeDiffStatus.Eq);
expect(diff.get('2')?.status).toBe(NodeDiffStatus.Added);
expect(diff.get('2')?.node).toEqual(targetNodes[1]);
});
it('should detect deleted nodes', () => {
const baseNodes = [createTestNode('1'), createTestNode('2')];
const targetNodes = [createTestNode('1')];
const diff = compareWorkflowsNodes(baseNodes, targetNodes);
expect(diff.size).toBe(2);
expect(diff.get('1')?.status).toBe(NodeDiffStatus.Eq);
expect(diff.get('2')?.status).toBe(NodeDiffStatus.Deleted);
expect(diff.get('2')?.node).toEqual(baseNodes[1]);
});
it('should handle empty base array', () => {
const baseNodes: TestNode[] = [];
const targetNodes = [createTestNode('1'), createTestNode('2')];
const diff = compareWorkflowsNodes(baseNodes, targetNodes);
expect(diff.size).toBe(2);
expect(diff.get('1')?.status).toBe(NodeDiffStatus.Added);
expect(diff.get('2')?.status).toBe(NodeDiffStatus.Added);
});
it('should handle empty target array', () => {
const baseNodes = [createTestNode('1'), createTestNode('2')];
const targetNodes: TestNode[] = [];
const diff = compareWorkflowsNodes(baseNodes, targetNodes);
expect(diff.size).toBe(2);
expect(diff.get('1')?.status).toBe(NodeDiffStatus.Deleted);
expect(diff.get('2')?.status).toBe(NodeDiffStatus.Deleted);
});
it('should handle both arrays being empty', () => {
const baseNodes: TestNode[] = [];
const targetNodes: TestNode[] = [];
const diff = compareWorkflowsNodes(baseNodes, targetNodes);
expect(diff.size).toBe(0);
});
it('should use custom comparison function when provided', () => {
const baseNodes = [createTestNode('1', { name: 'Original' })];
const targetNodes = [createTestNode('1', { name: 'Modified' })];
const customCompare = vi.fn().mockReturnValue(true);
const diff = compareWorkflowsNodes(baseNodes, targetNodes, customCompare);
expect(customCompare).toHaveBeenCalledWith(baseNodes[0], targetNodes[0]);
expect(diff.get('1')?.status).toBe(NodeDiffStatus.Eq);
});
it('should handle complex workflow comparison', () => {
const baseNodes = [
createTestNode('1', { name: 'Node 1' }),
createTestNode('2', { name: 'Node 2' }),
createTestNode('3', { name: 'Node 3' }),
];
const targetNodes = [
createTestNode('1', { name: 'Node 1' }), // Equal
createTestNode('2', { name: 'Node 2 Modified' }), // Modified
createTestNode('4', { name: 'Node 4' }), // Added
];
const diff = compareWorkflowsNodes(baseNodes, targetNodes);
expect(diff.size).toBe(4);
expect(diff.get('1')?.status).toBe(NodeDiffStatus.Eq);
expect(diff.get('2')?.status).toBe(NodeDiffStatus.Modified);
expect(diff.get('3')?.status).toBe(NodeDiffStatus.Deleted);
expect(diff.get('4')?.status).toBe(NodeDiffStatus.Added);
});
});
describe('mapConnections', () => {
const createTestConnection = (
id: string,
overrides: Partial<CanvasConnection> = {},
): CanvasConnection => ({
id,
source: `source-${id}`,
target: `target-${id}`,
sourceHandle: `source-handle-${id}`,
targetHandle: `target-handle-${id}`,
...overrides,
});
it('should map connections correctly', () => {
const connections = [
createTestConnection('conn1'),
createTestConnection('conn2'),
createTestConnection('conn3'),
];
const result = mapConnections(connections);
expect(result.set.size).toBe(3);
expect(result.map.size).toBe(3);
expect(result.set.has('conn1')).toBe(true);
expect(result.set.has('conn2')).toBe(true);
expect(result.set.has('conn3')).toBe(true);
expect(result.map.get('conn1')).toEqual(connections[0]);
expect(result.map.get('conn2')).toEqual(connections[1]);
expect(result.map.get('conn3')).toEqual(connections[2]);
});
it('should handle empty connections array', () => {
const connections: CanvasConnection[] = [];
const result = mapConnections(connections);
expect(result.set.size).toBe(0);
expect(result.map.size).toBe(0);
});
it('should handle single connection', () => {
const connections = [createTestConnection('single')];
const result = mapConnections(connections);
expect(result.set.size).toBe(1);
expect(result.map.size).toBe(1);
expect(result.set.has('single')).toBe(true);
expect(result.map.get('single')).toEqual(connections[0]);
});
it('should handle connections with same id (overwrite)', () => {
const connections = [
createTestConnection('duplicate', { source: 'source1' }),
createTestConnection('duplicate', { source: 'source2' }),
];
const result = mapConnections(connections);
expect(result.set.size).toBe(1);
expect(result.map.size).toBe(1);
expect(result.set.has('duplicate')).toBe(true);
expect(result.map.get('duplicate')?.source).toBe('source2');
});
it('should maintain connection properties', () => {
const connection = createTestConnection('test', {
source: 'node1',
target: 'node2',
sourceHandle: 'output',
targetHandle: 'input',
});
const result = mapConnections([connection]);
const mappedConnection = result.map.get('test');
expect(mappedConnection?.source).toBe('node1');
expect(mappedConnection?.target).toBe('node2');
expect(mappedConnection?.sourceHandle).toBe('output');
expect(mappedConnection?.targetHandle).toBe('input');
});
});
describe('useWorkflowDiff composable', () => {
const mockUseCanvasMapping = vi.mocked(useCanvasMapping);
const createMockCanvasMappingReturn = (
nodes: Array<Partial<CanvasNode>> = [],
connections: Array<Partial<CanvasConnection>> = [],
) => ({
additionalNodePropertiesById: computed(() => ({}) as Record<string, Partial<CanvasNode>>),
nodeExecutionRunDataOutputMapById: computed(() => ({}) as Record<string, ExecutionOutputMap>),
nodeExecutionWaitingForNextById: computed(() => ({}) as Record<string, boolean>),
nodeIssuesById: computed(() => ({}) as Record<string, string[]>),
nodeHasIssuesById: computed(() => ({}) as Record<string, boolean>),
nodes: computed(() => nodes as CanvasNode[]),
connections: computed(() => connections as CanvasConnection[]),
});
beforeEach(() => {
vi.clearAllMocks();
mockUseCanvasMapping.mockReturnValue(createMockCanvasMappingReturn());
});
const createMockWorkflow = (
id: string,
nodes: INodeUi[] = [],
connections: IConnections = {},
): IWorkflowDb => ({
id,
name: `Workflow ${id}`,
nodes,
connections,
active: false,
createdAt: '2023-01-01T00:00:00.000Z',
updatedAt: '2023-01-01T00:00:00.000Z',
tags: [],
pinData: {},
settings: {
executionOrder: 'v1',
},
versionId: 'version-1',
isArchived: false,
});
const createMockNode = (id: string, overrides: Partial<INodeUi> = {}): INodeUi => ({
id,
name: `Node ${id}`,
type: 'test-node',
typeVersion: 1,
position: [100, 100],
parameters: {},
...overrides,
});
const createMockCanvasConnection = (
id: string,
overrides: Partial<CanvasConnection> = {},
): CanvasConnection => ({
id,
source: `node-${id}-source`,
target: `node-${id}-target`,
sourceHandle: 'main',
targetHandle: 'main',
...overrides,
});
it('should initialize with default values when no workflows provided', () => {
const { source, target, nodesDiff, connectionsDiff } = useWorkflowDiff(undefined, undefined);
expect(source.value.workflow).toBeUndefined();
expect(source.value.nodes).toEqual([]);
expect(source.value.connections).toEqual([]);
expect(target.value.workflow).toBeUndefined();
expect(target.value.nodes).toEqual([]);
expect(target.value.connections).toEqual([]);
expect(nodesDiff.value.size).toBe(0);
expect(connectionsDiff.value.size).toBe(0);
});
it('should handle source workflow only', () => {
const sourceWorkflow = createMockWorkflow('source', [createMockNode('node1')]);
mockUseCanvasMapping.mockReturnValue(createMockCanvasMappingReturn([{ id: 'canvas-node1' }]));
const { source, target } = useWorkflowDiff(sourceWorkflow, undefined);
expect(source.value.workflow?.value).toEqual(sourceWorkflow);
expect(source.value.nodes).toHaveLength(1);
expect(source.value.nodes[0]).toEqual(
expect.objectContaining({
id: 'canvas-node1',
draggable: false,
selectable: false,
focusable: false,
}),
);
expect(target.value.workflow).toBeUndefined();
});
it('should handle target workflow only', () => {
const targetWorkflow = createMockWorkflow('target', [createMockNode('node1')]);
mockUseCanvasMapping.mockReturnValue(createMockCanvasMappingReturn([{ id: 'canvas-node1' }]));
const { source, target } = useWorkflowDiff(undefined, targetWorkflow);
expect(source.value.workflow).toBeUndefined();
expect(target.value.workflow?.value).toEqual(targetWorkflow);
expect(target.value.nodes).toHaveLength(1);
expect(target.value.nodes[0]).toEqual(
expect.objectContaining({
id: 'canvas-node1',
draggable: false,
selectable: false,
focusable: false,
}),
);
});
it('should set canvas nodes as non-interactive', () => {
const sourceWorkflow = createMockWorkflow('source');
const mockCanvasNode = {
id: 'node1',
draggable: true,
selectable: true,
focusable: true,
};
mockUseCanvasMapping.mockReturnValue(createMockCanvasMappingReturn([mockCanvasNode]));
const { source } = useWorkflowDiff(sourceWorkflow, undefined);
expect(source.value.nodes[0]).toEqual(
expect.objectContaining({
draggable: false,
selectable: false,
focusable: false,
}),
);
});
it('should set canvas connections as non-interactive', () => {
const sourceWorkflow = createMockWorkflow('source');
const mockCanvasConnection = {
id: 'conn1',
selectable: true,
focusable: true,
source: 'node1',
target: 'node2',
};
mockUseCanvasMapping.mockReturnValue(
createMockCanvasMappingReturn([], [mockCanvasConnection]),
);
const { source } = useWorkflowDiff(sourceWorkflow, undefined);
expect(source.value.connections[0]).toEqual(
expect.objectContaining({
selectable: false,
focusable: false,
}),
);
});
it('should compute nodesDiff correctly', () => {
const sourceNode = createMockNode('node1', { name: 'Source Node' });
const targetNode = createMockNode('node1', { name: 'Target Node' });
const sourceWorkflow = createMockWorkflow('source', [sourceNode]);
const targetWorkflow = createMockWorkflow('target', [targetNode]);
const { nodesDiff } = useWorkflowDiff(sourceWorkflow, targetWorkflow);
expect(nodesDiff.value.size).toBe(1);
expect(nodesDiff.value.get('node1')?.status).toBe(NodeDiffStatus.Modified);
});
it('should handle workflows with different nodes', () => {
const sourceNodes = [createMockNode('node1'), createMockNode('node2')];
const targetNodes = [createMockNode('node1'), createMockNode('node3')];
const sourceWorkflow = createMockWorkflow('source', sourceNodes);
const targetWorkflow = createMockWorkflow('target', targetNodes);
const { nodesDiff } = useWorkflowDiff(sourceWorkflow, targetWorkflow);
expect(nodesDiff.value.size).toBe(3);
expect(nodesDiff.value.get('node1')?.status).toBe(NodeDiffStatus.Eq);
expect(nodesDiff.value.get('node2')?.status).toBe(NodeDiffStatus.Deleted);
expect(nodesDiff.value.get('node3')?.status).toBe(NodeDiffStatus.Added);
});
it('should compute connectionsDiff correctly', () => {
const sourceConnections = [createMockCanvasConnection('conn1')];
const targetConnections = [
createMockCanvasConnection('conn1'),
createMockCanvasConnection('conn2'),
];
const sourceWorkflow = createMockWorkflow('source');
const targetWorkflow = createMockWorkflow('target');
mockUseCanvasMapping
.mockReturnValueOnce(createMockCanvasMappingReturn([], sourceConnections))
.mockReturnValueOnce(createMockCanvasMappingReturn([], targetConnections));
const { connectionsDiff } = useWorkflowDiff(sourceWorkflow, targetWorkflow);
expect(connectionsDiff.value.size).toBe(1);
expect(connectionsDiff.value.get('conn2')?.status).toBe(NodeDiffStatus.Added);
});
it('should handle reactive workflow updates', () => {
const sourceWorkflowRef = ref<IWorkflowDb | undefined>(undefined);
const { source } = useWorkflowDiff(sourceWorkflowRef, undefined);
expect(source.value.workflow).toBeUndefined();
const newWorkflow = createMockWorkflow('new-source');
sourceWorkflowRef.value = newWorkflow;
expect(source.value.workflow?.value).toEqual(newWorkflow);
});
it('should include node type information in connectionsDiff', () => {
const sourceNode = createMockNode('node1', { type: 'http-request', typeVersion: 2 });
const targetNode = createMockNode('node2', { type: 'webhook', typeVersion: 1 });
const sourceWorkflow = createMockWorkflow('source', [sourceNode, targetNode]);
const targetWorkflow = createMockWorkflow('target', [sourceNode, targetNode]);
const connection = createMockCanvasConnection('conn1', {
source: 'node1',
target: 'node2',
});
mockUseCanvasMapping
.mockReturnValueOnce(createMockCanvasMappingReturn())
.mockReturnValueOnce(createMockCanvasMappingReturn([], [connection]));
const { connectionsDiff } = useWorkflowDiff(sourceWorkflow, targetWorkflow);
// Just verify that the connection diff was computed
const connectionDiff = connectionsDiff.value.get('conn1');
expect(connectionDiff?.status).toBe(NodeDiffStatus.Added);
expect(connectionDiff?.connection).toBeDefined();
expect(connectionDiff?.connection.id).toBe('conn1');
});
});
});

View File

@@ -0,0 +1,242 @@
import _pick from 'lodash-es/pick';
import _isEqual from 'lodash-es/isEqual';
import type { CanvasConnection } from '@/types';
import type { INodeUi, IWorkflowDb } from '@/Interface';
import type { MaybeRefOrGetter, Ref, ComputedRef } from 'vue';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { toValue, computed, ref, watchEffect, shallowRef } from 'vue';
import { useCanvasMapping } from '@/composables/useCanvasMapping';
import type { Workflow, IConnections, INodeTypeDescription } from 'n8n-workflow';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
export const enum NodeDiffStatus {
Eq = 'equal',
Modified = 'modified',
Added = 'added',
Deleted = 'deleted',
}
export type NodeDiff<T> = {
status: NodeDiffStatus;
node: T;
};
export type WorkflowDiff<T> = Map<string, NodeDiff<T>>;
export function compareNodes<T extends { id: string }>(
base: T | undefined,
target: T | undefined,
): boolean {
const propsToCompare = ['name', 'type', 'typeVersion', 'webhookId', 'credentials', 'parameters'];
const baseNode = _pick(base, propsToCompare);
const targetNode = _pick(target, propsToCompare);
return _isEqual(baseNode, targetNode);
}
export function compareWorkflowsNodes<T extends { id: string }>(
base: T[],
target: T[],
nodesEqual: (base: T | undefined, target: T | undefined) => boolean = compareNodes,
): WorkflowDiff<T> {
const baseNodes = base.reduce<Map<string, T>>((acc, node) => {
acc.set(node.id, node);
return acc;
}, new Map());
const targetNodes = target.reduce<Map<string, T>>((acc, node) => {
acc.set(node.id, node);
return acc;
}, new Map());
const diff: WorkflowDiff<T> = new Map();
baseNodes.entries().forEach(([id, node]) => {
if (!targetNodes.has(id)) {
diff.set(id, { status: NodeDiffStatus.Deleted, node });
} else if (!nodesEqual(baseNodes.get(id), targetNodes.get(id))) {
diff.set(id, { status: NodeDiffStatus.Modified, node });
} else {
diff.set(id, { status: NodeDiffStatus.Eq, node });
}
});
targetNodes.entries().forEach(([id, node]) => {
if (!baseNodes.has(id)) {
diff.set(id, { status: NodeDiffStatus.Added, node });
}
});
return diff;
}
export function mapConnections(connections: CanvasConnection[]) {
return connections.reduce(
(acc, connection) => {
acc.set.add(connection.id);
acc.map.set(connection.id, connection);
return acc;
},
{ set: new Set<string>(), map: new Map<string, CanvasConnection>() },
);
}
function createWorkflowRefs(
workflow: MaybeRefOrGetter<IWorkflowDb | undefined>,
getWorkflow: (nodes: INodeUi[], connections: IConnections) => Workflow,
) {
const workflowRef = computed(() => toValue(workflow));
const workflowNodes = ref<INodeUi[]>([]);
const workflowConnections = ref<IConnections>({});
const workflowObjectRef = shallowRef<Workflow>(getWorkflow([], {}));
watchEffect(() => {
const workflowValue = workflowRef.value;
if (workflowValue) {
workflowObjectRef.value = getWorkflow(workflowValue.nodes, workflowValue.connections);
workflowNodes.value = workflowValue.nodes;
workflowConnections.value = workflowValue.connections;
}
});
return {
workflowRef,
workflowNodes,
workflowConnections,
workflowObjectRef,
};
}
function createWorkflowDiff(
workflowRef: ComputedRef<IWorkflowDb | undefined>,
workflowNodes: Ref<INodeUi[]>,
workflowConnections: Ref<IConnections>,
workflowObjectRef: Ref<Workflow>,
) {
return computed(() => {
if (!workflowRef.value) {
return {
workflow: undefined,
nodes: [],
connections: [],
};
}
const { nodes, connections } = useCanvasMapping({
nodes: workflowNodes,
connections: workflowConnections,
workflowObject: workflowObjectRef,
});
return {
workflow: workflowRef,
nodes: nodes.value.map((node) => {
node.draggable = false;
node.selectable = false;
node.focusable = false;
return node;
}),
connections: connections.value.map((connection) => {
connection.selectable = false;
connection.focusable = false;
return connection;
}),
};
});
}
export const useWorkflowDiff = (
sourceWorkflow: MaybeRefOrGetter<IWorkflowDb | undefined>,
targetWorkflow: MaybeRefOrGetter<IWorkflowDb | undefined>,
) => {
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const sourceRefs = createWorkflowRefs(sourceWorkflow, workflowsStore.getWorkflow);
const targetRefs = createWorkflowRefs(targetWorkflow, workflowsStore.getWorkflow);
const source = createWorkflowDiff(
sourceRefs.workflowRef,
sourceRefs.workflowNodes,
sourceRefs.workflowConnections,
sourceRefs.workflowObjectRef,
);
const target = createWorkflowDiff(
targetRefs.workflowRef,
targetRefs.workflowNodes,
targetRefs.workflowConnections,
targetRefs.workflowObjectRef,
);
const nodesDiff = computed(() =>
compareWorkflowsNodes(
source.value.workflow?.value?.nodes ?? [],
target.value.workflow?.value?.nodes ?? [],
),
);
type Connection = {
id: string;
source?: INodeUi;
target?: INodeUi;
sourceType: INodeTypeDescription | null;
targetType: INodeTypeDescription | null;
};
function formatConnectionDiff(
id: string,
status: NodeDiffStatus,
collection: Map<string, CanvasConnection>,
accumulator: Map<string, { status: NodeDiffStatus; connection: Connection }>,
) {
const connection = collection.get(id);
if (!connection) return;
const sourceNode = nodesDiff.value.get(connection.source)?.node;
const targetNode = nodesDiff.value.get(connection.target)?.node;
accumulator.set(id, {
status,
connection: {
id,
source: sourceNode,
target: targetNode,
sourceType: sourceNode
? nodeTypesStore.getNodeType(sourceNode.type, sourceNode.typeVersion)
: null,
targetType: targetNode
? nodeTypesStore.getNodeType(targetNode.type, targetNode.typeVersion)
: null,
},
});
}
const connectionsDiff = computed(() => {
const sourceConnections = mapConnections(source.value?.connections ?? []);
const targetConnections = mapConnections(target.value?.connections ?? []);
const added = targetConnections.set.difference(sourceConnections.set);
const removed = sourceConnections.set.difference(targetConnections.set);
const acc = new Map<string, { status: NodeDiffStatus; connection: Connection }>();
added
.values()
.forEach((id) => formatConnectionDiff(id, NodeDiffStatus.Added, targetConnections.map, acc));
removed
.values()
.forEach((id) =>
formatConnectionDiff(id, NodeDiffStatus.Deleted, sourceConnections.map, acc),
);
return acc;
});
return {
source,
target,
nodesDiff,
connectionsDiff,
};
};

View File

@@ -102,6 +102,10 @@ export const useSourceControlStore = defineStore('sourceControl', () => {
return await vcApi.getAggregatedStatus(rootStore.restApiContext);
};
const getRemoteWorkflow = async (workflowId: string) => {
return await vcApi.getRemoteWorkflow(rootStore.restApiContext, workflowId);
};
return {
isEnterpriseSourceControlEnabled,
state,
@@ -117,6 +121,7 @@ export const useSourceControlStore = defineStore('sourceControl', () => {
disconnect,
getStatus,
getAggregatedStatus,
getRemoteWorkflow,
sshKeyTypesWithLabel,
};
});

View File

@@ -43,6 +43,7 @@ import {
WORKFLOW_EXTRACTION_NAME_MODAL_KEY,
LOCAL_STORAGE_THEME,
WHATS_NEW_MODAL_KEY,
WORKFLOW_DIFF_MODAL_KEY,
} from '@/constants';
import { STORES } from '@n8n/stores';
import type {
@@ -123,6 +124,7 @@ export const useUIStore = defineStore(STORES.UI, () => {
NEW_ASSISTANT_SESSION_MODAL,
IMPORT_WORKFLOW_URL_MODAL_KEY,
WHATS_NEW_MODAL_KEY,
WORKFLOW_DIFF_MODAL_KEY,
].map((modalKey) => [modalKey, { open: false }]),
),
[DELETE_USER_MODAL_KEY]: {

21
patches/v-code-diff.patch Normal file
View File

@@ -0,0 +1,21 @@
diff --git a/package.json b/package.json
index 7dc91b25fae9cd1e81afa279629b6e2ffa80fa77..8cd22b5a1fa243e1048211a297c645806723df04 100644
--- a/package.json
+++ b/package.json
@@ -7,12 +7,12 @@
"exports": {
".": {
"types": "./types/index.d.ts",
- "import": "./dist/index.es.js",
- "require": "./dist/index.cjs.js"
+ "import": "./dist/v3/index.es.js",
+ "require": "./dist/v3/index.cjs.js"
}
},
- "main": "dist/index.cjs",
- "module": "dist/index.es.js",
+ "main": "dist/v3/index.cjs",
+ "module": "dist/v3/index.es.js",
"types": "./types/index.d.ts",
"files": [
"dist",

44
pnpm-lock.yaml generated
View File

@@ -234,6 +234,9 @@ patchedDependencies:
pkce-challenge@5.0.0:
hash: 651e785d0b7bbf5be9210e1e895c39a16dc3ce8a5a3843b4819565fb6e175b90
path: patches/pkce-challenge@5.0.0.patch
v-code-diff:
hash: 21588de80e591bbc1e5a068d9bce311db5254686443652945d2c7887fdafe9d9
path: patches/v-code-diff.patch
vue-tsc@2.2.8:
hash: e2aee939ccac8a57fe449bfd92bedd8117841579526217bc39aca26c6b8c317f
path: patches/vue-tsc@2.2.8.patch
@@ -2466,6 +2469,9 @@ importers:
uuid:
specifier: 'catalog:'
version: 10.0.0
v-code-diff:
specifier: ^1.13.1
version: 1.13.1(patch_hash=21588de80e591bbc1e5a068d9bce311db5254686443652945d2c7887fdafe9d9)(vue@3.5.13(typescript@5.8.3))
v3-infinite-loading:
specifier: ^1.2.2
version: 1.2.2
@@ -9492,6 +9498,9 @@ packages:
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
diff-match-patch@1.0.5:
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
diff-sequences@29.6.3:
resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -9500,6 +9509,10 @@ packages:
resolution: {integrity: sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==}
engines: {node: '>=0.3.1'}
diff@5.2.0:
resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==}
engines: {node: '>=0.3.1'}
diff@7.0.0:
resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==}
engines: {node: '>=0.3.1'}
@@ -10767,6 +10780,10 @@ packages:
help-me@5.0.0:
resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==}
highlight.js@11.11.1:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
highlight.js@11.9.0:
resolution: {integrity: sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==}
engines: {node: '>=12.0.0'}
@@ -15529,6 +15546,15 @@ packages:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
v-code-diff@1.13.1:
resolution: {integrity: sha512-9LTV1dZhC1oYTntyB94vfumGgsfIX5u0fEDSI2Txx4vCE5sI5LkgeLJRRy2SsTVZmDcV+R73sBr0GpPn0TJxMw==}
peerDependencies:
'@vue/composition-api': ^1.4.9
vue: ^2.6.0 || >=3.0.0
peerDependenciesMeta:
'@vue/composition-api':
optional: true
v3-infinite-loading@1.2.2:
resolution: {integrity: sha512-MWJc6yChnqeUasBFJ3Enu8IGPcQgRMSTrAEtT1MsHBEx+QjwvNTaY8o+8V9DgVt1MVhQSl3MC55hsaWLJmpRMw==}
@@ -24470,11 +24496,15 @@ snapshots:
didyoumean@1.2.2: {}
diff-match-patch@1.0.5: {}
diff-sequences@29.6.3: {}
diff@4.0.2:
optional: true
diff@5.2.0: {}
diff@7.0.0: {}
diffie-hellman@5.0.3:
@@ -26173,6 +26203,8 @@ snapshots:
help-me@5.0.0: {}
highlight.js@11.11.1: {}
highlight.js@11.9.0: {}
hmac-drbg@1.0.1:
@@ -26336,7 +26368,7 @@ snapshots:
isstream: 0.1.2
jsonwebtoken: 9.0.2
mime-types: 2.1.35
retry-axios: 2.6.0(axios@1.10.0(debug@4.4.1))
retry-axios: 2.6.0(axios@1.10.0)
tough-cookie: 4.1.4
transitivePeerDependencies:
- supports-color
@@ -30264,7 +30296,7 @@ snapshots:
onetime: 5.1.2
signal-exit: 3.0.7
retry-axios@2.6.0(axios@1.10.0(debug@4.4.1)):
retry-axios@2.6.0(axios@1.10.0):
dependencies:
axios: 1.10.0
@@ -32043,6 +32075,14 @@ snapshots:
uuid@9.0.1: {}
v-code-diff@1.13.1(patch_hash=21588de80e591bbc1e5a068d9bce311db5254686443652945d2c7887fdafe9d9)(vue@3.5.13(typescript@5.8.3)):
dependencies:
diff: 5.2.0
diff-match-patch: 1.0.5
highlight.js: 11.11.1
vue: 3.5.13(typescript@5.8.3)
vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.3))
v3-infinite-loading@1.2.2: {}
v8-compile-cache-lib@3.0.1: