feat: Version control mvp (#6271)
* implement basic git service * cleanup connected prop * add skeleton of git functions * initial import/export setup * split out export service * refactor and improve export * begin import * more commands and basic import * clean up imports with transactions * work folder import functions * reintroduce versionid * add missing import to pull workfolder * add get-status endpoint * add cleanup to disconnect * add initRepo options * add more checks and cleanup * minor cleanup * refactor prefs * fix server.ts * fix sending deleted files * rename files to ee * add variable override and fix critical cred import bug * fix mkdir race condition * make initRepo default to true * fix front back integration * improve connect flow * add comment to generated ssh key * fix(editor): use useToast composable * fix buttons size * commenting out repo init for now * fix(editor): update UI logic * fix(editor): remove console.log * fix(editor): remove unused ref * adjust endpoints for improved UI * fix(editor): add push and pull buttons * keep or not ssh key * switching file name to id * fix(editor): add success messages, fix save button * fixed faulty diff preventing pull * fix build * fix(editor): adding loader to VC components * removing duplicate exports * improve conflict finding on push pull * manage pull conflict * alternate push pull * fix pull confirmation * fix rm and credential export/import * switch to alternative pull implementation * fix initial commit * fix(editor): subscribing to VC store action to refresh lists * fix(editor): wrap VC store actions with try * feat: add fine-grained file selection for push action * fix: close modal after successful push * fix(editor): VC preferences validation * fix confirm * fix: update endpoint to /get-status * feat: update pull modal override changes message * fix missing wf error * undo * removing connect endpoint * fix(editor): add button titles * fix(editor): cleaning up store action * add version-control/set-read-only protection * fix(editor): adding set branch readonly * fix(editor): remove Push button if branch set to readonly * fix(editor): fix some styles * fix(editor): remove duplicate and delete actions in WF list when branch is readonly * fix: load status before opening selective push modal * fix(editor): extend readonly logic * add cleanup after failed initRepo * fix deleted files crashing get-status * fix n8n-checkbox in staging dialog * fix(editor): fix loading * fix(editor): resize buttons * fix(editor): fix translation * fix(editor): fix copy text size * fix(editor): fix copy text size * fix(editor): add disconnection confirmation * fix(editor): add disconnection confirmation * fix(editor): set large buttons * add public api Pull endpoint * feat: add refresh ssh key * return prefs when new keys are generated * fix(editor): adding readOnly mode to main header * fix(editor): adding readOnly mode to workflow settings * improve credential owner import * add middleware to endpoints * improve public api error/doc * do not create branch if one already exists * update wordings for connect toasts * fix(editor): updating and separating readonly modes * fix(editor): fix readonly mode in WF list * fix(editor): disable elements dragging on canvas in readonly mode (WIP: not working when NodeView page is loaded first) * fix(editor): fix canvas draggables in readonly env * fix(editor): remove unused variables * fix(editor): hide actions in node connections when readonly * fix(editor): hide actions in node connections when readonly * fix(editor): disable Save button when readonly * fix(editor): disable Save settings if no branch is selected * fix(editor): lint fix * fix(editor): update snapshots * fix(editor): replace Loading... text * fix(editor): reset Loading... text * reset branchname on disconnect * fix(editor): adding some translations * fix(editor): fix unit test * fix(editor): fix loading * fix(editor): set settings saved message * fix(editor): update connection flag * fix branchName not returning after connect * temporary (but still breaking) fix for postgres * fix(editor): adding tooltip to Push/Pull buttons when they're collapsed * fix(editor): enabled activator in readonly mode * fix test * fix(editor): disabling new item addition for workflows in readonly mode * fix(editor): modify Pull/Push button tooltips * do not commit empty variables file --------- Co-authored-by: Michael Auerswald <michael.auerswald@gmail.com> Co-authored-by: Romain Minaud <romain.minaud@gmail.com> Co-authored-by: Alex Grozav <alex@grozav.com>
This commit is contained in:
@@ -108,7 +108,7 @@ export default defineComponent({
|
||||
|
||||
.medium {
|
||||
span {
|
||||
font-size: var(--font-size-2xs);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<span @keydown.stop class="inline-edit">
|
||||
<span v-if="isEditEnabled">
|
||||
<span v-if="isEditEnabled && !disabled">
|
||||
<ExpandableInputEdit
|
||||
:placeholder="placeholder"
|
||||
:value="newValue"
|
||||
@@ -29,12 +29,36 @@ import { createEventBus } from 'n8n-design-system';
|
||||
export default defineComponent({
|
||||
name: 'InlineTextEdit',
|
||||
components: { ExpandableInputEdit, ExpandableInputPreview },
|
||||
props: ['isEditEnabled', 'value', 'placeholder', 'maxLength', 'previewValue'],
|
||||
props: {
|
||||
isEditEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
maxLength: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
previewValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
newValue: '',
|
||||
escPressed: false,
|
||||
disabled: false,
|
||||
inputBus: createEventBus(),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div>
|
||||
<div :class="{ 'main-header': true, expanded: !this.uiStore.sidebarMenuCollapsed }">
|
||||
<div v-show="!hideMenuBar" class="top-menu">
|
||||
<WorkflowDetails />
|
||||
<WorkflowDetails :readOnly="readOnly" />
|
||||
<tab-bar
|
||||
v-if="onWorkflowPage"
|
||||
:items="tabBarItems"
|
||||
@@ -18,6 +18,7 @@
|
||||
import { defineComponent } from 'vue';
|
||||
import type { Route } from 'vue-router';
|
||||
import { mapStores } from 'pinia';
|
||||
import type { IExecutionsSummary } from 'n8n-workflow';
|
||||
import { pushConnection } from '@/mixins/pushConnection';
|
||||
import WorkflowDetails from '@/components/MainHeader/WorkflowDetails.vue';
|
||||
import TabBar from '@/components/MainHeader/TabBar.vue';
|
||||
@@ -27,10 +28,9 @@ import {
|
||||
STICKY_NODE_TYPE,
|
||||
VIEWS,
|
||||
} from '@/constants';
|
||||
import type { IExecutionsSummary, INodeUi, ITabBarItem } from '@/Interface';
|
||||
import type { INodeUi, ITabBarItem } from '@/Interface';
|
||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useNDVStore } from '@/stores/ndv.store';
|
||||
import { useUIStore, useNDVStore, useVersionControlStore } from '@/stores';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MainHeader',
|
||||
@@ -53,7 +53,7 @@ export default defineComponent({
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapStores(useNDVStore, useUIStore),
|
||||
...mapStores(useNDVStore, useUIStore, useVersionControlStore),
|
||||
tabBarItems(): ITabBarItem[] {
|
||||
return [
|
||||
{ value: MAIN_HEADER_TABS.WORKFLOW, label: this.$locale.baseText('generic.editor') },
|
||||
@@ -81,6 +81,9 @@ export default defineComponent({
|
||||
activeExecution(): IExecutionsSummary {
|
||||
return this.workflowsStore.activeWorkflowExecution as IExecutionsSummary;
|
||||
},
|
||||
readOnly(): boolean {
|
||||
return this.versionControlStore.preferences.branchReadOnly;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.dirtyState = this.uiStore.stateIsDirty;
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
:previewValue="shortenedName"
|
||||
:isEditEnabled="isNameEditEnabled"
|
||||
:maxLength="MAX_WORKFLOW_NAME_LENGTH"
|
||||
:disabled="readOnly"
|
||||
@toggle="onNameToggle"
|
||||
@submit="onNameSubmit"
|
||||
placeholder="Enter workflow name"
|
||||
@@ -25,7 +26,7 @@
|
||||
</BreakpointsObserver>
|
||||
|
||||
<span v-if="settingsStore.areTagsEnabled" class="tags" data-test-id="workflow-tags-container">
|
||||
<div v-if="isTagsEditEnabled">
|
||||
<div v-if="isTagsEditEnabled && !readOnly">
|
||||
<TagsDropdown
|
||||
:createEnabled="true"
|
||||
:currentTagIds="appliedTagIds"
|
||||
@@ -39,7 +40,7 @@
|
||||
data-test-id="workflow-tags-dropdown"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="currentWorkflowTagIds.length === 0">
|
||||
<div v-else-if="currentWorkflowTagIds.length === 0 && !readOnly">
|
||||
<span class="add-tag clickable" data-test-id="new-tag-link" @click="onTagsEditEnable">
|
||||
+ {{ $locale.baseText('workflowDetails.addTag') }}
|
||||
</span>
|
||||
@@ -99,7 +100,7 @@
|
||||
<SaveButton
|
||||
type="primary"
|
||||
:saved="!this.isDirty && !this.isNewWorkflow"
|
||||
:disabled="isWorkflowSaving"
|
||||
:disabled="isWorkflowSaving || readOnly"
|
||||
data-test-id="workflow-save-button"
|
||||
@click="onSaveButtonClick"
|
||||
/>
|
||||
@@ -152,15 +153,17 @@ import type { IUser, IWorkflowDataUpdate, IWorkflowDb, IWorkflowToShare } from '
|
||||
import { saveAs } from 'file-saver';
|
||||
import { useTitleChange, useToast, useMessage } from '@/composables';
|
||||
import type { MessageBoxInputData } from 'element-ui/types/message-box';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { useTagsStore } from '@/stores/tags.store';
|
||||
import {
|
||||
useUIStore,
|
||||
useSettingsStore,
|
||||
useWorkflowsStore,
|
||||
useRootStore,
|
||||
useTagsStore,
|
||||
useUsersStore,
|
||||
useUsageStore,
|
||||
} from '@/stores';
|
||||
import type { IPermissions } from '@/permissions';
|
||||
import { getWorkflowPermissions } from '@/permissions';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import { useUsageStore } from '@/stores/usage.store';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
import { useCloudPlanStore } from '@/stores';
|
||||
|
||||
@@ -186,6 +189,12 @@ export default defineComponent({
|
||||
InlineTextEdit,
|
||||
BreakpointsObserver,
|
||||
},
|
||||
props: {
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return {
|
||||
...useTitleChange(),
|
||||
@@ -266,44 +275,52 @@ export default defineComponent({
|
||||
return getWorkflowPermissions(this.usersStore.currentUser, this.workflow);
|
||||
},
|
||||
workflowMenuItems(): Array<{}> {
|
||||
return [
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
|
||||
label: this.$locale.baseText('menuActions.duplicate'),
|
||||
disabled: !this.onWorkflowPage || !this.currentWorkflowId,
|
||||
},
|
||||
const actions = [
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.DOWNLOAD,
|
||||
label: this.$locale.baseText('menuActions.download'),
|
||||
disabled: !this.onWorkflowPage,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL,
|
||||
label: this.$locale.baseText('menuActions.importFromUrl'),
|
||||
disabled: !this.onWorkflowPage || this.onExecutionsTab,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE,
|
||||
label: this.$locale.baseText('menuActions.importFromFile'),
|
||||
disabled: !this.onWorkflowPage || this.onExecutionsTab,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.SETTINGS,
|
||||
label: this.$locale.baseText('generic.settings'),
|
||||
disabled: !this.onWorkflowPage || this.isNewWorkflow,
|
||||
},
|
||||
...(this.workflowPermissions.delete
|
||||
? [
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.DELETE,
|
||||
label: this.$locale.baseText('menuActions.delete'),
|
||||
disabled: !this.onWorkflowPage || this.isNewWorkflow,
|
||||
customClass: this.$style.deleteItem,
|
||||
divided: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
if (!this.readOnly) {
|
||||
actions.unshift({
|
||||
id: WORKFLOW_MENU_ACTIONS.DUPLICATE,
|
||||
label: this.$locale.baseText('menuActions.duplicate'),
|
||||
disabled: !this.onWorkflowPage || !this.currentWorkflowId,
|
||||
});
|
||||
|
||||
actions.push(
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_URL,
|
||||
label: this.$locale.baseText('menuActions.importFromUrl'),
|
||||
disabled: !this.onWorkflowPage || this.onExecutionsTab,
|
||||
},
|
||||
{
|
||||
id: WORKFLOW_MENU_ACTIONS.IMPORT_FROM_FILE,
|
||||
label: this.$locale.baseText('menuActions.importFromFile'),
|
||||
disabled: !this.onWorkflowPage || this.onExecutionsTab,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.SETTINGS,
|
||||
label: this.$locale.baseText('generic.settings'),
|
||||
disabled: !this.onWorkflowPage || this.isNewWorkflow,
|
||||
});
|
||||
|
||||
if (this.workflowPermissions.delete && !this.readOnly) {
|
||||
actions.push({
|
||||
id: WORKFLOW_MENU_ACTIONS.DELETE,
|
||||
label: this.$locale.baseText('menuActions.delete'),
|
||||
disabled: !this.onWorkflowPage || this.isNewWorkflow,
|
||||
customClass: this.$style.deleteItem,
|
||||
divided: true,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
v-if="!isCollapsed && userIsTrialing"
|
||||
/></template>
|
||||
<template #menuSuffix>
|
||||
<div v-if="hasVersionUpdates || versionControlStore.state.currentBranch">
|
||||
<div v-if="hasVersionUpdates || versionControlStore.preferences.connected">
|
||||
<div v-if="hasVersionUpdates" :class="$style.updates" @click="openUpdatesPanel">
|
||||
<div :class="$style.giftContainer">
|
||||
<GiftNotificationIcon />
|
||||
@@ -46,24 +46,10 @@
|
||||
}}
|
||||
</n8n-text>
|
||||
</div>
|
||||
<div :class="$style.sync" v-if="versionControlStore.state.currentBranch">
|
||||
<span>
|
||||
<n8n-icon icon="code-branch" class="mr-xs" />
|
||||
{{ currentBranch }}
|
||||
</span>
|
||||
<n8n-button
|
||||
:title="
|
||||
$locale.baseText('settings.versionControl.sync.prompt.title', {
|
||||
interpolate: { branch: currentBranch },
|
||||
})
|
||||
"
|
||||
icon="sync"
|
||||
type="tertiary"
|
||||
:size="isCollapsed ? 'mini' : 'small'"
|
||||
square
|
||||
@click="sync"
|
||||
/>
|
||||
</div>
|
||||
<MainSidebarVersionControl
|
||||
v-if="versionControlStore.preferences.connected"
|
||||
:is-collapsed="isCollapsed"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer v-if="showUserArea">
|
||||
@@ -117,7 +103,6 @@
|
||||
|
||||
<script lang="ts">
|
||||
import type { CloudPlanAndUsageData, IExecutionResponse, IMenuItem, IVersion } from '@/Interface';
|
||||
import type { MessageBoxInputData } from 'element-ui/types/message-box';
|
||||
import GiftNotificationIcon from './GiftNotificationIcon.vue';
|
||||
|
||||
import { genericHelpers } from '@/mixins/genericHelpers';
|
||||
@@ -138,14 +123,16 @@ import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import { useVersionsStore } from '@/stores/versions.store';
|
||||
import { isNavigationFailure } from 'vue-router';
|
||||
import { useVersionControlStore } from '@/stores/versionControl.store';
|
||||
import ExecutionsUsage from '@/components/ExecutionsUsage.vue';
|
||||
import { useCloudPlanStore } from '@/stores/cloudPlan.store';
|
||||
import ExecutionsUsage from '@/components/ExecutionsUsage.vue';
|
||||
import MainSidebarVersionControl from '@/components/MainSidebarVersionControl.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'MainSidebar',
|
||||
components: {
|
||||
GiftNotificationIcon,
|
||||
ExecutionsUsage,
|
||||
MainSidebarVersionControl,
|
||||
},
|
||||
mixins: [genericHelpers, workflowHelpers, workflowRun, userHelpers, debounceHelper],
|
||||
setup(props) {
|
||||
@@ -171,9 +158,6 @@ export default defineComponent({
|
||||
useVersionControlStore,
|
||||
useCloudPlanStore,
|
||||
),
|
||||
currentBranch(): string {
|
||||
return this.versionControlStore.state.currentBranch;
|
||||
},
|
||||
hasVersionUpdates(): boolean {
|
||||
return this.versionsStore.hasVersionUpdates;
|
||||
},
|
||||
@@ -500,29 +484,6 @@ export default defineComponent({
|
||||
});
|
||||
}
|
||||
},
|
||||
async sync() {
|
||||
const prompt = (await this.prompt(
|
||||
this.$locale.baseText('settings.versionControl.sync.prompt.description', {
|
||||
interpolate: { branch: this.versionControlStore.state.currentBranch },
|
||||
}),
|
||||
this.$locale.baseText('settings.versionControl.sync.prompt.title', {
|
||||
interpolate: { branch: this.versionControlStore.state.currentBranch },
|
||||
}),
|
||||
{
|
||||
confirmButtonText: 'Sync',
|
||||
cancelButtonText: 'Cancel',
|
||||
inputPlaceholder: this.$locale.baseText(
|
||||
'settings.versionControl.sync.prompt.placeholder',
|
||||
),
|
||||
inputPattern: /^.+$/,
|
||||
inputErrorMessage: this.$locale.baseText('settings.versionControl.sync.prompt.error'),
|
||||
},
|
||||
)) as MessageBoxInputData;
|
||||
|
||||
if (prompt.value) {
|
||||
await this.versionControlStore.sync({ commitMessage: prompt.value });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -638,27 +599,4 @@ export default defineComponent({
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sync {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-s) var(--spacing-s) var(--spacing-s) var(--spacing-l);
|
||||
margin: 0 calc(var(--spacing-l) * -1) calc(var(--spacing-m) * -1);
|
||||
background: var(--color-background-light);
|
||||
border-top: 1px solid var(--color-foreground-light);
|
||||
font-size: var(--font-size-2xs);
|
||||
|
||||
span {
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.sideMenuCollapsed & {
|
||||
justify-content: center;
|
||||
margin-left: calc(var(--spacing-xl) * -1);
|
||||
> span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
154
packages/editor-ui/src/components/MainSidebarVersionControl.vue
Normal file
154
packages/editor-ui/src/components/MainSidebarVersionControl.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script lang="ts" setup>
|
||||
import { useVersionControlStore } from '@/stores/versionControl.store';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n, useLoadingService, useMessage, useToast } from '@/composables';
|
||||
import { useUIStore } from '@/stores';
|
||||
import { VERSION_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
||||
import { createEventBus } from 'n8n-design-system/utils';
|
||||
|
||||
const props = defineProps<{
|
||||
isCollapsed: boolean;
|
||||
}>();
|
||||
|
||||
const loadingService = useLoadingService();
|
||||
const uiStore = useUIStore();
|
||||
const versionControlStore = useVersionControlStore();
|
||||
const message = useMessage();
|
||||
const toast = useToast();
|
||||
const { i18n } = useI18n();
|
||||
|
||||
const eventBus = createEventBus();
|
||||
const tooltipOpenDelay = ref(300);
|
||||
|
||||
const currentBranch = computed(() => {
|
||||
return versionControlStore.preferences.branchName;
|
||||
});
|
||||
|
||||
async function pushWorkfolder() {
|
||||
loadingService.startLoading();
|
||||
try {
|
||||
const status = await versionControlStore.getAggregatedStatus();
|
||||
|
||||
uiStore.openModalWithData({
|
||||
name: VERSION_CONTROL_PUSH_MODAL_KEY,
|
||||
data: { eventBus, status },
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('error'));
|
||||
} finally {
|
||||
loadingService.stopLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
|
||||
}
|
||||
}
|
||||
|
||||
async function pullWorkfolder() {
|
||||
loadingService.startLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('settings.versionControl.loading.pull'));
|
||||
try {
|
||||
await versionControlStore.pullWorkfolder(false);
|
||||
} catch (error) {
|
||||
const confirm = await message.confirm(
|
||||
i18n.baseText('settings.versionControl.modals.pull.description'),
|
||||
i18n.baseText('settings.versionControl.modals.pull.title'),
|
||||
{
|
||||
confirmButtonText: i18n.baseText('settings.versionControl.modals.pull.buttons.save'),
|
||||
cancelButtonText: i18n.baseText('settings.versionControl.modals.pull.buttons.cancel'),
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
if (confirm === 'confirm') {
|
||||
await versionControlStore.pullWorkfolder(true);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, 'Error');
|
||||
}
|
||||
} finally {
|
||||
loadingService.stopLoading();
|
||||
loadingService.setLoadingText(i18n.baseText('genericHelpers.loading'));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="{ [$style.sync]: true, [$style.collapsed]: isCollapsed }">
|
||||
<span>
|
||||
<n8n-icon icon="code-branch" />
|
||||
{{ currentBranch }}
|
||||
</span>
|
||||
<div :class="{ 'pt-xs': !isCollapsed }">
|
||||
<n8n-tooltip :disabled="!isCollapsed" :open-delay="tooltipOpenDelay" placement="right">
|
||||
<template #content>
|
||||
<div>
|
||||
{{ i18n.baseText('settings.versionControl.button.pull') }}
|
||||
</div>
|
||||
</template>
|
||||
<n8n-button
|
||||
:class="{
|
||||
'mr-2xs': !isCollapsed,
|
||||
'mb-2xs': isCollapsed && !versionControlStore.preferences.branchReadOnly,
|
||||
}"
|
||||
icon="arrow-down"
|
||||
type="tertiary"
|
||||
size="mini"
|
||||
:square="isCollapsed"
|
||||
@click="pullWorkfolder"
|
||||
>
|
||||
<span v-if="!isCollapsed">{{
|
||||
i18n.baseText('settings.versionControl.button.pull')
|
||||
}}</span>
|
||||
</n8n-button>
|
||||
</n8n-tooltip>
|
||||
<n8n-tooltip
|
||||
v-if="!versionControlStore.preferences.branchReadOnly"
|
||||
:disabled="!isCollapsed"
|
||||
:open-delay="tooltipOpenDelay"
|
||||
placement="right"
|
||||
>
|
||||
<template #content>
|
||||
<div>
|
||||
{{ i18n.baseText('settings.versionControl.button.push') }}
|
||||
</div>
|
||||
</template>
|
||||
<n8n-button
|
||||
:square="isCollapsed"
|
||||
icon="arrow-up"
|
||||
type="tertiary"
|
||||
size="mini"
|
||||
@click="pushWorkfolder"
|
||||
>
|
||||
<span v-if="!isCollapsed">{{
|
||||
i18n.baseText('settings.versionControl.button.push')
|
||||
}}</span>
|
||||
</n8n-button>
|
||||
</n8n-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.sync {
|
||||
padding: var(--spacing-s) var(--spacing-s) var(--spacing-s) var(--spacing-l);
|
||||
margin: 0 calc(var(--spacing-l) * -1) calc(var(--spacing-m) * -1);
|
||||
background: var(--color-background-light);
|
||||
border-top: 1px solid var(--color-foreground-light);
|
||||
font-size: var(--font-size-2xs);
|
||||
|
||||
span {
|
||||
color: var(--color-text-base);
|
||||
}
|
||||
|
||||
button {
|
||||
font-size: var(--font-size-3xs);
|
||||
}
|
||||
}
|
||||
|
||||
.collapsed {
|
||||
text-align: center;
|
||||
margin-left: calc(var(--spacing-xl) * -1);
|
||||
|
||||
> span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -115,6 +115,12 @@
|
||||
/>
|
||||
</template>
|
||||
</ModalRoot>
|
||||
|
||||
<ModalRoot :name="VERSION_CONTROL_PUSH_MODAL_KEY">
|
||||
<template #default="{ modalName, data }">
|
||||
<VersionControlPushModal :modalName="modalName" :data="data" />
|
||||
</template>
|
||||
</ModalRoot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -144,6 +150,7 @@ import {
|
||||
LOG_STREAM_MODAL_KEY,
|
||||
ASK_AI_MODAL_KEY,
|
||||
USER_ACTIVATION_SURVEY_MODAL,
|
||||
VERSION_CONTROL_PUSH_MODAL_KEY,
|
||||
} from '@/constants';
|
||||
|
||||
import AboutModal from './AboutModal.vue';
|
||||
@@ -170,6 +177,7 @@ import ImportCurlModal from './ImportCurlModal.vue';
|
||||
import WorkflowShareModal from './WorkflowShareModal.ee.vue';
|
||||
import WorkflowSuccessModal from './UserActivationSurveyModal.vue';
|
||||
import EventDestinationSettingsModal from '@/components/SettingsLogStreaming/EventDestinationSettingsModal.ee.vue';
|
||||
import VersionControlPushModal from '@/components/VersionControlPushModal.ee.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'Modals',
|
||||
@@ -198,6 +206,7 @@ export default defineComponent({
|
||||
ImportCurlModal,
|
||||
EventDestinationSettingsModal,
|
||||
WorkflowSuccessModal,
|
||||
VersionControlPushModal,
|
||||
},
|
||||
data: () => ({
|
||||
COMMUNITY_PACKAGE_CONFIRM_MODAL_KEY,
|
||||
@@ -223,6 +232,7 @@ export default defineComponent({
|
||||
IMPORT_CURL_MODAL_KEY,
|
||||
LOG_STREAM_MODAL_KEY,
|
||||
USER_ACTIVATION_SURVEY_MODAL,
|
||||
VERSION_CONTROL_PUSH_MODAL_KEY,
|
||||
}),
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -183,7 +183,7 @@ import { nodeHelpers } from '@/mixins/nodeHelpers';
|
||||
import { workflowHelpers } from '@/mixins/workflowHelpers';
|
||||
import { pinData } from '@/mixins/pinData';
|
||||
|
||||
import type { INodeTypeDescription, ITaskData } from 'n8n-workflow';
|
||||
import type { IExecutionsSummary, INodeTypeDescription, ITaskData } from 'n8n-workflow';
|
||||
import { NodeHelpers } from 'n8n-workflow';
|
||||
|
||||
import NodeIcon from '@/components/NodeIcon.vue';
|
||||
@@ -191,7 +191,7 @@ import TitledList from '@/components/TitledList.vue';
|
||||
|
||||
import { get } from 'lodash-es';
|
||||
import { getStyleTokenValue, getTriggerNodeServiceName } from '@/utils';
|
||||
import type { IExecutionsSummary, INodeUi, XYPosition } from '@/Interface';
|
||||
import type { INodeUi, XYPosition } from '@/Interface';
|
||||
import { debounceHelper } from '@/mixins/debounce';
|
||||
import { useUIStore } from '@/stores/ui.store';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
|
||||
254
packages/editor-ui/src/components/VersionControlPushModal.ee.vue
Normal file
254
packages/editor-ui/src/components/VersionControlPushModal.ee.vue
Normal file
@@ -0,0 +1,254 @@
|
||||
<script lang="ts" setup>
|
||||
import Modal from './Modal.vue';
|
||||
import { CREDENTIAL_EDIT_MODAL_KEY, VERSION_CONTROL_PUSH_MODAL_KEY } from '@/constants';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import type { EventBus } from 'n8n-design-system/utils';
|
||||
import type { VersionControlAggregatedFile } from '@/Interface';
|
||||
import { useI18n, useLoadingService, useToast } from '@/composables';
|
||||
import { useVersionControlStore } from '@/stores/versionControl.store';
|
||||
import { useUIStore } from '@/stores';
|
||||
import { useRoute } from 'vue-router/composables';
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object as PropType<{ eventBus: EventBus; status: VersionControlAggregatedFile[] }>,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const loadingService = useLoadingService();
|
||||
const uiStore = useUIStore();
|
||||
const toast = useToast();
|
||||
const { i18n } = useI18n();
|
||||
const versionControlStore = useVersionControlStore();
|
||||
const route = useRoute();
|
||||
|
||||
const staged = ref<Record<string, boolean>>({});
|
||||
const files = ref<VersionControlAggregatedFile[]>(props.data.status || []);
|
||||
|
||||
const commitMessage = ref('');
|
||||
const loading = ref(true);
|
||||
const context = ref<'workflow' | 'workflows' | 'credentials' | string>('');
|
||||
|
||||
const isSubmitDisabled = computed(() => {
|
||||
return !commitMessage.value || Object.values(staged.value).every((value) => !value);
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
context.value = getContext();
|
||||
try {
|
||||
staged.value = getStagedFilesByContext(files.value);
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('error'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
function getContext() {
|
||||
if (route.fullPath.startsWith('/workflows')) {
|
||||
return 'workflows';
|
||||
} else if (
|
||||
route.fullPath.startsWith('/credentials') ||
|
||||
uiStore.modals[CREDENTIAL_EDIT_MODAL_KEY].open
|
||||
) {
|
||||
return 'credentials';
|
||||
} else if (route.fullPath.startsWith('/workflow/')) {
|
||||
return 'workflow';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getStagedFilesByContext(files: VersionControlAggregatedFile[]): Record<string, boolean> {
|
||||
const stagedFiles: VersionControlAggregatedFile[] = [];
|
||||
if (context.value === 'workflows') {
|
||||
stagedFiles.push(...files.filter((file) => file.file.startsWith('workflows')));
|
||||
} else if (context.value === 'credentials') {
|
||||
stagedFiles.push(...files.filter((file) => file.file.startsWith('credentials')));
|
||||
} else if (context.value === 'workflow') {
|
||||
const workflowId = route.params.name as string;
|
||||
stagedFiles.push(...files.filter((file) => file.type === 'workflow' && file.id === workflowId));
|
||||
}
|
||||
|
||||
return stagedFiles.reduce<Record<string, boolean>>((acc, file) => {
|
||||
acc[file.file] = true;
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function setStagedStatus(file: VersionControlAggregatedFile, status: boolean) {
|
||||
staged.value = {
|
||||
...staged.value,
|
||||
[file.file]: status,
|
||||
};
|
||||
}
|
||||
|
||||
function close() {
|
||||
uiStore.closeModal(VERSION_CONTROL_PUSH_MODAL_KEY);
|
||||
}
|
||||
|
||||
async function commitAndPush() {
|
||||
const fileNames = files.value.filter((file) => staged.value[file.file]).map((file) => file.file);
|
||||
|
||||
loadingService.startLoading(i18n.baseText('settings.versionControl.loading.push'));
|
||||
close();
|
||||
|
||||
try {
|
||||
await versionControlStore.pushWorkfolder({
|
||||
commitMessage: commitMessage.value,
|
||||
fileNames,
|
||||
});
|
||||
|
||||
toast.showToast({
|
||||
title: i18n.baseText('settings.versionControl.modals.push.success.title'),
|
||||
message: i18n.baseText('settings.versionControl.modals.push.success.description'),
|
||||
type: 'success',
|
||||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('error'));
|
||||
} finally {
|
||||
loadingService.stopLoading();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
width="812px"
|
||||
:title="i18n.baseText('settings.versionControl.modals.push.title')"
|
||||
:eventBus="data.eventBus"
|
||||
:name="VERSION_CONTROL_PUSH_MODAL_KEY"
|
||||
>
|
||||
<template #content>
|
||||
<div :class="$style.container">
|
||||
<n8n-text>
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.description') }}
|
||||
<span v-if="context">
|
||||
{{ i18n.baseText(`settings.versionControl.modals.push.description.${context}`) }}
|
||||
</span>
|
||||
<n8n-link
|
||||
:href="i18n.baseText('settings.versionControl.modals.push.description.learnMore.url')"
|
||||
>
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.description.learnMore') }}
|
||||
</n8n-link>
|
||||
</n8n-text>
|
||||
|
||||
<div v-if="files.length > 0">
|
||||
<n8n-text bold tag="p" class="mt-l mb-2xs">
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.filesToCommit') }}
|
||||
</n8n-text>
|
||||
<n8n-card
|
||||
v-for="file in files"
|
||||
:key="file.file"
|
||||
:class="$style.listItem"
|
||||
@click="setStagedStatus(file, !staged[file.file])"
|
||||
>
|
||||
<div :class="$style.listItemBody">
|
||||
<n8n-checkbox
|
||||
:value="staged[file.file]"
|
||||
:class="$style.listItemCheckbox"
|
||||
@input="setStagedStatus(file, !staged[file.file])"
|
||||
/>
|
||||
<n8n-text bold>
|
||||
<span v-if="file.status === 'deleted'">
|
||||
<span v-if="file.type === 'workflow'"> Workflow </span>
|
||||
<span v-if="file.type === 'credential'"> Credential </span>
|
||||
Id: {{ file.id }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ file.name }}
|
||||
</span>
|
||||
</n8n-text>
|
||||
<n8n-badge :class="$style.listItemStatus">
|
||||
{{ file.status }}
|
||||
</n8n-badge>
|
||||
</div>
|
||||
</n8n-card>
|
||||
|
||||
<n8n-text bold tag="p" class="mt-l mb-2xs">
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.commitMessage') }}
|
||||
</n8n-text>
|
||||
<n8n-input
|
||||
type="text"
|
||||
v-model="commitMessage"
|
||||
:placeholder="
|
||||
i18n.baseText('settings.versionControl.modals.push.commitMessage.placeholder')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="!loading">
|
||||
<n8n-callout class="mt-l">
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.everythingIsUpToDate') }}
|
||||
</n8n-callout>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<div :class="$style.footer">
|
||||
<n8n-button type="tertiary" class="mr-2xs" @click="close">
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.buttons.cancel') }}
|
||||
</n8n-button>
|
||||
<n8n-button type="primary" :disabled="isSubmitDisabled" @click="commitAndPush">
|
||||
{{ i18n.baseText('settings.versionControl.modals.push.buttons.save') }}
|
||||
</n8n-button>
|
||||
</div>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style module lang="scss">
|
||||
.container > * {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
margin-top: var(--spacing-2xs);
|
||||
margin-bottom: var(--spacing-2xs);
|
||||
cursor: pointer;
|
||||
transition: border 0.3s ease;
|
||||
padding: var(--spacing-xs);
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-foreground-dark);
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.listItemBody {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
|
||||
.listItemCheckbox {
|
||||
display: inline-flex !important;
|
||||
margin-bottom: 0 !important;
|
||||
margin-right: var(--spacing-2xs);
|
||||
}
|
||||
|
||||
.listItemStatus {
|
||||
margin-left: var(--spacing-2xs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
@@ -123,7 +123,7 @@ export default defineComponent({
|
||||
versionId: '',
|
||||
}),
|
||||
},
|
||||
readonly: {
|
||||
readOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
@@ -137,7 +137,7 @@ export default defineComponent({
|
||||
return getWorkflowPermissions(this.currentUser, this.data);
|
||||
},
|
||||
actions(): Array<{ label: string; value: string }> {
|
||||
return [
|
||||
const actions = [
|
||||
{
|
||||
label: this.$locale.baseText('workflows.item.open'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.OPEN,
|
||||
@@ -146,20 +146,23 @@ export default defineComponent({
|
||||
label: this.$locale.baseText('workflows.item.share'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.SHARE,
|
||||
},
|
||||
{
|
||||
];
|
||||
|
||||
if (!this.readOnly) {
|
||||
actions.push({
|
||||
label: this.$locale.baseText('workflows.item.duplicate'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DUPLICATE,
|
||||
},
|
||||
].concat(
|
||||
this.workflowPermissions.delete
|
||||
? [
|
||||
{
|
||||
label: this.$locale.baseText('workflows.item.delete'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (this.workflowPermissions.delete && !this.readOnly) {
|
||||
actions.push({
|
||||
label: this.$locale.baseText('workflows.item.delete'),
|
||||
value: WORKFLOW_LIST_ITEM_ACTIONS.DELETE,
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
},
|
||||
formattedCreatedAtDate(): string {
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
placeholder="Select Workflow"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-error-workflow"
|
||||
>
|
||||
@@ -57,6 +58,7 @@
|
||||
<el-col :span="14" class="ignore-key-press">
|
||||
<n8n-select
|
||||
v-model="workflowSettings.callerPolicy"
|
||||
:disabled="readOnlyEnv"
|
||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
size="medium"
|
||||
filterable
|
||||
@@ -84,6 +86,7 @@
|
||||
</el-col>
|
||||
<el-col :span="14">
|
||||
<n8n-input
|
||||
:disabled="readOnlyEnv"
|
||||
:placeholder="$locale.baseText('workflowSettings.callerIds.placeholder')"
|
||||
type="text"
|
||||
size="medium"
|
||||
@@ -109,6 +112,7 @@
|
||||
placeholder="Select Timezone"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-timezone"
|
||||
>
|
||||
@@ -138,6 +142,7 @@
|
||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-save-failed-executions"
|
||||
>
|
||||
@@ -167,6 +172,7 @@
|
||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-save-success-executions"
|
||||
>
|
||||
@@ -196,6 +202,7 @@
|
||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-save-manual-executions"
|
||||
>
|
||||
@@ -225,6 +232,7 @@
|
||||
:placeholder="$locale.baseText('workflowSettings.selectOption')"
|
||||
size="medium"
|
||||
filterable
|
||||
:disabled="readOnlyEnv"
|
||||
:limit-popper-width="true"
|
||||
data-test-id="workflow-settings-save-execution-progress"
|
||||
>
|
||||
@@ -252,6 +260,7 @@
|
||||
<div>
|
||||
<el-switch
|
||||
ref="inputField"
|
||||
:disabled="readOnlyEnv"
|
||||
:value="workflowSettings.executionTimeout > -1"
|
||||
@change="toggleTimeout"
|
||||
active-color="#13ce66"
|
||||
@@ -277,6 +286,7 @@
|
||||
<el-col :span="4">
|
||||
<n8n-input
|
||||
size="medium"
|
||||
:disabled="readOnlyEnv"
|
||||
:value="timeoutHMS.hours"
|
||||
@input="(value) => setTimeout('hours', value)"
|
||||
:min="0"
|
||||
@@ -287,6 +297,7 @@
|
||||
<el-col :span="4" class="timeout-input">
|
||||
<n8n-input
|
||||
size="medium"
|
||||
:disabled="readOnlyEnv"
|
||||
:value="timeoutHMS.minutes"
|
||||
@input="(value) => setTimeout('minutes', value)"
|
||||
:min="0"
|
||||
@@ -298,6 +309,7 @@
|
||||
<el-col :span="4" class="timeout-input">
|
||||
<n8n-input
|
||||
size="medium"
|
||||
:disabled="readOnlyEnv"
|
||||
:value="timeoutHMS.seconds"
|
||||
@input="(value) => setTimeout('seconds', value)"
|
||||
:min="0"
|
||||
@@ -313,6 +325,7 @@
|
||||
<template #footer>
|
||||
<div class="action-buttons" data-test-id="workflow-settings-save-button">
|
||||
<n8n-button
|
||||
:disabled="readOnlyEnv"
|
||||
:label="$locale.baseText('workflowSettings.save')"
|
||||
size="large"
|
||||
float="right"
|
||||
@@ -348,11 +361,14 @@ import {
|
||||
|
||||
import type { WorkflowSettings } from 'n8n-workflow';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
import { useWorkflowsStore } from '@/stores/workflows.store';
|
||||
import { useSettingsStore } from '@/stores/settings.store';
|
||||
import { useRootStore } from '@/stores/n8nRoot.store';
|
||||
import useWorkflowsEEStore from '@/stores/workflows.ee.store';
|
||||
import { useUsersStore } from '@/stores/users.store';
|
||||
import {
|
||||
useWorkflowsStore,
|
||||
useSettingsStore,
|
||||
useRootStore,
|
||||
useWorkflowsEEStore,
|
||||
useUsersStore,
|
||||
useVersionControlStore,
|
||||
} from '@/stores';
|
||||
import { createEventBus } from 'n8n-design-system';
|
||||
|
||||
export default defineComponent({
|
||||
@@ -424,6 +440,7 @@ export default defineComponent({
|
||||
useSettingsStore,
|
||||
useWorkflowsStore,
|
||||
useWorkflowsEEStore,
|
||||
useVersionControlStore,
|
||||
),
|
||||
workflowName(): string {
|
||||
return this.workflowsStore.workflowName;
|
||||
@@ -447,6 +464,9 @@ export default defineComponent({
|
||||
|
||||
return this.workflowsEEStore.getWorkflowOwnerName(`${this.workflowId}`, fallback);
|
||||
},
|
||||
readOnlyEnv(): boolean {
|
||||
return this.versionControlStore.preferences.branchReadOnly;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.executionTimeout = this.rootStore.executionTimeout;
|
||||
|
||||
Reference in New Issue
Block a user