Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot
2025-04-30 15:13:29 +00:00
parent 5494c7bfaa
commit 7d48c9bdf7
372 changed files with 536 additions and 35597 deletions

View File

@ -95,23 +95,6 @@ export default {
'app/assets/javascripts/google_cloud/gcp_regions/list.vue',
'app/assets/javascripts/groups/components/group_item.vue',
'app/assets/javascripts/groups/components/invite_members_banner.vue',
'app/assets/javascripts/ide/components/commit_sidebar/form.vue',
'app/assets/javascripts/ide/components/commit_sidebar/list_item.vue',
'app/assets/javascripts/ide/components/commit_sidebar/radio_group.vue',
'app/assets/javascripts/ide/components/file_row_extra.vue',
'app/assets/javascripts/ide/components/ide.vue',
'app/assets/javascripts/ide/components/ide_side_bar.vue',
'app/assets/javascripts/ide/components/ide_status_bar.vue',
'app/assets/javascripts/ide/components/ide_tree.vue',
'app/assets/javascripts/ide/components/ide_tree_list.vue',
'app/assets/javascripts/ide/components/jobs/item.vue',
'app/assets/javascripts/ide/components/merge_requests/list.vue',
'app/assets/javascripts/ide/components/new_dropdown/modal.vue',
'app/assets/javascripts/ide/components/panes/right.vue',
'app/assets/javascripts/ide/components/pipelines/list.vue',
'app/assets/javascripts/ide/components/repo_commit_section.vue',
'app/assets/javascripts/ide/components/repo_editor.vue',
'app/assets/javascripts/ide/components/repo_tabs.vue',
'app/assets/javascripts/import_entities/import_groups/components/import_actions_cell.vue',
'app/assets/javascripts/import_entities/import_groups/components/import_table.vue',
'app/assets/javascripts/import_entities/import_groups/components/import_target_cell.vue',

View File

@ -1251,6 +1251,7 @@ lib/gitlab/checks/**
# i18n Docs
[Localization Team] @gitlab-com/localization/maintainers
/doc-locale/**
/doc/development/i18n/proofreader.md
[Authorization] @gitlab-org/software-supply-chain-security/authorization/approvers
/config/initializers/declarative_policy.rb

View File

@ -21,8 +21,7 @@
{
"files": [
"app/assets/stylesheets/application_dark.scss",
"app/assets/stylesheets/framework/**/*.scss",
"app/assets/stylesheets/page_bundles/_ide_theme_overrides.scss"
"app/assets/stylesheets/framework/**/*.scss"
],
"rules": {
"gitlab/no-gl-class": null

View File

@ -1 +1 @@
c9a38829a0c35e0d325941304873f715fb10507e
b8a5f8c7440eb511a10903d7f3d1862c96cd6428

View File

@ -163,7 +163,7 @@ export default {
:title="restProjectsTooltip"
class="deploy-project-label gl-mb-2 gl-mr-2 gl-truncate"
href="#"
@click.native="toggleExpanded"
@click="toggleExpanded"
>
<span class="gl-truncate">{{ restProjectsLabel }}</span>
</gl-badge>

View File

@ -1,178 +0,0 @@
/**
* A WebIDE Extension options for Source Editor
* @typedef {Object} WebIDEExtensionOptions
* @property {Object} modelManager The root manager for WebIDE models
* @property {Object} store The state store for communication
* @property {Object} file
* @property {Object} options The Monaco editor options
*/
import { KeyCode, KeyMod, Range } from 'monaco-editor';
import { EDITOR_TYPE_DIFF } from '~/editor/constants';
import Disposable from '~/ide/lib/common/disposable';
import { editorOptions } from '~/ide/lib/editor_options';
import keymap from '~/ide/lib/keymap.json';
const isDiffEditorType = (instance) => {
return instance.getEditorType() === EDITOR_TYPE_DIFF;
};
export const UPDATE_DIMENSIONS_DELAY = 200;
const defaultOptions = {
modelManager: undefined,
store: undefined,
file: undefined,
options: {},
};
const addActions = (instance, store) => {
const getKeyCode = (key) => {
const monacoKeyMod = key.indexOf('KEY_') === 0;
return monacoKeyMod ? KeyCode[key] : KeyMod[key];
};
keymap.forEach((command) => {
const { bindings, id, label, action } = command;
const keybindings = bindings.map((binding) => {
const keys = binding.split('+');
// eslint-disable-next-line no-bitwise
return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
});
instance.addAction({
id,
label,
keybindings,
run() {
store.dispatch(action.name, action.params);
return null;
},
});
});
};
const renderSideBySide = (domElement) => {
return domElement.offsetWidth >= 700;
};
const updateDiffInstanceRendering = (instance) => {
instance.updateOptions({
renderSideBySide: renderSideBySide(instance.getDomNode()),
});
};
export class EditorWebIdeExtension {
static get extensionName() {
return 'EditorWebIde';
}
/**
* Set up the WebIDE extension for Source Editor
* @param {module:source_editor_instance~EditorInstance} instance - The Source Editor instance
* @param {WebIDEExtensionOptions} setupOptions
*/
onSetup(instance, setupOptions = defaultOptions) {
this.modelManager = setupOptions.modelManager;
this.store = setupOptions.store;
this.file = setupOptions.file;
this.options = setupOptions.options;
this.disposable = new Disposable();
addActions(instance, setupOptions.store);
if (isDiffEditorType(instance)) {
updateDiffInstanceRendering(instance);
instance.getModifiedEditor().onDidLayoutChange(() => {
updateDiffInstanceRendering(instance);
});
}
instance.onDidDispose(() => {
this.onUnuse();
});
}
onUnuse() {
// catch any potential errors with disposing the error
// this is mainly for tests caused by elements not existing
try {
this.disposable.dispose();
} catch (e) {
if (process.env.NODE_ENV !== 'test') {
// eslint-disable-next-line no-console
console.error(e);
}
}
}
provides() {
return {
createModel: (instance, file, head = null) => {
return this.modelManager.addModel(file, head);
},
attachModel: (instance, model) => {
if (isDiffEditorType(instance)) {
instance.setModel({
original: model.getOriginalModel(),
modified: model.getModel(),
});
return;
}
instance.setModel(model.getModel());
instance.updateOptions(
editorOptions.reduce((acc, obj) => {
Object.keys(obj).forEach((key) => {
Object.assign(acc, {
[key]: obj[key](model),
});
});
return acc;
}, {}),
);
},
attachMergeRequestModel: (instance, model) => {
instance.setModel({
original: model.getBaseModel(),
modified: model.getModel(),
});
},
setPos: (instance, { lineNumber, column }) => {
instance.revealPositionInCenter({
lineNumber,
column,
});
instance.setPosition({
lineNumber,
column,
});
},
onPositionChange: (instance, cb) => {
if (typeof instance.onDidChangeCursorPosition !== 'function') {
return;
}
this.disposable.add(instance.onDidChangeCursorPosition((e) => cb(instance, e)));
},
replaceSelectedText: (instance, text) => {
let selection = instance.getSelection();
const range = new Range(
selection.startLineNumber,
selection.startColumn,
selection.endLineNumber,
selection.endColumn,
);
instance.executeEdits('', [{ range, text }]);
selection = instance.getSelection();
instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
},
};
}
}

View File

@ -89,7 +89,7 @@ export default {
:icon="statusIcon"
:href="badgeHref"
tabindex="0"
@click.native="onClick"
@click="onClick"
>
{{ badgeText }}
</gl-badge>

View File

@ -256,7 +256,7 @@ export default {
data-testid="sync-badge"
tabindex="0"
:href="fluxBadgeHref"
@click.native="toggleFluxResource('')"
@click="toggleFluxResource('')"
>{{ syncStatusBadge.text }}
<gl-popover :target="fluxBadgeId" :title="syncStatusBadge.popoverTitle">
<span

View File

@ -65,7 +65,7 @@ export default {
class="gl-align-middle"
:variant="status"
data-testid="check-version-badge"
@click.native="onClick"
@click="onClick"
>{{ title }}</gl-badge
>
</template>

View File

@ -1,98 +0,0 @@
<script>
import { GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { leftSidebarViews } from '../constants';
export default {
components: {
GlIcon,
GlBadge,
},
directives: {
GlTooltip: GlTooltipDirective,
},
computed: {
...mapState(['currentActivityView', 'stagedFiles']),
},
methods: {
...mapActions(['updateActivityBarView']),
changedActivityView(e, view) {
e.currentTarget.blur();
this.updateActivityBarView(view);
this.$root.$emit(BV_HIDE_TOOLTIP);
},
},
leftSidebarViews,
};
</script>
<template>
<nav class="ide-activity-bar" data-testid="left-sidebar">
<ul class="list-unstyled">
<li>
<button
v-gl-tooltip.right.viewport
:class="{
active: currentActivityView === $options.leftSidebarViews.edit.name,
}"
:title="s__('IDE|Edit')"
:aria-label="s__('IDE|Edit')"
data-container="body"
data-placement="right"
data-testid="edit-mode-button"
type="button"
class="ide-sidebar-link js-ide-edit-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.edit.name)"
>
<gl-icon name="code" />
</button>
</li>
<li>
<button
v-gl-tooltip.right.viewport
:class="{
active: currentActivityView === $options.leftSidebarViews.review.name,
}"
:title="s__('IDE|Review')"
:aria-label="s__('IDE|Review')"
data-container="body"
data-placement="right"
data-testid="review-mode-button"
type="button"
class="ide-sidebar-link js-ide-review-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.review.name)"
>
<gl-icon name="review-list" />
</button>
</li>
<li>
<button
v-gl-tooltip.right.viewport
:class="{
active: currentActivityView === $options.leftSidebarViews.commit.name,
}"
:title="s__('IDE|Commit')"
:aria-label="s__('IDE|Commit')"
data-container="body"
data-placement="right"
data-testid="commit-mode-button"
type="button"
class="ide-sidebar-link js-ide-commit-mode"
@click.prevent="changedActivityView($event, $options.leftSidebarViews.commit.name)"
>
<gl-icon name="commit" />
<gl-badge
v-if="stagedFiles.length"
class="gl-absolute gl-right-3 gl-top-3 !gl-bg-gray-900 gl-px-2 gl-font-bold !gl-text-white"
>
{{ stagedFiles.length }}
</gl-badge>
</button>
</li>
</ul>
</nav>
</template>

View File

@ -1,50 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlIcon, GlButton } from '@gitlab/ui';
import Timeago from '~/vue_shared/components/time_ago_tooltip.vue';
export default {
components: {
GlIcon,
Timeago,
GlButton,
},
props: {
item: {
type: Object,
required: true,
},
projectId: {
type: String,
required: true,
},
isActive: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
branchHref() {
return this.$router.resolve(`/project/${this.projectId}/edit/${this.item.name}`).href;
},
},
};
</script>
<template>
<gl-button variant="link" :href="branchHref">
<span class="gl-flex gl-items-center">
<span class="ide-search-list-current-icon gl-mr-3 gl-flex">
<gl-icon v-if="isActive" :size="16" name="mobile-issue-close" />
</span>
<span class="gl-flex gl-flex-col gl-items-end">
<strong> {{ item.name }} </strong>
<span class="ide-merge-request-project-path mt-1 gl-block">
Updated <timeago :time="item.committedDate || ''" />
</span>
</span>
</span>
</gl-button>
</template>

View File

@ -1,95 +0,0 @@
<script>
import { GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { debounce } from 'lodash';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import Item from './item.vue';
export default {
components: {
Item,
GlIcon,
GlLoadingIcon,
},
data() {
return {
search: '',
};
},
computed: {
...mapState('branches', ['branches', 'isLoading']),
...mapState(['currentBranchId', 'currentProjectId']),
hasBranches() {
return this.branches.length !== 0;
},
hasNoSearchResults() {
return this.search !== '' && !this.hasBranches;
},
},
watch: {
isLoading: {
handler: 'focusSearch',
},
},
mounted() {
this.loadBranches();
},
methods: {
...mapActions('branches', ['fetchBranches']),
loadBranches() {
this.fetchBranches({ search: this.search });
},
searchBranches: debounce(function debounceSearch() {
this.loadBranches();
}, 250),
focusSearch() {
if (!this.isLoading) {
this.$nextTick(() => {
this.$refs.searchInput.focus();
});
}
},
isActiveBranch(item) {
return item.name === this.currentBranchId;
},
},
};
</script>
<template>
<div>
<label
class="dropdown-input gl-mb-0 gl-block gl-border-b-1 gl-pb-5 gl-pt-3 gl-border-b-solid"
@click.stop
>
<input
ref="searchInput"
v-model="search"
:placeholder="__('Search branches')"
type="search"
class="form-control dropdown-input-field"
@input="searchBranches"
/>
<gl-icon name="search" class="input-icon gl-ml-5 gl-mt-1" />
</label>
<div class="dropdown-content ide-merge-requests-dropdown-content !gl-flex">
<gl-loading-icon
v-if="isLoading"
size="lg"
class="mt-3 mb-3 align-self-center ml-auto mr-auto"
/>
<ul v-else class="mb-0 gl-w-full">
<template v-if="hasBranches">
<li v-for="item in branches" :key="item.name">
<item :item="item" :project-id="currentProjectId" :is-active="isActiveBranch(item)" />
</li>
</template>
<li v-else class="ide-search-list-empty !gl-flex gl-items-center gl-justify-center">
<template v-if="hasNoSearchResults">
{{ __('No branches found') }}
</template>
</li>
</ul>
</div>
</div>
</template>

View File

@ -1,40 +0,0 @@
<script>
import { GlAlert, GlButton } from '@gitlab/ui';
export default {
components: {
GlAlert,
GlButton,
},
props: {
message: {
type: String,
required: true,
},
action: {
type: Object,
required: false,
default: null,
},
},
computed: {
hasAction() {
return Boolean(this.action?.href);
},
actionButtonMethod() {
return this.action?.isForm ? 'post' : null;
},
},
};
</script>
<template>
<gl-alert :dismissible="false">
{{ message }}
<template v-if="hasAction" #actions>
<gl-button variant="confirm" :href="action.href" :data-method="actionButtonMethod">
{{ action.text }}
</gl-button>
</template>
</gl-alert>
</template>

View File

@ -1,98 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlSprintf } from '@gitlab/ui';
import { escape } from 'lodash';
// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, createNamespacedHelpers } from 'vuex';
import { s__ } from '~/locale';
import {
COMMIT_TO_CURRENT_BRANCH,
COMMIT_TO_NEW_BRANCH,
} from '../../stores/modules/commit/constants';
import NewMergeRequestOption from './new_merge_request_option.vue';
import RadioGroup from './radio_group.vue';
const { mapState: mapCommitState, mapActions: mapCommitActions } =
createNamespacedHelpers('commit');
export default {
components: {
GlSprintf,
RadioGroup,
NewMergeRequestOption,
},
computed: {
...mapState(['currentBranchId', 'changedFiles', 'stagedFiles']),
...mapCommitState(['commitAction']),
...mapGetters(['currentBranch', 'emptyRepo', 'canPushToBranch']),
currentBranchText() {
return escape(this.currentBranchId);
},
containsStagedChanges() {
return this.changedFiles.length > 0 && this.stagedFiles.length > 0;
},
shouldDefaultToCurrentBranch() {
if (this.emptyRepo) {
return true;
}
return this.canPushToBranch && !this.currentBranch?.default;
},
},
watch: {
containsStagedChanges() {
this.updateSelectedCommitAction();
},
},
mounted() {
if (!this.commitAction) {
this.updateSelectedCommitAction();
}
},
methods: {
...mapCommitActions(['updateCommitAction']),
updateSelectedCommitAction() {
if (!this.currentBranch && !this.emptyRepo) {
return;
}
if (this.shouldDefaultToCurrentBranch) {
this.updateCommitAction(COMMIT_TO_CURRENT_BRANCH);
} else {
this.updateCommitAction(COMMIT_TO_NEW_BRANCH);
}
},
},
commitToCurrentBranch: COMMIT_TO_CURRENT_BRANCH,
commitToNewBranch: COMMIT_TO_NEW_BRANCH,
currentBranchPermissionsTooltip: s__(
"IDE|This option is disabled because you don't have write permissions for the current branch.",
),
};
</script>
<template>
<div class="ide-commit-options gl-mb-5">
<radio-group
:value="$options.commitToCurrentBranch"
:disabled="!canPushToBranch"
:title="$options.currentBranchPermissionsTooltip"
>
<span class="ide-option-label">
<gl-sprintf :message="s__('IDE|Commit to %{branchName} branch')">
<template #branchName>
<strong class="monospace">{{ currentBranchText }}</strong>
</template>
</gl-sprintf>
</span>
</radio-group>
<template v-if="!emptyRepo">
<radio-group
:value="$options.commitToNewBranch"
:label="__('Create a new branch')"
:show-input="true"
/>
<new-merge-request-option />
</template>
</div>
</template>

View File

@ -1,93 +0,0 @@
<script>
import { GlModal, GlButton } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { sprintf, __ } from '~/locale';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
export default {
components: {
GlModal,
GlButton,
FileIcon,
ChangedFileIcon,
},
props: {
activeFile: {
type: Object,
required: true,
},
},
modal: {
actionPrimary: {
text: __('Discard changes'),
attributes: {
variant: 'danger',
},
},
actionCancel: {
text: __('Cancel'),
attributes: {
variant: 'default',
},
},
},
computed: {
discardModalId() {
return `discard-file-${this.activeFile.path}`;
},
discardModalTitle() {
return sprintf(__('Discard changes to %{path}?'), { path: this.activeFile.path });
},
canDiscard() {
return this.activeFile.changed || this.activeFile.staged;
},
},
methods: {
...mapActions(['unstageChange', 'discardFileChanges']),
showDiscardModal() {
this.$refs.discardModal.show();
},
discardChanges(path) {
this.unstageChange(path);
this.discardFileChanges(path);
},
},
};
</script>
<template>
<div class="ide-commit-editor-header gl-flex gl-items-center">
<file-icon :file-name="activeFile.name" :size="16" class="gl-mr-3" />
<strong class="gl-mr-3">
<template v-if="activeFile.prevPath && activeFile.prevPath !== activeFile.path">
{{ activeFile.prevPath }} &#x2192;
</template>
{{ activeFile.path }}
</strong>
<changed-file-icon :file="activeFile" :is-centered="false" />
<div class="ml-auto">
<gl-button
v-if="canDiscard"
ref="discardButton"
category="secondary"
variant="danger"
class="gl-mr-3"
@click="showDiscardModal"
>
{{ __('Discard changes') }}
</gl-button>
</div>
<gl-modal
ref="discardModal"
:modal-id="discardModalId"
:title="discardModalTitle"
:action-primary="$options.modal.actionPrimary"
:action-cancel="$options.modal.actionCancel"
@primary="discardChanges(activeFile.path)"
>
{{ __("You will lose all changes you've made to this file. This action cannot be undone.") }}
</gl-modal>
</div>
</template>

View File

@ -1,24 +0,0 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
export default {
computed: {
...mapState(['lastCommitMsg', 'noChangesStateSvgPath']),
},
};
</script>
<template>
<div v-if="!lastCommitMsg" class="multi-file-commit-panel-section ide-commit-empty-state">
<div class="ide-commit-empty-state-container">
<div class="svg-content svg-80"><img :src="noChangesStateSvgPath" /></div>
<div class="gl-ml-3 gl-mr-3">
<div class="text-content text-center">
<h4>{{ __('No changes') }}</h4>
<p>{{ __('Edit files in the editor and commit changes here') }}</p>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,230 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlModal, GlButton, GlTooltipDirective } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { n__ } from '~/locale';
import { leftSidebarViews, MAX_WINDOW_HEIGHT_COMPACT } from '../../constants';
import { createUnexpectedCommitError } from '../../lib/errors';
import Actions from './actions.vue';
import CommitMessageField from './message_field.vue';
import SuccessMessage from './success_message.vue';
export default {
components: {
Actions,
CommitMessageField,
SuccessMessage,
GlModal,
GlButton,
},
directives: {
SafeHtml,
GlTooltip: GlTooltipDirective,
},
data() {
return {
isCompact: true,
componentHeight: null,
// Keep track of "lastCommitError" so we hold onto the value even when "commitError" is cleared.
lastCommitError: createUnexpectedCommitError(),
};
},
computed: {
...mapState(['changedFiles', 'stagedFiles', 'currentActivityView', 'lastCommitMsg']),
...mapState('commit', ['commitMessage', 'submitCommitLoading', 'commitError']),
...mapGetters(['someUncommittedChanges', 'canPushCodeStatus']),
...mapGetters('commit', ['discardDraftButtonDisabled', 'preBuiltCommitMessage']),
commitButtonDisabled() {
return !this.canPushCodeStatus.isAllowed || !this.someUncommittedChanges;
},
commitButtonTooltip() {
if (!this.canPushCodeStatus.isAllowed) {
return this.canPushCodeStatus.messageShort;
}
return '';
},
overviewText() {
return n__('%d changed file', '%d changed files', this.stagedFiles.length);
},
currentViewIsCommitView() {
return this.currentActivityView === leftSidebarViews.commit.name;
},
commitErrorPrimaryAction() {
const { primaryAction } = this.lastCommitError || {};
return {
button: primaryAction ? { text: primaryAction.text } : undefined,
callback: primaryAction?.callback?.bind(this, this.$store) || (() => {}),
};
},
},
watch: {
currentActivityView: 'handleCompactState',
someUncommittedChanges: 'handleCompactState',
lastCommitMsg: 'handleCompactState',
commitError(val) {
if (!val) {
return;
}
this.lastCommitError = val;
this.$refs.commitErrorModal.show();
},
},
methods: {
...mapActions(['updateActivityBarView']),
...mapActions('commit', [
'updateCommitMessage',
'discardDraft',
'commitChanges',
'updateCommitAction',
]),
commit() {
// Even though the submit button will be disabled, we need to disable the submission
// since hitting enter on the branch name text input also submits the form.
if (!this.canPushCodeStatus.isAllowed) {
return false;
}
return this.commitChanges();
},
handleCompactState() {
if (this.lastCommitMsg) {
this.isCompact = false;
} else {
this.isCompact =
!this.someUncommittedChanges ||
!this.currentViewIsCommitView ||
window.innerHeight < MAX_WINDOW_HEIGHT_COMPACT;
}
},
toggleIsCompact() {
this.isCompact = !this.isCompact;
},
beginCommit() {
return this.updateActivityBarView(leftSidebarViews.commit.name).then(() => {
this.isCompact = false;
});
},
beforeEnterTransition() {
const elHeight = this.isCompact
? this.$refs.formEl && this.$refs.formEl.offsetHeight
: this.$refs.compactEl && this.$refs.compactEl.offsetHeight;
this.componentHeight = elHeight;
},
enterTransition() {
this.$nextTick(() => {
const elHeight = this.isCompact
? this.$refs.compactEl && this.$refs.compactEl.offsetHeight
: this.$refs.formEl && this.$refs.formEl.offsetHeight;
this.componentHeight = elHeight;
});
},
afterEndTransition() {
this.componentHeight = null;
},
},
};
</script>
<template>
<div
:class="{
'is-compact': isCompact,
'is-full': !isCompact,
}"
:style="{
height: componentHeight ? `${componentHeight}px` : null,
}"
class="multi-file-commit-form"
>
<transition
name="commit-form-slide-up"
@before-enter="beforeEnterTransition"
@enter="enterTransition"
@after-enter="afterEndTransition"
>
<div v-if="isCompact" ref="compactEl" class="commit-form-compact">
<div
v-gl-tooltip="{ title: commitButtonTooltip }"
data-testid="begin-commit-button-tooltip"
>
<gl-button
:disabled="commitButtonDisabled"
category="primary"
variant="confirm"
block
data-testid="begin-commit-button"
@click="beginCommit"
>
{{ __('Create commit…') }}
</gl-button>
</div>
<p class="gl-text-center gl-font-bold">{{ overviewText }}</p>
</div>
<form v-else ref="formEl" @submit.prevent.stop="commit">
<transition name="fade"> <success-message v-show="lastCommitMsg" /> </transition>
<commit-message-field
:text="commitMessage"
:placeholder="preBuiltCommitMessage"
@input="updateCommitMessage"
@submit="commit"
/>
<div class="clearfix gl-mt-5">
<actions />
<div
v-gl-tooltip="{ title: commitButtonTooltip }"
class="float-left"
data-testid="commit-button-tooltip"
>
<gl-button
:disabled="commitButtonDisabled"
:loading="submitCommitLoading"
data-testid="commit-button"
category="primary"
variant="confirm"
type="submit"
>
{{ __('Commit') }}
</gl-button>
</div>
<gl-button
v-if="!discardDraftButtonDisabled"
class="gl-float-right"
data-testid="discard-draft"
@click="discardDraft"
>
{{ __('Discard draft') }}
</gl-button>
<gl-button
v-else
type="button"
class="gl-float-right"
category="secondary"
variant="default"
@click="toggleIsCompact"
>
{{ __('Collapse') }}
</gl-button>
</div>
<gl-modal
ref="commitErrorModal"
modal-id="ide-commit-error-modal"
:title="lastCommitError.title"
:action-primary="commitErrorPrimaryAction.button"
:action-cancel="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ {
text: __('Cancel'),
} /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */"
@ok="commitErrorPrimaryAction.callback"
>
<div v-safe-html="lastCommitError.messageHTML"></div>
</gl-modal>
</form>
</transition>
</div>
</template>

View File

@ -1,129 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlModal, GlTooltipDirective } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { __ } from '~/locale';
import ListItem from './list_item.vue';
export default {
components: {
GlButton,
ListItem,
GlModal,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
fileList: {
type: Array,
required: true,
},
stagedList: {
type: Boolean,
required: false,
default: false,
},
activeFileKey: {
type: String,
required: false,
default: null,
},
keyPrefix: {
type: String,
required: true,
},
emptyStateText: {
type: String,
required: false,
default: __('No changes'),
},
},
modal: {
actionPrimary: {
text: __('Discard all changes'),
attributes: {
variant: 'danger',
},
},
actionCancel: {
text: __('Cancel'),
attributes: {
variant: 'default',
},
},
},
computed: {
filesLength() {
return this.fileList.length;
},
},
methods: {
...mapActions(['unstageAllChanges', 'discardAllChanges']),
openDiscardModal() {
this.$refs.discardAllModal.show();
},
unstageAndDiscardAllChanges() {
this.unstageAllChanges();
this.discardAllChanges();
},
},
discardModalText: __(
"You will lose all uncommitted changes you've made in this project. This action cannot be undone.",
),
};
</script>
<template>
<div class="ide-commit-list-container">
<header class="multi-file-commit-panel-header gl-mb-0 gl-flex">
<div class="flex-fill gl-flex gl-items-center">
<strong> {{ __('Changes') }} </strong>
<div class="gl-ml-auto gl-flex">
<gl-button
v-if="!stagedList"
v-gl-tooltip
:title="__('Discard all changes')"
:aria-label="__('Discard all changes')"
:disabled="!filesLength"
:class="{
'disabled-content': !filesLength,
}"
class="!gl-shadow-none"
category="tertiary"
icon="remove"
data-placement="bottom"
data-container="body"
data-boundary="viewport"
@click="openDiscardModal"
/>
</div>
</div>
</header>
<ul v-if="filesLength" class="multi-file-commit-list list-unstyled gl-mb-0">
<li v-for="file in fileList" :key="file.key">
<list-item
:file="file"
:key-prefix="keyPrefix"
:staged-list="stagedList"
:active-file-key="activeFileKey"
/>
</li>
</ul>
<p v-else class="multi-file-commit-list form-text gl-text-center gl-text-subtle">
{{ emptyStateText }}
</p>
<gl-modal
v-if="!stagedList"
ref="discardAllModal"
modal-id="discard-all-changes"
:title="__('Discard all changes?')"
:action-primary="$options.modal.actionPrimary"
:action-cancel="$options.modal.actionCancel"
@primary="unstageAndDiscardAllChanges"
>
{{ $options.discardModalText }}
</gl-modal>
</div>
</template>

View File

@ -1,103 +0,0 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import getCommitIconMap from '../../commit_icon';
import { viewerTypes } from '../../constants';
export default {
components: {
GlIcon,
FileIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
file: {
type: Object,
required: true,
},
keyPrefix: {
type: String,
required: false,
default: '',
},
stagedList: {
type: Boolean,
required: false,
default: false,
},
activeFileKey: {
type: String,
required: false,
default: null,
},
},
computed: {
iconName() {
// name: '-solid' is a false positive: https://gitlab.com/gitlab-org/frontend/eslint-plugin-i18n/issues/26#possible-false-positives
// eslint-disable-next-line @gitlab/require-i18n-strings
const suffix = this.stagedList ? '-solid' : '';
return `${getCommitIconMap(this.file).icon}${suffix}`;
},
iconClass() {
return `${getCommitIconMap(this.file).class} ml-auto mr-auto`;
},
fullKey() {
return `${this.keyPrefix}-${this.file.key}`;
},
isActive() {
return this.activeFileKey === this.fullKey;
},
tooltipTitle() {
return this.file.path === this.file.name ? '' : this.file.path;
},
},
methods: {
...mapActions(['discardFileChanges', 'updateViewer', 'openPendingTab']),
openFileInEditor() {
if (this.file.type === 'tree') return null;
return this.openPendingTab({
file: this.file,
keyPrefix: this.keyPrefix,
}).then((changeViewer) => {
if (changeViewer) {
this.updateViewer(viewerTypes.diff);
}
});
},
},
};
</script>
<template>
<div class="multi-file-commit-list-item position-relative">
<div
v-gl-tooltip
:title="tooltipTitle"
:class="{
'is-active': isActive,
}"
class="multi-file-commit-list-path border-0 ml-0 mr-0 gl-w-full"
role="button"
@click="openFileInEditor"
>
<span class="multi-file-commit-list-file-path !gl-flex gl-items-center">
<file-icon :file-name="file.name" class="gl-mr-3" />
<template v-if="file.prevName && file.prevName !== file.name">
{{ file.prevName }} &#x2192;
</template>
{{ file.name }}
</span>
<div class="ml-auto gl-flex gl-items-center">
<div class="ide-commit-list-changed-icon gl-flex gl-items-center">
<gl-icon :name="iconName" :size="16" :class="iconClass" />
</div>
</div>
</div>
</div>
</template>

View File

@ -1,129 +0,0 @@
<script>
import { GlPopover } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import HelpIcon from '~/vue_shared/components/help_icon/help_icon.vue';
import { MAX_TITLE_LENGTH, MAX_BODY_LENGTH } from '../../constants';
export default {
components: {
GlPopover,
HelpIcon,
},
props: {
text: {
type: String,
required: true,
},
placeholder: {
type: String,
required: true,
},
},
data() {
return {
scrollTop: 0,
isFocused: false,
};
},
computed: {
allLines() {
return this.text.split('\n').map((line, i) => ({
text: line.substr(0, this.getLineLength(i)) || ' ',
highlightedText: line.substr(this.getLineLength(i)),
}));
},
},
methods: {
handleScroll() {
if (this.$refs.textarea) {
this.$nextTick(() => {
this.scrollTop = this.$refs.textarea.scrollTop;
});
}
},
getLineLength(i) {
return i === 0 ? MAX_TITLE_LENGTH : MAX_BODY_LENGTH;
},
onInput(e) {
this.$emit('input', e.target.value);
},
onCtrlEnter() {
if (!this.isFocused) return;
this.$emit('submit');
},
updateIsFocused(isFocused) {
this.isFocused = isFocused;
},
},
popoverOptions: {
triggers: 'hover',
placement: 'top',
content: sprintf(
__(`
The character highlighter helps you keep the subject line to %{titleLength} characters
and wrap the body at %{bodyLength} so they are readable in git.
`),
{ titleLength: MAX_TITLE_LENGTH, bodyLength: MAX_BODY_LENGTH },
),
},
};
</script>
<template>
<fieldset class="common-note-form ide-commit-message-field">
<div
:class="{
'is-focused': isFocused,
}"
class="md-area"
>
<div v-once class="md-header">
<ul class="nav-links">
<li>
{{ __('Commit Message') }}
<div id="ide-commit-message-popover-container">
<span id="ide-commit-message-question" class="form-text gl-ml-3">
<help-icon />
</span>
<gl-popover
target="ide-commit-message-question"
container="ide-commit-message-popover-container"
v-bind="$options.popoverOptions"
/>
</div>
</li>
</ul>
</div>
<div class="ide-commit-message-textarea-container">
<div class="ide-commit-message-highlights-container">
<div
:style="{
transform: `translate3d(0, ${-scrollTop}px, 0)`,
}"
class="note-textarea highlights monospace"
>
<div v-for="(line, index) in allLines" :key="index">
<span v-text="line.text"> </span
><mark v-show="line.highlightedText" v-text="line.highlightedText"> </mark>
</div>
</div>
</div>
<textarea
ref="textarea"
:placeholder="placeholder"
:value="text"
class="note-textarea ide-commit-message-textarea"
dir="auto"
name="commit-message"
@scroll="handleScroll"
@input="onInput"
@focus="updateIsFocused(true)"
@blur="updateIsFocused(false)"
@keydown.ctrl.enter="onCtrlEnter"
@keydown.meta.enter="onCtrlEnter"
>
</textarea>
</div>
</div>
</fieldset>
</template>

View File

@ -1,53 +0,0 @@
<script>
import { GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { createNamespacedHelpers } from 'vuex';
import { s__ } from '~/locale';
const { mapActions: mapCommitActions, mapGetters: mapCommitGetters } =
createNamespacedHelpers('commit');
export default {
components: { GlFormCheckbox },
directives: {
GlTooltip: GlTooltipDirective,
},
i18n: {
newMrText: s__('IDE|Start a new merge request'),
tooltipText: s__(
'IDE|This option is disabled because you are not allowed to create merge requests in this project.',
),
},
computed: {
...mapCommitGetters(['shouldHideNewMrOption', 'shouldDisableNewMrOption', 'shouldCreateMR']),
tooltipText() {
return this.shouldDisableNewMrOption ? this.$options.i18n.tooltipText : null;
},
},
methods: {
...mapCommitActions(['toggleShouldCreateMR']),
},
};
</script>
<template>
<fieldset
v-if="!shouldHideNewMrOption"
v-gl-tooltip="tooltipText"
data-testid="new-merge-request-fieldset"
class="js-ide-commit-new-mr"
:class="{ 'is-disabled': shouldDisableNewMrOption }"
>
<hr class="gl-mb-4 gl-mt-3" />
<gl-form-checkbox
:disabled="shouldDisableNewMrOption"
:checked="shouldCreateMR"
@change="toggleShouldCreateMR"
>
<span class="ide-option-label">
{{ $options.i18n.newMrText }}
</span>
</gl-form-checkbox>
</fieldset>
</template>

View File

@ -1,104 +0,0 @@
<script>
import {
GlTooltipDirective,
GlFormRadio,
GlFormRadioGroup,
GlFormGroup,
GlFormInput,
} from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
export default {
components: {
GlFormRadio,
GlFormRadioGroup,
GlFormGroup,
GlFormInput,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
value: {
type: String,
required: true,
},
label: {
type: String,
required: false,
default: null,
},
checked: {
type: Boolean,
required: false,
default: false,
},
showInput: {
type: Boolean,
required: false,
default: false,
},
disabled: {
type: Boolean,
required: false,
default: false,
},
title: {
type: String,
required: false,
default: '',
},
},
computed: {
...mapState('commit', ['commitAction', 'newBranchName']),
...mapGetters('commit', ['placeholderBranchName']),
tooltipTitle() {
return this.disabled ? this.title : '';
},
},
methods: {
...mapActions('commit', ['updateCommitAction', 'updateBranchName']),
},
};
</script>
<template>
<fieldset class="gl-mb-2">
<gl-form-radio-group
v-gl-tooltip="tooltipTitle"
:checked="commitAction"
:class="{
'is-disabled': disabled,
}"
>
<gl-form-radio
:value="value"
:disabled="disabled"
name="commit-action"
@change="updateCommitAction(value)"
>
<span v-if="label" class="ide-option-label">
{{ label }}
</span>
<slot v-else></slot>
</gl-form-radio>
</gl-form-radio-group>
<gl-form-group
v-if="commitAction === value && showInput"
:label="placeholderBranchName"
:label-sr-only="true"
class="gl-mb-0 gl-ml-6"
>
<gl-form-input
:placeholder="placeholderBranchName"
:value="newBranchName"
:disabled="disabled"
data-testid="ide-new-branch-name"
class="gl-font-monospace"
@input="updateBranchName($event)"
/>
</gl-form-group>
</fieldset>
</template>

View File

@ -1,28 +0,0 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
export default {
directives: {
SafeHtml,
},
computed: {
...mapState(['lastCommitMsg', 'committedStateSvgPath']),
},
};
</script>
<template>
<div class="multi-file-commit-panel-success-message" aria-live="assertive">
<div class="svg-content svg-80">
<img :src="committedStateSvgPath" :alt="s__('IDE|Successful commit')" />
</div>
<div class="gl-ml-3 gl-mr-3">
<div class="text-content text-center">
<h4>{{ __('All changes are committed') }}</h4>
<p v-safe-html="lastCommitMsg"></p>
</div>
</div>
</div>
</template>

View File

@ -1,61 +0,0 @@
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { viewerTypes } from '../constants';
export default {
components: {
GlDropdown,
GlDropdownItem,
},
props: {
viewer: {
type: String,
required: true,
},
mergeRequestId: {
type: Number,
required: true,
},
},
computed: {
modeDropdownItems() {
return [
{
viewerType: this.$options.viewerTypes.mr,
title: sprintf(__('Reviewing (merge request !%{mergeRequestId})'), {
mergeRequestId: this.mergeRequestId,
}),
content: __('Compare changes with the merge request target branch'),
},
{
viewerType: this.$options.viewerTypes.diff,
title: __('Reviewing'),
content: __('Compare changes with the last commit'),
},
];
},
},
methods: {
changeMode(mode) {
this.$emit('click', mode);
},
},
viewerTypes,
};
</script>
<template>
<gl-dropdown :text="__('Edit')" size="small">
<gl-dropdown-item
v-for="mode in modeDropdownItems"
:key="mode.viewerType"
is-check-item
:is-checked="viewer === mode.viewerType"
@click="changeMode(mode.viewerType)"
>
<strong class="dropdown-menu-inner-title"> {{ mode.title }} </strong>
<span class="dropdown-menu-inner-content"> {{ mode.content }} </span>
</gl-dropdown-item>
</gl-dropdown>
</template>

View File

@ -1,65 +0,0 @@
<script>
import { GlAlert, GlLoadingIcon } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
export default {
components: {
GlAlert,
GlLoadingIcon,
},
directives: {
SafeHtml,
},
props: {
message: {
type: Object,
required: true,
},
},
data() {
return {
isLoading: false,
};
},
computed: {
canDismiss() {
return !this.message.action;
},
},
methods: {
...mapActions(['setErrorMessage']),
doAction() {
if (this.isLoading) return;
this.isLoading = true;
this.message
.action(this.message.actionPayload)
.then(() => {
this.isLoading = false;
})
.catch(() => {
this.isLoading = false;
});
},
dismiss() {
this.setErrorMessage(null);
},
},
};
</script>
<template>
<gl-alert
variant="danger"
:dismissible="canDismiss"
:primary-button-text="message.actionText"
@dismiss="dismiss"
@primaryAction="doAction"
>
<span v-safe-html="message.text"></span>
<gl-loading-icon v-show="isLoading" size="sm" inline class="vertical-align-middle ml-1" />
</gl-alert>
</template>

View File

@ -1,96 +0,0 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { n__ } from '~/locale';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
import MrFileIcon from './mr_file_icon.vue';
import NewDropdown from './new_dropdown/index.vue';
export default {
name: 'FileRowExtra',
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
NewDropdown,
ChangedFileIcon,
MrFileIcon,
},
props: {
file: {
type: Object,
required: true,
},
dropdownOpen: {
type: Boolean,
required: true,
},
},
computed: {
...mapGetters([
'getChangesInFolder',
'getUnstagedFilesCountForPath',
'getStagedFilesCountForPath',
]),
isTree() {
return this.file.type === 'tree';
},
folderUnstagedCount() {
return this.getUnstagedFilesCountForPath(this.file.path);
},
folderStagedCount() {
return this.getStagedFilesCountForPath(this.file.path);
},
changesCount() {
return this.getChangesInFolder(this.file.path);
},
folderChangesTooltip() {
if (this.changesCount === 0) return undefined;
return n__('%d changed file', '%d changed files', this.changesCount);
},
showTreeChangesCount() {
return this.isTree && this.changesCount > 0 && !this.file.opened;
},
isModified() {
return this.file.changed || this.file.tempFile || this.file.staged || this.file.prevPath;
},
showChangedFileIcon() {
return !this.isTree && this.isModified;
},
},
};
</script>
<template>
<div class="ide-file-icon-holder gl-float-right">
<mr-file-icon v-if="file.mrChange" />
<span v-if="showTreeChangesCount" class="ide-tree-changes">
{{ changesCount }}
<gl-icon
v-gl-tooltip.left.viewport
:title="folderChangesTooltip"
:size="12"
data-container="body"
data-placement="right"
name="file-modified"
class="ide-file-modified gl-ml-2"
/>
</span>
<changed-file-icon
v-else-if="showChangedFileIcon"
:file="file"
:show-tooltip="true"
:show-staged-icon="false"
/>
<new-dropdown
:type="file.type"
:path="file.path"
:is-open="dropdownOpen"
class="gl-ml-3"
v-on="$listeners"
/>
</div>
</template>

View File

@ -1,129 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
const barLabel = __('File templates');
const templateListDropdownLabel = __('Choose a template…');
const templateTypesDropdownLabel = __('Choose a type…');
const undoButtonText = __('Undo');
export default {
i18n: {
barLabel,
templateListDropdownLabel,
templateTypesDropdownLabel,
undoButtonText,
},
components: {
GlButton,
GlDropdown,
GlDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
},
data() {
return {
search: '',
};
},
computed: {
...mapGetters(['activeFile']),
...mapGetters('fileTemplates', ['templateTypes']),
...mapState('fileTemplates', [
'selectedTemplateType',
'updateSuccess',
'templates',
'isLoading',
]),
filteredTemplateTypes() {
return this.templates.filter((t) => {
return t.name.toLowerCase().includes(this.search.toLowerCase());
});
},
showTemplatesDropdown() {
return Object.keys(this.selectedTemplateType).length > 0;
},
},
watch: {
activeFile: 'setInitialType',
},
mounted() {
this.setInitialType();
},
methods: {
...mapActions('fileTemplates', [
'setSelectedTemplateType',
'fetchTemplate',
'fetchTemplateTypes',
'undoFileTemplate',
]),
setInitialType() {
const initialTemplateType = this.templateTypes.find((t) => t.name === this.activeFile.name);
if (initialTemplateType) {
this.setSelectedTemplateType(initialTemplateType);
}
},
selectTemplateType(templateType) {
this.setSelectedTemplateType(templateType);
},
selectTemplate(template) {
this.fetchTemplate(template);
},
undo() {
this.undoFileTemplate();
},
},
};
</script>
<template>
<div
class="ide-file-templates gl-relative gl-z-1 gl-flex gl-items-center"
data-testid="file-templates-bar"
>
<strong class="gl-mr-3"> {{ $options.i18n.barLabel }} </strong>
<gl-dropdown
class="gl-mr-6"
:text="selectedTemplateType.name || $options.i18n.templateTypesDropdownLabel"
>
<gl-dropdown-item
v-for="template in templateTypes"
:key="template.key"
@click.prevent="selectTemplateType(template)"
>
{{ template.name }}
</gl-dropdown-item>
</gl-dropdown>
<gl-dropdown
v-if="showTemplatesDropdown"
class="gl-mr-6"
:text="$options.i18n.templateListDropdownLabel"
@show="fetchTemplateTypes"
>
<template #header>
<gl-search-box-by-type v-model.trim="search" />
</template>
<div>
<gl-loading-icon v-if="isLoading" />
<template v-else>
<gl-dropdown-item
v-for="template in filteredTemplateTypes"
:key="template.key"
@click="selectTemplate(template)"
>
{{ template.name }}
</gl-dropdown-item>
</template>
</div>
</gl-dropdown>
<transition name="fade">
<gl-button v-show="updateSuccess" category="secondary" variant="default" @click="undo">
{{ $options.i18n.undoButtonText }}
</gl-button>
</transition>
</div>
</template>

View File

@ -1,238 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlLoadingIcon, GlBroadcastMessage, GlLink, GlSprintf } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { __ } from '~/locale';
import {
WEBIDE_MARK_APP_START,
WEBIDE_MARK_FILE_FINISH,
WEBIDE_MARK_FILE_CLICKED,
WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
WEBIDE_MEASURE_BEFORE_VUE,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import { helpPagePath } from '~/helpers/help_page_helper';
import { modalTypes } from '../constants';
import eventHub from '../eventhub';
import { measurePerformance } from '../utils';
import CannotPushCodeAlert from './cannot_push_code_alert.vue';
import IdeSidebar from './ide_side_bar.vue';
import RepoEditor from './repo_editor.vue';
eventHub.$on(WEBIDE_MEASURE_FILE_AFTER_INTERACTION, () =>
measurePerformance(
WEBIDE_MARK_FILE_FINISH,
WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
WEBIDE_MARK_FILE_CLICKED,
),
);
const removalAnnouncementHelpPagePath = helpPagePath(
'update/deprecations.md#legacy-web-ide-is-deprecated',
);
export default {
components: {
IdeSidebar,
RepoEditor,
GlButton,
GlLoadingIcon,
ErrorMessage: () => import(/* webpackChunkName: 'ide_runtime' */ './error_message.vue'),
CommitEditorHeader: () =>
import(/* webpackChunkName: 'ide_runtime' */ './commit_sidebar/editor_header.vue'),
RepoTabs: () => import(/* webpackChunkName: 'ide_runtime' */ './repo_tabs.vue'),
IdeStatusBar: () => import(/* webpackChunkName: 'ide_runtime' */ './ide_status_bar.vue'),
FindFile: () =>
import(/* webpackChunkName: 'ide_runtime' */ '~/vue_shared/components/file_finder/index.vue'),
RightPane: () => import(/* webpackChunkName: 'ide_runtime' */ './panes/right.vue'),
NewModal: () => import(/* webpackChunkName: 'ide_runtime' */ './new_dropdown/modal.vue'),
CannotPushCodeAlert,
GlBroadcastMessage,
GlLink,
GlSprintf,
},
data() {
return {
loadDeferred: false,
skipBeforeUnload: false,
};
},
computed: {
...mapState([
'openFiles',
'viewer',
'fileFindVisible',
'emptyStateSvgPath',
'currentProjectId',
'errorMessage',
'loading',
]),
...mapGetters([
'canPushCodeStatus',
'activeFile',
'someUncommittedChanges',
'isCommitModeActive',
'allBlobs',
'emptyRepo',
'currentTree',
'hasCurrentProject',
'editorTheme',
'getUrlForPath',
]),
themeName() {
return window.gon?.user_color_scheme;
},
},
mounted() {
window.onbeforeunload = (e) => this.onBeforeUnload(e);
eventHub.$on('skip-beforeunload', this.handleSkipBeforeUnload);
if (this.themeName)
document.querySelector('.navbar-gitlab')?.classList.add(`theme-${this.themeName}`);
},
destroyed() {
eventHub.$off('skip-beforeunload', this.handleSkipBeforeUnload);
},
beforeCreate() {
performanceMarkAndMeasure({
mark: WEBIDE_MARK_APP_START,
measures: [
{
name: WEBIDE_MEASURE_BEFORE_VUE,
},
],
});
},
methods: {
...mapActions(['toggleFileFinder']),
onBeforeUnload(e = {}) {
if (this.skipBeforeUnload) {
this.skipBeforeUnload = false;
return undefined;
}
const returnValue = __('Are you sure you want to lose unsaved changes?');
if (!this.someUncommittedChanges) return undefined;
Object.assign(e, {
returnValue,
});
return returnValue;
},
handleSkipBeforeUnload() {
this.skipBeforeUnload = true;
},
openFile(file) {
this.$router.push(this.getUrlForPath(file.path));
},
createNewFile() {
this.$refs.newModal.open(modalTypes.blob);
},
loadDeferredComponents() {
this.loadDeferred = true;
},
},
removalAnnouncementHelpPagePath,
};
</script>
<template>
<article
class="ide position-relative flex-column align-items-stretch gl-flex"
:class="{ [`theme-${themeName}`]: themeName }"
>
<gl-broadcast-message icon-name="warning" theme="red" :dismissible="false">
{{ __('The legacy Vue-based GitLab Web IDE will be removed in GitLab 18.0.') }}
<gl-sprintf
:message="
__('To prepare for this removal, see %{linkStart}deprecations and removals%{linkEnd}.')
"
>
<template #link="{ content }">
<gl-link class="!gl-text-inherit" :href="$options.removalAnnouncementHelpPagePath">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</gl-broadcast-message>
<cannot-push-code-alert
v-if="!canPushCodeStatus.isAllowed"
:message="canPushCodeStatus.message"
:action="canPushCodeStatus.action"
/>
<error-message v-if="errorMessage" :message="errorMessage" />
<div class="ide-view gl-flex gl-grow">
<template v-if="loadDeferred">
<find-file
:files="allBlobs"
:visible="fileFindVisible"
:loading="loading"
@toggle="toggleFileFinder"
@click="openFile"
/>
</template>
<ide-sidebar @tree-ready="loadDeferredComponents" />
<div class="multi-file-edit-pane">
<template v-if="activeFile">
<template v-if="loadDeferred">
<commit-editor-header v-if="isCommitModeActive" :active-file="activeFile" />
<repo-tabs v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" />
</template>
<repo-editor :file="activeFile" class="multi-file-edit-pane-content" />
</template>
<template v-else>
<div class="ide-empty-state">
<div class="row js-empty-state">
<div class="col-12">
<div class="svg-content svg-150"><img :src="emptyStateSvgPath" /></div>
</div>
<div class="col-12">
<div class="text-content text-center">
<h4>
{{ __('Make and review changes in the browser with the Web IDE') }}
</h4>
<template v-if="emptyRepo">
<p>
{{
__(
"Create a new file as there are no files yet. Afterwards, you'll be able to commit your changes.",
)
}}
</p>
<gl-button
variant="confirm"
category="primary"
:title="__('New file')"
:aria-label="__('New file')"
@click="createNewFile()"
>
{{ __('New file') }}
</gl-button>
</template>
<gl-loading-icon v-else-if="!currentTree || currentTree.loading" size="lg" />
<p v-else>
{{
__(
"Select a file from the left sidebar to begin editing. Afterwards, you'll be able to commit your changes.",
)
}}
</p>
</div>
</div>
</div>
</div>
</template>
</div>
<template v-if="loadDeferred">
<right-pane v-if="currentProjectId" />
</template>
</div>
<template v-if="loadDeferred">
<ide-status-bar />
<new-modal ref="newModal" />
</template>
</article>
</template>

View File

@ -1,49 +0,0 @@
<script>
/**
* This component is an iterative step towards refactoring and simplifying `vue_shared/components/file_row.vue`
* https://gitlab.com/gitlab-org/gitlab/-/merge_requests/23720
*/
// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import FileRow from '~/vue_shared/components/file_row.vue';
import FileRowExtra from './file_row_extra.vue';
export default {
name: 'IdeFileRow',
components: {
FileRow,
FileRowExtra,
},
props: {
file: {
type: Object,
required: true,
},
},
data() {
return {
dropdownOpen: false,
};
},
computed: {
...mapGetters(['getUrlForPath']),
},
methods: {
toggleDropdown(val) {
this.dropdownOpen = val;
},
},
};
</script>
<template>
<file-row
:file="file"
:file-url="getUrlForPath(file.path)"
v-bind="$attrs"
@mouseleave="toggleDropdown(false)"
v-on="$listeners"
>
<file-row-extra :file="file" :dropdown-open="dropdownOpen" @toggle="toggleDropdown($event)" />
</file-row>
</template>

View File

@ -1,34 +0,0 @@
<script>
import ProjectAvatar from '~/vue_shared/components/project_avatar.vue';
export default {
components: {
ProjectAvatar,
},
props: {
project: {
type: Object,
required: true,
},
},
};
</script>
<template>
<div class="context-header ide-context-header">
<a :href="project.web_url" :title="s__('IDE|Go to project')" data-testid="go-to-project-link">
<project-avatar
:project-id="project.id"
:project-name="project.name"
:project-avatar-url="project.avatar_url"
:size="48"
/>
<span class="ide-sidebar-project-title">
<span class="sidebar-context-title"> {{ project.name }} </span>
<span class="sidebar-context-title gl-font-normal gl-text-subtle">
{{ project.path_with_namespace }}
</span>
</span>
</a>
</div>
</template>

View File

@ -1,78 +0,0 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapGetters, mapState, mapActions } from 'vuex';
import { viewerTypes } from '../constants';
import EditorModeDropdown from './editor_mode_dropdown.vue';
import IdeTreeList from './ide_tree_list.vue';
export default {
components: {
IdeTreeList,
EditorModeDropdown,
},
computed: {
...mapGetters(['currentMergeRequest', 'activeFile', 'getUrlForPath']),
...mapState(['viewer', 'currentMergeRequestId']),
showLatestChangesText() {
return !this.currentMergeRequestId || this.viewer === viewerTypes.diff;
},
showMergeRequestText() {
return this.currentMergeRequestId && this.viewer === viewerTypes.mr;
},
mergeRequestId() {
return `!${this.currentMergeRequest.iid}`;
},
},
mounted() {
this.initialize();
},
activated() {
this.initialize();
},
methods: {
...mapActions(['updateViewer', 'resetOpenFiles']),
initialize() {
if (this.activeFile && this.activeFile.pending && !this.activeFile.deleted) {
this.$router.push(this.getUrlForPath(this.activeFile.path), () => {
this.updateViewer(viewerTypes.edit);
});
} else if (this.activeFile && this.activeFile.deleted) {
this.resetOpenFiles();
}
this.$nextTick(() => {
this.updateViewer(this.currentMergeRequestId ? viewerTypes.mr : viewerTypes.diff);
});
},
},
};
</script>
<template>
<ide-tree-list header-class="ide-review-header">
<template #header>
<div class="ide-review-button-holder">
{{ __('Review') }}
<editor-mode-dropdown
v-if="currentMergeRequest"
:viewer="viewer"
:merge-request-id="currentMergeRequest.iid"
@click="updateViewer"
/>
</div>
<div class="ide-review-sub-header gl-mt-2">
<template v-if="showLatestChangesText">
{{ __('Latest changes') }}
</template>
<template v-else-if="showMergeRequestText">
{{ __('Merge request') }} (<a
v-if="currentMergeRequest"
:href="currentMergeRequest.web_url"
v-text="mergeRequestId"
></a
>)
</template>
</div>
</template>
</ide-tree-list>
</template>

View File

@ -1,61 +0,0 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters } from 'vuex';
import { SIDEBAR_INIT_WIDTH, leftSidebarViews } from '../constants';
import ActivityBar from './activity_bar.vue';
import CommitForm from './commit_sidebar/form.vue';
import IdeProjectHeader from './ide_project_header.vue';
import IdeTree from './ide_tree.vue';
import ResizablePanel from './resizable_panel.vue';
export default {
components: {
GlSkeletonLoader,
ResizablePanel,
ActivityBar,
IdeTree,
[leftSidebarViews.review.name]: () =>
import(/* webpackChunkName: 'ide_runtime' */ './ide_review.vue'),
[leftSidebarViews.commit.name]: () =>
import(/* webpackChunkName: 'ide_runtime' */ './repo_commit_section.vue'),
CommitForm,
IdeProjectHeader,
},
computed: {
...mapState(['loading', 'currentActivityView', 'changedFiles', 'stagedFiles', 'lastCommitMsg']),
...mapGetters(['currentProject', 'someUncommittedChanges']),
},
SIDEBAR_INIT_WIDTH,
};
</script>
<template>
<resizable-panel
:initial-width="$options.SIDEBAR_INIT_WIDTH"
side="left"
class="multi-file-commit-panel flex-column"
>
<template v-if="loading">
<div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner">
<div v-for="n in 3" :key="n" class="multi-file-loading-container">
<gl-skeleton-loader />
</div>
</div>
</template>
<template v-else>
<ide-project-header :project="currentProject" />
<div class="ide-context-body flex-fill gl-flex">
<activity-bar />
<div class="multi-file-commit-panel-inner" data-testid="ide-side-bar-inner">
<div class="multi-file-commit-panel-inner-content">
<keep-alive>
<component :is="currentActivityView" @tree-ready="$emit('tree-ready')" />
</keep-alive>
</div>
<commit-form />
</div>
</div>
</template>
</resizable-panel>
</template>

View File

@ -1,83 +0,0 @@
<script>
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants';
import { SIDE_RIGHT } from '../constants';
import { otherSide } from '../utils';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
},
props: {
tabs: {
type: Array,
required: true,
},
side: {
type: String,
required: true,
},
currentView: {
type: String,
required: false,
default: '',
},
isOpen: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
otherSide() {
return otherSide(this.side);
},
},
methods: {
isActiveTab(tab) {
return this.isOpen && tab.views.some((view) => view.name === this.currentView);
},
buttonClasses(tab) {
return [
{
'is-right': this.side === SIDE_RIGHT,
active: this.isActiveTab(tab),
},
...(tab.buttonClasses || []),
];
},
clickTab(e, tab) {
e.currentTarget.blur();
this.$root.$emit(BV_HIDE_TOOLTIP);
if (this.isActiveTab(tab)) {
this.$emit('close');
} else {
this.$emit('open', tab.views[0]);
}
},
},
};
</script>
<template>
<nav class="ide-activity-bar">
<ul class="list-unstyled">
<li v-for="tab of tabs" :key="tab.title">
<button
v-gl-tooltip="{ container: 'body', placement: otherSide }"
:title="tab.title"
:aria-label="tab.title"
class="ide-sidebar-link"
:class="buttonClasses(tab)"
type="button"
@click="clickTab($event, tab)"
>
<gl-icon :size="16" :name="tab.icon" />
</button>
</li>
</ul>
</nav>
</template>

View File

@ -1,137 +0,0 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import timeAgoMixin from '~/vue_shared/mixins/timeago';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue';
import { rightSidebarViews } from '../constants';
import IdeStatusList from './ide_status_list.vue';
import IdeStatusMr from './ide_status_mr.vue';
export default {
components: {
GlIcon,
UserAvatarImage,
CiIcon,
IdeStatusList,
IdeStatusMr,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeAgoMixin],
data() {
return {
lastCommitFormattedAge: null,
};
},
computed: {
...mapState(['currentBranchId', 'currentProjectId']),
...mapGetters(['currentProject', 'lastCommit', 'currentMergeRequest']),
...mapState('pipelines', ['latestPipeline']),
},
watch: {
lastCommit: {
handler() {
this.initPipelinePolling();
},
immediate: true,
},
},
mounted() {
this.startTimer();
},
beforeDestroy() {
if (this.intervalId) {
clearInterval(this.intervalId);
}
this.stopPipelinePolling();
},
methods: {
...mapActions('rightPane', {
openRightPane: 'open',
}),
...mapActions('pipelines', ['fetchLatestPipeline', 'stopPipelinePolling']),
startTimer() {
this.intervalId = setInterval(() => {
this.commitAgeUpdate();
}, 1000);
},
initPipelinePolling() {
if (this.lastCommit) {
this.fetchLatestPipeline();
}
},
commitAgeUpdate() {
if (this.lastCommit) {
this.lastCommitFormattedAge = this.timeFormatted(this.lastCommit.committed_date);
}
},
getCommitPath(shortSha) {
return `${this.currentProject.web_url}/commit/${shortSha}`;
},
},
rightSidebarViews,
};
</script>
<template>
<footer class="ide-status-bar">
<div v-if="lastCommit" class="ide-status-branch">
<span v-if="latestPipeline && latestPipeline.details" class="ide-status-pipeline">
<button
type="button"
class="p-0 border-0 bg-transparent"
@click="openRightPane($options.rightSidebarViews.pipelines)"
>
<ci-icon
:status="latestPipeline.details.status"
:title="latestPipeline.details.status.text"
/>
</button>
Pipeline
<a :href="latestPipeline.details.status.details_path" class="monospace"
>#{{ latestPipeline.id }}</a
>
{{ latestPipeline.details.status.text }} for
</span>
<gl-icon name="commit" />
<a
v-gl-tooltip
:title="lastCommit.message"
:href="getCommitPath(lastCommit.short_id)"
class="commit-sha"
data-testid="commit-sha-content"
>{{ lastCommit.short_id }}</a
>
by
<user-avatar-image
css-classes="ide-status-avatar"
:size="16"
:img-src="latestPipeline && latestPipeline.commit.author_gravatar_url"
:img-alt="lastCommit.author_name"
:tooltip-text="lastCommit.author_name"
/>
{{ lastCommit.author_name }}
<time
v-gl-tooltip
:datetime="lastCommit.committed_date"
:title="tooltipTitle(lastCommit.committed_date)"
data-placement="top"
data-container="body"
>{{ lastCommitFormattedAge }}</time
>
</div>
<ide-status-mr
v-if="currentMergeRequest"
class="mx-3"
:url="currentMergeRequest.web_url"
:text="currentMergeRequest.references.short"
/>
<ide-status-list class="ml-auto" />
</footer>
</template>

View File

@ -1,45 +0,0 @@
<script>
import { GlLink, GlTooltipDirective } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { isTextFile, getFileEOL } from '~/ide/utils';
import TerminalSyncStatusSafe from './terminal_sync/terminal_sync_status_safe.vue';
export default {
components: {
GlLink,
TerminalSyncStatusSafe,
},
directives: {
GlTooltip: GlTooltipDirective,
},
computed: {
...mapGetters(['activeFile']),
...mapGetters('editor', ['activeFileEditor']),
activeFileEOL() {
return getFileEOL(this.activeFile.content);
},
activeFileIsText() {
return isTextFile(this.activeFile);
},
},
};
</script>
<template>
<div class="ide-status-list gl-flex">
<template v-if="activeFile">
<div>
<gl-link v-gl-tooltip.hover :href="activeFile.permalink" :title="__('Open in file view')">
{{ activeFile.name }}
</gl-link>
</div>
<div>{{ activeFileEOL }}</div>
<div v-if="activeFileIsText">
{{ activeFileEditor.editorRow }}:{{ activeFileEditor.editorColumn }}
</div>
<div>{{ activeFileEditor.fileLanguage }}</div>
</template>
<terminal-sync-status-safe />
</div>
</template>

View File

@ -1,30 +0,0 @@
<script>
import { GlIcon, GlLink } from '@gitlab/ui';
export default {
components: {
GlIcon,
GlLink,
},
props: {
text: {
type: String,
required: true,
},
url: {
type: String,
required: true,
},
},
};
</script>
<template>
<div
class="text-nowrap js-ide-status-mr gl-flex gl-flex-nowrap gl-items-center gl-justify-center"
>
<gl-icon name="merge-request" />
<span class="ml-1 gl-hidden sm:gl-block">{{ s__('WebIDE|Merge request') }}</span>
<gl-link class="ml-1" :href="url">{{ text }}</gl-link>
</div>
</template>

View File

@ -1,83 +0,0 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import { modalTypes, viewerTypes } from '../constants';
import IdeTreeList from './ide_tree_list.vue';
import NewEntryButton from './new_dropdown/button.vue';
import NewModal from './new_dropdown/modal.vue';
import Upload from './new_dropdown/upload.vue';
export default {
components: {
Upload,
IdeTreeList,
NewEntryButton,
NewModal,
},
computed: {
...mapState(['currentBranchId']),
...mapGetters(['currentProject', 'currentTree', 'activeFile', 'getUrlForPath']),
},
mounted() {
this.initialize();
},
activated() {
this.initialize();
},
methods: {
...mapActions(['updateViewer', 'createTempEntry', 'resetOpenFiles']),
createNewFile() {
this.$refs.newModal.open(modalTypes.blob);
},
createNewFolder() {
this.$refs.newModal.open(modalTypes.tree);
},
initialize() {
this.$nextTick(() => {
this.updateViewer(viewerTypes.edit);
});
if (!this.activeFile) return;
if (this.activeFile.pending && !this.activeFile.deleted) {
this.$router.push(this.getUrlForPath(this.activeFile.path), () => {
this.updateViewer(viewerTypes.edit);
});
} else if (this.activeFile.deleted) {
this.resetOpenFiles();
}
},
},
};
</script>
<template>
<ide-tree-list @tree-ready="$emit('tree-ready')">
<template #header>
{{ __('Edit') }}
<div class="ide-tree-actions gl-ml-auto gl-flex" data-testid="ide-root-actions">
<new-entry-button
:label="__('New file')"
:show-label="false"
class="gl-mr-5 gl-flex gl-border-0 gl-p-0"
icon="doc-new"
@click="createNewFile()"
/>
<upload
:show-label="false"
class="gl-mr-5 gl-flex"
button-css-classes="gl-border-0 gl-p-0"
@create="createTempEntry"
/>
<new-entry-button
:label="__('New directory')"
:show-label="false"
class="gl-flex gl-border-0 gl-p-0"
icon="folder-new"
@click="createNewFolder()"
/>
</div>
<new-modal ref="newModal" />
</template>
</ide-tree-list>
</template>

View File

@ -1,83 +0,0 @@
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { WEBIDE_MARK_FILE_CLICKED } from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import FileTree from '~/vue_shared/components/file_tree.vue';
import IdeFileRow from './ide_file_row.vue';
import NavDropdown from './nav_dropdown.vue';
export default {
name: 'IdeTreeList',
components: {
GlSkeletonLoader,
NavDropdown,
FileTree,
},
props: {
headerClass: {
type: String,
required: false,
default: null,
},
},
computed: {
...mapState(['currentBranchId']),
...mapGetters(['currentProject', 'currentTree']),
showLoading() {
return !this.currentTree || this.currentTree.loading;
},
},
watch: {
showLoading() {
this.notifyTreeReady();
},
},
mounted() {
this.notifyTreeReady();
},
methods: {
...mapActions(['toggleTreeOpen']),
notifyTreeReady() {
if (!this.showLoading) {
this.$emit('tree-ready');
}
},
clickedFile() {
performanceMarkAndMeasure({ mark: WEBIDE_MARK_FILE_CLICKED });
},
},
IdeFileRow,
};
</script>
<template>
<div class="ide-file-list">
<template v-if="showLoading">
<div v-for="n in 3" :key="n" class="multi-file-loading-container">
<gl-skeleton-loader />
</div>
</template>
<template v-else>
<header :class="headerClass" class="ide-tree-header">
<nav-dropdown />
<slot name="header"></slot>
</header>
<div class="ide-tree-body gl-h-full" data-testid="ide-tree-body">
<template v-if="currentTree.tree.length">
<file-tree
v-for="file in currentTree.tree"
:key="file.key"
:file="file"
:level="0"
:file-row-component="$options.IdeFileRow"
@toggleTreeOpen="toggleTreeOpen"
@clickFile="clickedFile"
/>
</template>
<div v-else class="file-row">{{ __('No files') }}</div>
</div>
</template>
</div>
</template>

View File

@ -1,121 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlTooltipDirective, GlButton, GlIcon } from '@gitlab/ui';
import { throttle } from 'lodash';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __ } from '~/locale';
import JobDescription from './detail/description.vue';
import ScrollButton from './detail/scroll_button.vue';
const scrollPositions = {
top: 0,
bottom: 1,
};
export default {
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml,
},
components: {
GlButton,
GlIcon,
ScrollButton,
JobDescription,
},
data() {
return {
scrollPos: scrollPositions.top,
};
},
computed: {
...mapState('pipelines', ['detailJob']),
isScrolledToBottom() {
return this.scrollPos === scrollPositions.bottom;
},
isScrolledToTop() {
return this.scrollPos === scrollPositions.top;
},
jobOutput() {
return this.detailJob.output || __('No messages were logged');
},
},
mounted() {
this.getLogs();
},
methods: {
...mapActions('pipelines', ['fetchJobLogs', 'setDetailJob']),
scrollDown() {
if (this.$refs.buildJobLog) {
this.$refs.buildJobLog.scrollTo(0, this.$refs.buildJobLog.scrollHeight);
}
},
scrollUp() {
if (this.$refs.buildJobLog) {
this.$refs.buildJobLog.scrollTo(0, 0);
}
},
scrollBuildLog: throttle(function buildLogScrollDebounce() {
const { scrollTop } = this.$refs.buildJobLog;
const { offsetHeight, scrollHeight } = this.$refs.buildJobLog;
if (scrollTop + offsetHeight === scrollHeight) {
this.scrollPos = scrollPositions.bottom;
} else if (scrollTop === 0) {
this.scrollPos = scrollPositions.top;
} else {
this.scrollPos = '';
}
}),
getLogs() {
return this.fetchJobLogs().then(() => this.scrollDown());
},
},
};
</script>
<template>
<div class="ide-pipeline build-page flex-column flex-fill gl-flex">
<header class="ide-job-header gl-flex gl-items-center">
<gl-button category="secondary" icon="chevron-left" size="small" @click="setDetailJob(null)">
{{ __('View jobs') }}
</gl-button>
</header>
<div class="top-bar border-left-0 mr-3 gl-flex">
<job-description :job="detailJob" />
<div class="controllers ml-auto">
<a
v-gl-tooltip
:title="__('Show complete raw log')"
:href="detailJob.rawPath"
data-placement="top"
data-container="body"
class="controllers-buttons"
target="_blank"
>
<gl-icon name="doc-text" />
</a>
<scroll-button :disabled="isScrolledToTop" direction="up" @click="scrollUp" />
<scroll-button :disabled="isScrolledToBottom" direction="down" @click="scrollDown" />
</div>
</div>
<pre ref="buildJobLog" class="build-log mb-0 mr-3 gl-h-full" @scroll="scrollBuildLog">
<code
v-show="!detailJob.isLoading"
v-safe-html="jobOutput"
class="bash"
>
</code>
<div
v-show="detailJob.isLoading"
class="build-loader-animation"
>
<div class="dot"></div>
<div class="dot"></div>
<div class="dot"></div>
</div>
</pre>
</div>
</template>

View File

@ -1,41 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon } from '@gitlab/ui';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
export default {
components: {
GlIcon,
CiIcon,
},
props: {
job: {
type: Object,
required: true,
},
},
computed: {
jobId() {
return `#${this.job.id}`;
},
},
};
</script>
<template>
<div class="gl-flex gl-items-center">
<ci-icon :status="job.status" class="gl-border gl-z-1 gl-inline-flex gl-items-center" />
<span class="gl-ml-3">
{{ job.name }}
<a
v-if="job.path"
:href="job.path"
target="_blank"
class="ide-external-link gl-relative"
data-testid="description-detail-link"
>
{{ jobId }} <gl-icon :size="12" name="external-link" />
</a>
</span>
</div>
</template>

View File

@ -1,65 +0,0 @@
<script>
import { GlIcon, GlTooltipDirective, GlButton } from '@gitlab/ui';
import { __ } from '~/locale';
const directions = {
up: 'up',
down: 'down',
};
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlButton,
GlIcon,
},
props: {
direction: {
type: String,
required: true,
validator(value) {
return Object.keys(directions).includes(value);
},
},
disabled: {
type: Boolean,
required: true,
},
},
computed: {
tooltipTitle() {
return this.direction === directions.up ? __('Scroll to top') : __('Scroll to bottom');
},
iconName() {
return `scroll_${this.direction}`;
},
},
methods: {
clickedScroll() {
this.$emit('click');
},
},
};
</script>
<template>
<div
v-gl-tooltip
:title="tooltipTitle"
class="controllers-buttons"
data-container="body"
data-placement="top"
>
<gl-button
:disabled="disabled"
class="!gl-m-0 gl-block !gl-min-w-0 gl-rounded-none !gl-border-0 !gl-border-none gl-bg-transparent !gl-p-0 !gl-shadow-none !gl-outline-none"
type="button"
:aria-label="tooltipTitle"
@click="clickedScroll"
>
<gl-icon :name="iconName" />
</gl-button>
</div>
</template>

View File

@ -1,39 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton } from '@gitlab/ui';
import JobDescription from './detail/description.vue';
export default {
components: {
JobDescription,
GlButton,
},
props: {
job: {
type: Object,
required: true,
},
},
computed: {
jobId() {
return `#${this.job.id}`;
},
},
methods: {
clickViewLog() {
this.$emit('clickViewLog', this.job);
},
},
};
</script>
<template>
<div class="ide-job-item">
<job-description :job="job" class="gl-mr-3" />
<div class="ml-auto align-self-center">
<gl-button v-if="job.started" category="secondary" size="small" @click="clickViewLog">
{{ __('View log') }}
</gl-button>
</div>
</div>
</template>

View File

@ -1,43 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import Stage from './stage.vue';
export default {
components: {
Stage,
GlLoadingIcon,
},
props: {
stages: {
type: Array,
required: true,
},
loading: {
type: Boolean,
required: true,
},
},
methods: {
...mapActions('pipelines', ['fetchJobs', 'toggleStageCollapsed', 'setDetailJob']),
},
};
</script>
<template>
<div>
<gl-loading-icon v-if="loading && !stages.length" size="lg" class="gl-mt-3" />
<template v-else>
<stage
v-for="stage in stages"
:key="stage.id"
:stage="stage"
@fetch="fetchJobs"
@toggleCollapsed="toggleStageCollapsed"
@clickViewLog="setDetailJob"
/>
</template>
</div>
</template>

View File

@ -1,93 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon, GlIcon, GlTooltipDirective, GlBadge } from '@gitlab/ui';
import { __ } from '~/locale';
import Item from './item.vue';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
GlBadge,
Item,
GlLoadingIcon,
},
props: {
stage: {
type: Object,
required: true,
},
},
data() {
return {
showTooltip: false,
};
},
computed: {
collapseIcon() {
return this.stage.isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up';
},
showLoadingIcon() {
return this.stage.isLoading && !this.stage.jobs.length;
},
stageTitle() {
const prefix = __('Stage');
return `${prefix}: ${this.stage.name}`;
},
jobsCount() {
return this.stage.jobs.length;
},
},
mounted() {
const { stageTitle } = this.$refs;
this.showTooltip = stageTitle.scrollWidth > stageTitle.offsetWidth;
this.$emit('fetch', this.stage);
},
methods: {
toggleCollapsed() {
this.$emit('toggleCollapsed', this.stage.id);
},
clickViewLog(job) {
this.$emit('clickViewLog', job);
},
},
};
</script>
<template>
<div class="ide-stage card gl-mt-3">
<div
:class="{
'border-bottom-0': stage.isCollapsed,
}"
class="card-header gl-flex gl-cursor-pointer gl-items-center"
data-testid="card-header"
@click="toggleCollapsed"
>
<strong
ref="stageTitle"
v-gl-tooltip="showTooltip"
:title="showTooltip ? stage.name : null"
data-container="body"
class="gl-truncate"
data-testid="stage-title"
>
{{ stageTitle }}
</strong>
<div v-if="!stage.isLoading || stage.jobs.length" class="gl-ml-2 gl-mr-3">
<gl-badge>{{ jobsCount }}</gl-badge>
</div>
<gl-icon :name="collapseIcon" class="gl-absolute gl-right-5" />
</div>
<div v-show="!stage.isCollapsed" class="card-body p-0" data-testid="job-list">
<gl-loading-icon v-if="showLoadingIcon" size="sm" />
<template v-else>
<item v-for="job in stage.jobs" :key="job.id" :job="job" @clickViewLog="clickViewLog" />
</template>
</div>
</div>
</template>

View File

@ -1,51 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon, GlButton } from '@gitlab/ui';
export default {
components: {
GlIcon,
GlButton,
},
props: {
item: {
type: Object,
required: true,
},
currentId: {
type: String,
required: true,
},
currentProjectId: {
type: String,
required: true,
},
},
computed: {
isActive() {
return (
this.item.iid === parseInt(this.currentId, 10) &&
this.currentProjectId === this.item.projectPathWithNamespace
);
},
pathWithID() {
return `${this.item.projectPathWithNamespace}!${this.item.iid}`;
},
mergeRequestHref() {
const path = `/project/${this.item.projectPathWithNamespace}/merge_requests/${this.item.iid}`;
return this.$router.resolve(path).href;
},
},
};
</script>
<template>
<gl-button variant="link" :href="mergeRequestHref">
<span class="gl-inline-block gl-whitespace-normal">
<gl-icon v-if="isActive" :size="16" name="mobile-issue-close" class="gl-mr-3" />
<strong> {{ item.title }} </strong>
<span class="ide-merge-request-project-path mt-1 gl-block"> {{ pathWithID }} </span>
</span>
</gl-button>
</template>

View File

@ -1,126 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon, GlIcon, GlButton } from '@gitlab/ui';
import { debounce } from 'lodash';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import TokenedInput from '../shared/tokened_input.vue';
import Item from './item.vue';
const SEARCH_TYPES = [
{ type: 'created', label: __('Created by me') },
{ type: 'assigned', label: __('Assigned to me') },
];
export default {
components: {
TokenedInput,
Item,
GlIcon,
GlLoadingIcon,
GlButton,
},
data() {
return {
search: '',
currentSearchType: null,
hasSearchFocus: false,
};
},
computed: {
...mapState('mergeRequests', ['mergeRequests', 'isLoading']),
...mapState(['currentMergeRequestId', 'currentProjectId']),
hasMergeRequests() {
return this.mergeRequests.length !== 0;
},
hasNoSearchResults() {
return this.search !== '' && !this.hasMergeRequests;
},
showSearchTypes() {
return this.hasSearchFocus && !this.search && !this.currentSearchType;
},
type() {
return this.currentSearchType ? this.currentSearchType.type : '';
},
searchTokens() {
return this.currentSearchType ? [this.currentSearchType] : [];
},
},
watch: {
search() {
// When the search is updated, let's turn off this flag to hide the search types
this.hasSearchFocus = false;
},
},
mounted() {
this.loadMergeRequests();
},
methods: {
...mapActions('mergeRequests', ['fetchMergeRequests']),
loadMergeRequests() {
this.fetchMergeRequests({ type: this.type, search: this.search });
},
searchMergeRequests: debounce(function debounceSearch() {
this.loadMergeRequests();
}, 250),
onSearchFocus() {
this.hasSearchFocus = true;
},
setSearchType(searchType) {
this.currentSearchType = searchType;
this.loadMergeRequests();
},
},
searchTypes: SEARCH_TYPES,
};
</script>
<template>
<div>
<label
class="dropdown-input gl-mb-0 gl-block gl-border-b-1 gl-pb-5 gl-pt-3 gl-border-b-solid"
@click.stop
>
<tokened-input
v-model="search"
:tokens="searchTokens"
:placeholder="__('Search merge requests')"
@focus="onSearchFocus"
@input="searchMergeRequests"
@removeToken="setSearchType(null)"
/>
<gl-icon :size="16" name="search" class="ml-3 input-icon" />
</label>
<div class="dropdown-content ide-merge-requests-dropdown-content gl-flex">
<gl-loading-icon
v-if="isLoading"
size="lg"
class="mt-3 mb-3 align-self-center ml-auto mr-auto"
/>
<template v-else>
<ul class="mb-0 gl-w-full">
<template v-if="showSearchTypes">
<li v-for="searchType in $options.searchTypes" :key="searchType.type">
<gl-button variant="link" icon="search" @click="setSearchType(searchType)">
{{ searchType.label }}
</gl-button>
</li>
</template>
<template v-else-if="hasMergeRequests">
<li v-for="item in mergeRequests" :key="item.id">
<item
:item="item"
:current-id="currentMergeRequestId"
:current-project-id="currentProjectId"
/>
</li>
</template>
<li v-else class="ide-search-list-empty justify-content-center gl-flex gl-items-center">
{{ __('No merge requests found') }}
</li>
</ul>
</template>
</div>
</div>
</template>

View File

@ -1,22 +0,0 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
};
</script>
<template>
<gl-icon
v-gl-tooltip
:title="__('Part of merge request changes')"
:size="12"
name="merge-request"
class="gl-mr-3"
/>
</template>

View File

@ -1,54 +0,0 @@
<script>
import $ from 'jquery';
// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import NavDropdownButton from './nav_dropdown_button.vue';
import NavForm from './nav_form.vue';
export default {
components: {
NavDropdownButton,
NavForm,
},
data() {
return {
isVisibleDropdown: false,
};
},
computed: {
...mapGetters(['canReadMergeRequests']),
},
mounted() {
this.addDropdownListeners();
},
beforeDestroy() {
this.removeDropdownListeners();
},
methods: {
addDropdownListeners() {
$(this.$refs.dropdown)
.on('show.bs.dropdown', () => this.showDropdown())
.on('hide.bs.dropdown', () => this.hideDropdown());
},
removeDropdownListeners() {
// eslint-disable-next-line @gitlab/no-global-event-off
$(this.$refs.dropdown).off('show.bs.dropdown').off('hide.bs.dropdown');
},
showDropdown() {
this.isVisibleDropdown = true;
},
hideDropdown() {
this.isVisibleDropdown = false;
},
},
};
</script>
<template>
<div ref="dropdown" class="btn-group ide-nav-dropdown dropdown" data-testid="ide-nav-dropdown">
<nav-dropdown-button :show-merge-requests="canReadMergeRequests" />
<div class="dropdown-menu dropdown-menu-left p-0">
<nav-form v-if="isVisibleDropdown" :show-merge-requests="canReadMergeRequests" />
</div>
</div>
</template>

View File

@ -1,56 +0,0 @@
<script>
import { GlIcon } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue';
const EMPTY_LABEL = '-';
export default {
components: {
GlIcon,
DropdownButton,
},
props: {
showMergeRequests: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
...mapState(['currentBranchId', 'currentMergeRequestId']),
mergeRequestLabel() {
return this.currentMergeRequestId ? `!${this.currentMergeRequestId}` : EMPTY_LABEL;
},
branchLabel() {
return this.currentBranchId || EMPTY_LABEL;
},
},
};
</script>
<template>
<dropdown-button class="!gl-w-full">
<span class="row gl-flex-nowrap">
<span class="col-auto flex-fill text-truncate">
<gl-icon
data-testid="branch-icon"
:size="16"
:aria-label="__('Current Branch')"
name="branch"
/>
{{ branchLabel }}
</span>
<span v-if="showMergeRequests" class="col-auto pl-0 text-truncate">
<gl-icon
data-testid="merge-request-icon"
:size="16"
:aria-label="__('Merge request')"
name="merge-request"
/>
{{ mergeRequestLabel }}
</span>
</span>
</dropdown-button>
</template>

View File

@ -1,35 +0,0 @@
<script>
import { GlTab, GlTabs } from '@gitlab/ui';
import BranchesSearchList from './branches/search_list.vue';
import MergeRequestSearchList from './merge_requests/list.vue';
export default {
components: {
GlTab,
GlTabs,
BranchesSearchList,
MergeRequestSearchList,
},
props: {
showMergeRequests: {
type: Boolean,
required: false,
default: true,
},
},
};
</script>
<template>
<div class="ide-nav-form p-0">
<gl-tabs v-if="showMergeRequests">
<gl-tab :title="__('Branches')">
<branches-search-list />
</gl-tab>
<gl-tab :title="__('Merge requests')">
<merge-request-search-list />
</gl-tab>
</gl-tabs>
<branches-search-list v-else />
</div>
</template>

View File

@ -1,60 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
export default {
directives: {
GlTooltip: GlTooltipDirective,
},
components: {
GlIcon,
},
props: {
label: {
type: String,
required: false,
default: null,
},
icon: {
type: String,
required: true,
},
iconClasses: {
type: String,
required: false,
default: null,
},
showLabel: {
type: Boolean,
required: false,
default: true,
},
},
computed: {
tooltipTitle() {
return this.showLabel ? '' : this.label;
},
},
methods: {
clicked() {
this.$emit('click');
},
},
};
</script>
<template>
<button
v-gl-tooltip
:aria-label="label"
:title="tooltipTitle"
type="button"
class="gl-rounded-none gl-border-none !gl-bg-transparent gl-p-0 !gl-shadow-none !gl-outline-none"
@click.stop.prevent="clicked"
>
<gl-icon :name="icon" :class="iconClasses" />
<template v-if="showLabel">
{{ label }}
</template>
</button>
</template>

View File

@ -1,118 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlIcon } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { modalTypes } from '../../constants';
import ItemButton from './button.vue';
import NewModal from './modal.vue';
import Upload from './upload.vue';
export default {
components: {
GlIcon,
Upload,
ItemButton,
NewModal,
},
props: {
type: {
type: String,
required: true,
},
path: {
type: String,
required: false,
default: '',
},
isOpen: {
type: Boolean,
required: false,
default: false,
},
},
watch: {
isOpen() {
this.$nextTick(() => {
this.$refs.dropdownMenu.scrollIntoView({
block: 'nearest',
});
});
},
},
methods: {
...mapActions(['createTempEntry', 'deleteEntry']),
createNewItem(type) {
this.$refs.newModal.open(type, this.path);
this.$emit('toggle', false);
},
openDropdown() {
this.$emit('toggle', !this.isOpen);
},
},
modalTypes,
};
</script>
<template>
<div class="ide-new-btn">
<div
:class="{
show: isOpen,
}"
class="dropdown gl-flex"
>
<button
:aria-label="__('Create new file or directory')"
type="button"
class="rounded border-0 ide-entry-dropdown-toggle gl-flex"
@click.stop="openDropdown()"
>
<gl-icon name="ellipsis_v" />
</button>
<ul ref="dropdownMenu" class="dropdown-menu dropdown-menu-right" data-testid="dropdown-menu">
<template v-if="type === 'tree'">
<li>
<item-button
:label="__('New file')"
class="gl-flex"
icon="doc-new"
icon-classes="mr-2"
@click="createNewItem('blob')"
/>
</li>
<upload :path="path" @create="createTempEntry" />
<li>
<item-button
:label="__('New directory')"
class="gl-flex"
icon="folder-new"
icon-classes="mr-2"
@click="createNewItem($options.modalTypes.tree)"
/>
</li>
<li class="divider"></li>
</template>
<li>
<item-button
:label="__('Rename/Move')"
class="gl-flex"
icon="pencil"
icon-classes="mr-2"
@click="createNewItem($options.modalTypes.rename)"
/>
</li>
<li>
<item-button
:label="__('Delete')"
class="gl-flex"
icon="remove"
icon-classes="mr-2"
@click="deleteEntry(path)"
/>
</li>
</ul>
</div>
<new-modal ref="newModal" />
</div>
</template>

View File

@ -1,196 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlModal, GlButton } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState, mapGetters } from 'vuex';
import { createAlert } from '~/alert';
import { __, sprintf } from '~/locale';
import { modalTypes } from '../../constants';
import { trimPathComponents, getPathParent } from '../../utils';
const i18n = {
cancelButtonText: __('Cancel'),
};
export default {
components: {
GlModal,
GlButton,
},
data() {
return {
entryName: '',
modalType: modalTypes.blob,
path: '',
};
},
computed: {
...mapState(['entries']),
...mapGetters('fileTemplates', ['templateTypes']),
modalTitle() {
const entry = this.entries[this.path];
if (this.modalType === modalTypes.tree) {
return __('Create new directory');
}
if (this.modalType === modalTypes.rename) {
return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
}
return __('Create new file');
},
buttonLabel() {
const entry = this.entries[this.path];
if (this.modalType === modalTypes.tree) {
return __('Create directory');
}
if (this.modalType === modalTypes.rename) {
return entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file');
}
return __('Create file');
},
actionPrimary() {
return {
text: this.buttonLabel,
attributes: { variant: 'confirm' },
};
},
actionCancel() {
return {
text: i18n.cancelButtonText,
attributes: { variant: 'default' },
};
},
isCreatingNewFile() {
return this.modalType === modalTypes.blob;
},
placeholder() {
return this.isCreatingNewFile ? 'dir/file_name' : 'dir/';
},
},
methods: {
...mapActions(['createTempEntry', 'renameEntry']),
submitAndClose() {
this.submitForm();
this.close();
},
submitForm() {
this.entryName = trimPathComponents(this.entryName);
if (this.modalType === modalTypes.rename) {
if (this.entries[this.entryName] && !this.entries[this.entryName].deleted) {
createAlert({
message: sprintf(__('The name "%{name}" is already taken in this directory.'), {
name: this.entryName,
}),
fadeTransition: false,
addBodyClass: true,
});
} else {
let parentPath = this.entryName.split('/');
const name = parentPath.pop();
parentPath = parentPath.join('/');
this.renameEntry({
path: this.path,
name,
parentPath,
});
}
} else {
this.createTempEntry({
name: this.entryName,
type: this.modalType,
});
}
},
createFromTemplate(template) {
const parent = getPathParent(this.entryName);
const name = parent ? `${parent}/${template.name}` : template.name;
this.createTempEntry({
name,
type: this.modalType,
});
this.$refs.modal.toggle();
},
focusInput() {
const name = this.entries[this.entryName]?.name;
const inputValue = this.$refs.fieldName.value;
this.$refs.fieldName.focus();
if (name) {
this.$refs.fieldName.setSelectionRange(inputValue.indexOf(name), inputValue.length);
}
},
resetData() {
this.entryName = '';
this.path = '';
this.modalType = modalTypes.blob;
},
open(type = modalTypes.blob, path = '') {
this.modalType = type;
this.path = path;
if (this.modalType === modalTypes.rename) {
this.entryName = path;
} else {
this.entryName = path ? `${path}/` : '';
}
this.$refs.modal.show();
// wait for modal to show first
this.$nextTick(() => this.focusInput());
},
close() {
this.$refs.modal.hide();
},
},
};
</script>
<template>
<gl-modal
ref="modal"
modal-id="ide-new-entry"
data-testid="ide-new-entry"
:title="modalTitle"
size="lg"
:action-primary="actionPrimary"
:action-cancel="actionCancel"
@primary="submitForm"
@cancel="resetData"
>
<div class="form-group row">
<label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label>
<div class="col-sm-10">
<form data-testid="file-name-form" @submit.prevent="submitAndClose">
<input
ref="fieldName"
v-model.trim="entryName"
type="text"
class="form-control"
data-testid="file-name-field"
:placeholder="placeholder"
/>
</form>
<ul v-if="isCreatingNewFile" class="file-templates list-inline gl-mt-3">
<li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item">
<gl-button
variant="dashed"
category="secondary"
class="p-1 pr-2 pl-2"
@click="createFromTemplate(template)"
>
{{ template.name }}
</gl-button>
</li>
</ul>
</div>
</div>
</gl-modal>
</template>

View File

@ -1,88 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { isTextFile } from '~/ide/utils';
import ItemButton from './button.vue';
export default {
components: {
ItemButton,
},
props: {
path: {
type: String,
required: false,
default: '',
},
showLabel: {
type: Boolean,
required: false,
default: true,
},
buttonCssClasses: {
type: String,
required: false,
default: null,
},
},
methods: {
createFile(target, file) {
const { name, type: mimeType } = file;
const encodedContent = target.result.split('base64,')[1];
const rawContent = encodedContent ? atob(encodedContent) : '';
const isText = isTextFile({ content: rawContent, mimeType, name });
const emitCreateEvent = (content) =>
this.$emit('create', {
name: `${this.path ? `${this.path}/` : ''}${name}`,
type: 'blob',
content,
rawPath: !isText ? URL.createObjectURL(file) : '',
mimeType,
});
if (isText) {
const reader = new FileReader();
reader.addEventListener('load', (e) => emitCreateEvent(e.target.result), { once: true });
reader.readAsText(file);
} else {
emitCreateEvent(rawContent);
}
},
readFile(file) {
const reader = new FileReader();
reader.addEventListener('load', (e) => this.createFile(e.target, file), { once: true });
reader.readAsDataURL(file);
},
openFile() {
Array.from(this.$refs.fileUpload.files).forEach((file) => this.readFile(file));
},
startFileUpload() {
this.$refs.fileUpload.click();
},
},
};
</script>
<template>
<li>
<item-button
:class="buttonCssClasses"
:show-label="showLabel"
:icon-classes="showLabel ? 'mr-2' : ''"
:label="__('Upload file')"
class="gl-flex"
icon="upload"
@click="startFileUpload"
/>
<input
id="file-upload"
ref="fileUpload"
type="file"
class="hidden"
multiple
@change="openFile"
/>
</li>
</template>

View File

@ -1,101 +0,0 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import IdeSidebarNav from '../ide_sidebar_nav.vue';
export default {
name: 'CollapsibleSidebar',
components: {
IdeSidebarNav,
},
props: {
extensionTabs: {
type: Array,
required: false,
default: () => [],
},
initOpenView: {
type: String,
required: false,
default: '',
},
side: {
type: String,
required: true,
},
},
computed: {
...mapState({
isOpen(state) {
return state[this.namespace].isOpen;
},
currentView(state) {
return state[this.namespace].currentView;
},
isAliveView(_state, getters) {
return getters[`${this.namespace}/isAliveView`];
},
}),
namespace() {
// eslint-disable-next-line @gitlab/require-i18n-strings
return `${this.side}Pane`;
},
tabs() {
return this.extensionTabs.filter((tab) => tab.show);
},
tabViews() {
return this.tabs.map((tab) => tab.views).flat();
},
aliveTabViews() {
return this.tabViews.filter((view) => this.isAliveView(view.name));
},
},
created() {
this.openViewByName(this.initOpenView);
},
methods: {
...mapActions({
toggleOpen(dispatch) {
return dispatch(`${this.namespace}/toggleOpen`);
},
open(dispatch, view) {
return dispatch(`${this.namespace}/open`, view);
},
}),
openViewByName(viewName) {
const view = viewName && this.tabViews.find((x) => x.name === viewName);
if (view) {
this.open(view);
}
},
},
};
</script>
<template>
<div :class="`ide-${side}-sidebar`" class="multi-file-commit-panel ide-sidebar">
<div
v-show="isOpen"
:class="`ide-${side}-sidebar-${currentView}`"
class="multi-file-commit-panel-inner"
>
<div
v-for="tabView in aliveTabViews"
v-show="tabView.name === currentView"
:key="tabView.name"
class="flex-fill js-tab-view gl-h-full gl-overflow-hidden"
>
<component :is="tabView.component" />
</div>
</div>
<ide-sidebar-nav
:tabs="tabs"
:side="side"
:current-view="currentView"
:is-open="isOpen"
@open="open"
@close="toggleOpen"
/>
</div>
</template>

View File

@ -1,59 +0,0 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { __ } from '~/locale';
import { rightSidebarViews, SIDEBAR_INIT_WIDTH, SIDEBAR_NAV_WIDTH } from '../../constants';
import JobsDetail from '../jobs/detail.vue';
import PipelinesList from '../pipelines/list.vue';
import ResizablePanel from '../resizable_panel.vue';
import TerminalView from '../terminal/view.vue';
import CollapsibleSidebar from './collapsible_sidebar.vue';
// Need to add the width of the nav buttons since the resizable container contains those as well
const WIDTH = SIDEBAR_INIT_WIDTH + SIDEBAR_NAV_WIDTH;
export default {
name: 'RightPane',
components: {
CollapsibleSidebar,
ResizablePanel,
},
computed: {
...mapState('terminal', { isTerminalVisible: 'isVisible' }),
...mapState(['currentMergeRequestId']),
...mapState('rightPane', ['isOpen']),
rightExtensionTabs() {
return [
{
show: true,
title: __('Pipelines'),
views: [
{ component: PipelinesList, ...rightSidebarViews.pipelines },
{ component: JobsDetail, ...rightSidebarViews.jobsDetail },
],
icon: 'rocket',
},
{
show: this.isTerminalVisible,
title: __('Terminal'),
views: [{ component: TerminalView, ...rightSidebarViews.terminal }],
icon: 'terminal',
},
];
},
},
WIDTH,
};
</script>
<template>
<resizable-panel
class="gl-flex gl-overflow-hidden"
side="right"
:initial-width="$options.WIDTH"
:min-size="$options.WIDTH"
:resizable="isOpen"
>
<collapsible-sidebar class="gl-w-full" :extension-tabs="rightExtensionTabs" side="right" />
</resizable-panel>
</template>

View File

@ -1,37 +0,0 @@
<script>
import { GlEmptyState } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { s__ } from '~/locale';
import { helpPagePath } from '~/helpers/help_page_helper';
export default {
components: {
GlEmptyState,
},
computed: {
...mapState(['pipelinesEmptyStateSvgPath']),
ciHelpPagePath() {
return helpPagePath('ci/quick_start/_index.md');
},
},
i18n: {
title: s__('Pipelines|Build with confidence'),
description: s__(`Pipelines|GitLab CI/CD can automatically build,
test, and deploy your code. Let GitLab take care of time
consuming tasks, so you can spend more time creating.`),
primaryButtonText: s__('Pipelines|Get started with GitLab CI/CD'),
},
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.title"
:svg-path="pipelinesEmptyStateSvgPath"
:svg-height="150"
:description="$options.i18n.description"
:primary-button-text="$options.i18n.primaryButtonText"
:primary-button-link="ciHelpPagePath"
/>
</template>

View File

@ -1,103 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon, GlIcon, GlTabs, GlTab, GlBadge, GlAlert } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import SafeHtml from '~/vue_shared/directives/safe_html';
import CiIcon from '~/vue_shared/components/ci_icon/ci_icon.vue';
import JobsList from '../jobs/list.vue';
import EmptyState from './empty_state.vue';
const CLASSES_FLEX_VERTICAL_CENTER = ['gl-h-full', 'gl-flex', 'gl-flex-col', 'gl-justify-center'];
export default {
components: {
GlIcon,
CiIcon,
JobsList,
EmptyState,
GlLoadingIcon,
GlTabs,
GlTab,
GlBadge,
GlAlert,
},
directives: {
SafeHtml,
},
computed: {
...mapGetters(['currentProject']),
...mapGetters('pipelines', ['jobsCount', 'failedJobsCount', 'failedStages', 'pipelineFailed']),
...mapState('pipelines', [
'isLoadingPipeline',
'hasLoadedPipeline',
'latestPipeline',
'stages',
'isLoadingJobs',
]),
showLoadingIcon() {
return this.isLoadingPipeline && !this.hasLoadedPipeline;
},
},
created() {
this.fetchLatestPipeline();
},
methods: {
...mapActions('pipelines', ['fetchLatestPipeline']),
},
CLASSES_FLEX_VERTICAL_CENTER,
};
</script>
<template>
<div class="ide-pipeline">
<div v-if="showLoadingIcon" :class="$options.CLASSES_FLEX_VERTICAL_CENTER">
<gl-loading-icon size="lg" />
</div>
<template v-else-if="hasLoadedPipeline">
<header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header">
<ci-icon :status="latestPipeline.details.status" />
<span class="gl-ml-3">
<strong> {{ __('Pipeline') }} </strong>
<a
:href="latestPipeline.path"
target="_blank"
class="ide-external-link position-relative"
>
#{{ latestPipeline.id }} <gl-icon :size="12" name="external-link" />
</a>
</span>
</header>
<div v-if="!latestPipeline" :class="$options.CLASSES_FLEX_VERTICAL_CENTER">
<empty-state />
</div>
<gl-alert
v-else-if="latestPipeline.yamlError"
variant="danger"
:dismissible="false"
class="gl-mt-5"
>
<p class="gl-mb-0">{{ __('Unable to create pipeline') }}</p>
<p class="break-word gl-mb-0">{{ latestPipeline.yamlError }}</p>
</gl-alert>
<gl-tabs v-else>
<gl-tab :active="!pipelineFailed">
<template #title>
{{ __('Jobs') }}
<gl-badge v-if="jobsCount" class="gl-tab-counter-badge">{{ jobsCount }}</gl-badge>
</template>
<jobs-list :loading="isLoadingJobs" :stages="stages" />
</gl-tab>
<gl-tab :active="pipelineFailed">
<template #title>
{{ __('Failed Jobs') }}
<gl-badge v-if="failedJobsCount" class="gl-tab-counter-badge">{{
failedJobsCount
}}</gl-badge>
</template>
<jobs-list :loading="isLoadingJobs" :stages="failedStages" />
</gl-tab>
</gl-tabs>
</template>
</div>
</template>

View File

@ -1,72 +0,0 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapState, mapActions, mapGetters } from 'vuex';
import { stageKeys } from '../constants';
import EmptyState from './commit_sidebar/empty_state.vue';
import CommitFilesList from './commit_sidebar/list.vue';
export default {
components: {
CommitFilesList,
EmptyState,
},
computed: {
...mapState(['changedFiles', 'stagedFiles', 'lastCommitMsg']),
...mapState('commit', ['commitMessage', 'submitCommitLoading']),
...mapGetters(['lastOpenedFile', 'someUncommittedChanges', 'activeFile']),
...mapGetters('commit', ['discardDraftButtonDisabled']),
showStageUnstageArea() {
return Boolean(this.someUncommittedChanges || this.lastCommitMsg);
},
activeFileKey() {
return this.activeFile ? this.activeFile.key : null;
},
},
mounted() {
this.initialize();
},
activated() {
this.initialize();
},
methods: {
...mapActions(['openPendingTab', 'updateViewer', 'updateActivityBarView']),
initialize() {
const file =
this.lastOpenedFile && this.lastOpenedFile.type !== 'tree'
? this.lastOpenedFile
: this.activeFile;
if (!file) return;
this.openPendingTab({
file,
keyPrefix: file.staged ? stageKeys.staged : stageKeys.unstaged,
})
.then((changeViewer) => {
if (changeViewer) {
this.updateViewer('diff');
}
})
.catch((e) => {
throw e;
});
},
},
stageKeys,
};
</script>
<template>
<div class="multi-file-commit-panel-section">
<template v-if="showStageUnstageArea">
<commit-files-list
:key-prefix="$options.stageKeys.staged"
:file-list="stagedFiles"
:active-file-key="activeFileKey"
:empty-state-text="__('There are no changes')"
class="is-first"
/>
</template>
<empty-state v-else />
</div>
</template>

View File

@ -1,540 +0,0 @@
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapState, mapGetters, mapActions } from 'vuex';
import {
EDITOR_TYPE_DIFF,
EDITOR_TYPE_CODE,
EDITOR_CODE_INSTANCE_FN,
EDITOR_DIFF_INSTANCE_FN,
} from '~/editor/constants';
import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base';
import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext';
import SourceEditor from '~/editor/source_editor';
import { createAlert } from '~/alert';
import ModelManager from '~/ide/lib/common/model_manager';
import { defaultDiffEditorOptions, defaultEditorOptions } from '~/ide/lib/editor_options';
import { __ } from '~/locale';
import {
WEBIDE_MARK_FILE_CLICKED,
WEBIDE_MARK_REPO_EDITOR_START,
WEBIDE_MARK_REPO_EDITOR_FINISH,
WEBIDE_MEASURE_REPO_EDITOR,
WEBIDE_MEASURE_FILE_AFTER_INTERACTION,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import { markRaw } from '~/lib/utils/vue3compat/mark_raw';
import { readFileAsDataURL } from '~/lib/utils/file_utility';
import { hasCiConfigExtension } from '~/lib/utils/common_utils';
import {
leftSidebarViews,
viewerTypes,
FILE_VIEW_MODE_EDITOR,
FILE_VIEW_MODE_PREVIEW,
} from '../constants';
import eventHub from '../eventhub';
import { getRulesWithTraversal } from '../lib/editorconfig/parser';
import mapRulesToMonaco from '../lib/editorconfig/rules_mapper';
import { getFileEditorOrDefault } from '../stores/modules/editor/utils';
import { extractMarkdownImagesFromEntries } from '../stores/utils';
import { getPathParent, registerSchema, isTextFile } from '../utils';
import FileTemplatesBar from './file_templates/bar.vue';
const MARKDOWN_FILE_TYPE = 'markdown';
export default {
name: 'RepoEditor',
components: {
GlTabs,
GlTab,
ContentViewer,
DiffViewer,
FileTemplatesBar,
},
props: {
file: {
type: Object,
required: true,
},
},
data() {
return {
content: '',
images: {},
rules: {},
globalEditor: null,
modelManager: markRaw(new ModelManager()),
isEditorLoading: true,
unwatchCiYaml: null,
SELivepreviewExtension: null,
MarkdownLivePreview: null,
};
},
computed: {
...mapState('rightPane', {
rightPaneIsOpen: 'isOpen',
}),
...mapState('editor', ['fileEditors']),
...mapState([
'viewer',
'panelResizing',
'currentActivityView',
'renderWhitespaceInCode',
'editorTheme',
'entries',
'currentProjectId',
'previewMarkdownPath',
]),
...mapGetters([
'currentMergeRequest',
'getStagedFile',
'isEditModeActive',
'isCommitModeActive',
'currentBranch',
'getJsonSchemaForPath',
]),
...mapGetters('fileTemplates', ['showFileTemplatesBar']),
fileEditor() {
return getFileEditorOrDefault(this.fileEditors, this.file.path);
},
isBinaryFile() {
return !isTextFile(this.file);
},
shouldHideEditor() {
return this.file && !this.file.loading && this.isBinaryFile;
},
showContentViewer() {
return (
(this.shouldHideEditor || this.isPreviewViewMode) &&
(this.viewer !== viewerTypes.mr || !this.file.mrChange)
);
},
showDiffViewer() {
return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr;
},
isEditorViewMode() {
return this.fileEditor.viewMode === FILE_VIEW_MODE_EDITOR;
},
isPreviewViewMode() {
return this.fileEditor.viewMode === FILE_VIEW_MODE_PREVIEW;
},
showEditor() {
return !this.shouldHideEditor && this.isEditorViewMode;
},
editorOptions() {
return {
renderWhitespace: this.renderWhitespaceInCode ? 'all' : 'none',
theme: this.editorTheme,
};
},
currentBranchCommit() {
return this.currentBranch?.commit.id;
},
previewMode() {
return viewerInformationForPath(this.file.path);
},
fileType() {
return this.previewMode?.id || '';
},
showTabs() {
return !this.shouldHideEditor && this.isEditModeActive && this.previewMode;
},
isCiConfigFile() {
return (
// For CI config schemas the filename must match '*.gitlab-ci.yml' regardless of project configuration.
// https://gitlab.com/gitlab-org/gitlab/-/issues/293641
hasCiConfigExtension(this.file.path) && this.editor?.getEditorType() === EDITOR_TYPE_CODE
);
},
},
watch: {
file(newVal, oldVal) {
if (oldVal.pending) {
this.removePendingTab(oldVal);
}
// Compare key to allow for files opened in review mode to be cached differently
if (oldVal.key !== this.file.key) {
this.isEditorLoading = true;
this.initEditor();
if (this.currentActivityView !== leftSidebarViews.edit.name) {
this.updateEditor({
viewMode: FILE_VIEW_MODE_EDITOR,
});
}
}
},
currentActivityView() {
if (this.currentActivityView !== leftSidebarViews.edit.name) {
this.updateEditor({
viewMode: FILE_VIEW_MODE_EDITOR,
});
}
},
viewer() {
this.isEditorLoading = false;
if (!this.file.pending) {
this.createEditorInstance();
}
},
showContentViewer(val) {
if (!val) return;
if (this.fileType === MARKDOWN_FILE_TYPE) {
const { content, images } = extractMarkdownImagesFromEntries(this.file, this.entries);
this.content = content;
this.images = images;
} else {
this.content = this.file.content || this.file.raw;
this.images = {};
}
},
},
beforeDestroy() {
this.globalEditor.dispose();
},
mounted() {
if (!this.globalEditor) {
this.globalEditor = markRaw(new SourceEditor());
}
this.initEditor();
// listen in capture phase to be able to override Monaco's behaviour.
window.addEventListener('paste', this.onPaste, true);
},
destroyed() {
window.removeEventListener('paste', this.onPaste, true);
},
methods: {
...mapActions([
'getFileData',
'getRawFileData',
'changeFileContent',
'removePendingTab',
'triggerFilesChange',
'addTempImage',
]),
...mapActions('editor', ['updateFileEditor']),
initEditor() {
performanceMarkAndMeasure({ mark: WEBIDE_MARK_REPO_EDITOR_START });
if (this.shouldHideEditor && (this.file.content || this.file.raw)) {
return;
}
Promise.all([this.fetchFileData(), this.fetchEditorconfigRules()])
.then(() => {
this.createEditorInstance();
})
.catch((err) => {
createAlert({
message: __('Error setting up editor. Please try again.'),
fadeTransition: false,
addBodyClass: true,
});
throw err;
});
},
fetchFileData() {
if (this.file.tempFile) {
return Promise.resolve();
}
return this.getFileData({
path: this.file.path,
makeFileActive: false,
toggleLoading: false,
}).then(() =>
this.getRawFileData({
path: this.file.path,
}),
);
},
createEditorInstance() {
if (this.isBinaryFile) {
return;
}
const isDiff = this.viewer !== viewerTypes.edit;
const shouldDisposeEditor = isDiff !== (this.editor?.getEditorType() === EDITOR_TYPE_DIFF);
if (this.editor && !shouldDisposeEditor) {
this.setupEditor();
} else {
if (this.editor && shouldDisposeEditor) {
this.editor.dispose();
}
const instanceOptions = isDiff ? defaultDiffEditorOptions : defaultEditorOptions;
const method = isDiff ? EDITOR_DIFF_INSTANCE_FN : EDITOR_CODE_INSTANCE_FN;
this.editor = markRaw(
this.globalEditor[method]({
el: this.$refs.editor,
blobPath: this.file.path,
blobGlobalId: this.file.key,
blobContent: this.content || this.file.content,
...instanceOptions,
...this.editorOptions,
}),
);
this.editor.use([
{
definition: SourceEditorExtension,
},
{
definition: EditorWebIdeExtension,
setupOptions: {
modelManager: this.modelManager,
store: this.$store,
file: this.file,
options: this.editorOptions,
},
},
]);
this.$nextTick(() => {
this.setupEditor();
});
}
},
setupEditor() {
if (!this.file || !this.editor || this.file.loading) return;
const useLivePreviewExtension = () => {
this.SELivepreviewExtension = this.editor.use({
definition: this.MarkdownLivePreview,
setupOptions: { previewMarkdownPath: this.previewMarkdownPath },
});
};
if (
this.fileType === MARKDOWN_FILE_TYPE &&
this.editor?.getEditorType() === EDITOR_TYPE_CODE &&
this.previewMarkdownPath
) {
if (this.MarkdownLivePreview) {
useLivePreviewExtension();
} else {
import('~/editor/extensions/source_editor_markdown_livepreview_ext')
.then(({ EditorMarkdownPreviewExtension }) => {
this.MarkdownLivePreview = EditorMarkdownPreviewExtension;
useLivePreviewExtension();
})
.catch((e) =>
createAlert({
message: e,
}),
);
}
} else if (this.SELivepreviewExtension) {
this.editor.unuse(this.SELivepreviewExtension);
}
const head = this.getStagedFile(this.file.path);
this.model = this.editor.createModel(
this.file,
this.file.staged && this.file.key.indexOf('unstaged-') === 0 ? head : null,
);
if (this.viewer === viewerTypes.mr && this.file.mrChange) {
this.editor.attachMergeRequestModel(this.model);
} else {
this.editor.attachModel(this.model);
}
this.isEditorLoading = false;
this.model.updateOptions(this.rules);
this.registerSchemaForFile();
this.model.onChange((model) => {
const { file } = model;
if (!file.active) return;
const monacoModel = model.getModel();
const content = monacoModel.getValue();
this.changeFileContent({ path: file.path, content });
});
// Handle Cursor Position
this.editor.onPositionChange((instance, e) => {
this.updateEditor({
editorRow: e.position.lineNumber,
editorColumn: e.position.column,
});
});
this.editor.setPos({
lineNumber: this.fileEditor.editorRow,
column: this.fileEditor.editorColumn,
});
// Handle File Language
this.updateEditor({
fileLanguage: this.model.language,
});
this.$emit('editorSetup');
if (performance.getEntriesByName(WEBIDE_MARK_FILE_CLICKED).length) {
eventHub.$emit(WEBIDE_MEASURE_FILE_AFTER_INTERACTION);
} else {
performanceMarkAndMeasure({
mark: WEBIDE_MARK_REPO_EDITOR_FINISH,
measures: [
{
name: WEBIDE_MEASURE_REPO_EDITOR,
start: WEBIDE_MARK_REPO_EDITOR_START,
},
],
});
}
},
fetchEditorconfigRules() {
return getRulesWithTraversal(this.file.path, (path) => {
const entry = this.entries[path];
if (!entry) return Promise.resolve(null);
const content = entry.content || entry.raw;
if (content) return Promise.resolve(content);
return this.getFileData({ path: entry.path, makeFileActive: false }).then(() =>
this.getRawFileData({ path: entry.path }),
);
}).then((rules) => {
this.rules = mapRulesToMonaco(rules);
});
},
onPaste(event) {
const { editor } = this;
const reImage = /^image\/(png|jpg|jpeg|gif)$/;
const file = event.clipboardData.files[0];
if (
editor.hasTextFocus() &&
this.fileType === MARKDOWN_FILE_TYPE &&
reImage.test(file?.type)
) {
// don't let the event be passed on to Monaco.
event.preventDefault();
event.stopImmediatePropagation();
return readFileAsDataURL(file).then((content) => {
const parentPath = getPathParent(this.file.path);
const path = `${parentPath ? `${parentPath}/` : ''}${file.name}`;
return this.addTempImage({
name: path,
rawPath: URL.createObjectURL(file),
content: atob(content.split('base64,')[1]),
}).then(({ name: fileName }) => {
this.editor.replaceSelectedText(`![${fileName}](./${fileName})`);
});
});
}
// do nothing if no image is found in the clipboard
return Promise.resolve();
},
registerSchemaForFile() {
const registerExternalSchema = () => {
const schema = this.getJsonSchemaForPath(this.file.path);
return registerSchema(schema);
};
const registerLocalSchema = async () => {
if (!this.CiSchemaExtension) {
const { CiSchemaExtension } = await import(
'~/editor/extensions/source_editor_ci_schema_ext'
).catch((e) =>
createAlert({
message: e,
}),
);
this.CiSchemaExtension = CiSchemaExtension;
}
this.editor.use({ definition: this.CiSchemaExtension });
this.editor.registerCiSchema();
};
if (this.isCiConfigFile) {
registerLocalSchema();
} else {
if (this.CiSchemaExtension) {
this.editor.unuse(this.CiSchemaExtension);
}
registerExternalSchema();
}
},
updateEditor(data) {
// Looks like our model wrapper `.dispose` causes the monaco editor to emit some position changes after
// when disposing. We want to ignore these by only capturing editor changes that happen to the currently active
// file.
if (!this.file.active) {
return;
}
this.updateFileEditor({ path: this.file.path, data });
},
},
viewerTypes,
FILE_VIEW_MODE_EDITOR,
FILE_VIEW_MODE_PREVIEW,
};
</script>
<template>
<div id="ide" class="blob-viewer-container blob-editor-container">
<gl-tabs v-if="showTabs" content-class="gl-hidden">
<gl-tab
:title="__('Edit')"
data-testid="edit-tab"
@click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_EDITOR })"
/>
<gl-tab
:title="previewMode.previewTitle"
data-testid="preview-tab"
@click="updateEditor({ viewMode: $options.FILE_VIEW_MODE_PREVIEW })"
/>
</gl-tabs>
<file-templates-bar v-else-if="showFileTemplatesBar(file.name)" />
<div
v-show="showEditor"
ref="editor"
:key="`content-editor`"
:class="{
'is-readonly': isCommitModeActive,
'is-deleted': file.deleted,
'is-added': file.tempFile,
}"
class="multi-file-editor-holder"
data-testid="editor-container"
:data-editor-loading="isEditorLoading"
@focusout="triggerFilesChange"
></div>
<content-viewer
v-if="showContentViewer"
:content="content"
:images="images"
:path="file.rawPath || file.path"
:file-path="file.path"
:file-size="file.size"
:project-path="currentProjectId"
:commit-sha="currentBranchCommit"
:type="fileType"
/>
<diff-viewer
v-if="showDiffViewer"
:diff-mode="file.mrChange.diffMode"
:new-path="file.mrChange.new_path"
:new-sha="currentMergeRequest.sha"
:old-path="file.mrChange.old_path"
:old-sha="currentMergeRequest.baseCommitSha"
:project-path="currentProjectId"
/>
</div>
</template>

View File

@ -1,33 +0,0 @@
<script>
import { GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import '~/lib/utils/datetime_utility';
export default {
components: {
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
file: {
type: Object,
required: true,
},
},
computed: {
lockTooltip() {
return sprintf(__(`Locked by %{fileLockUserName}`), {
fileLockUserName: this.file.file_lock.user.name,
});
},
},
};
</script>
<template>
<span v-if="file.file_lock" v-gl-tooltip :title="lockTooltip" data-container="body">
<gl-icon name="lock" class="file-status-icon" />
</span>
</template>

View File

@ -1,103 +0,0 @@
<script>
import { GlIcon, GlTab } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import { __, sprintf } from '~/locale';
import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue';
import FileIcon from '~/vue_shared/components/file_icon.vue';
import FileStatusIcon from './repo_file_status_icon.vue';
export default {
components: {
FileStatusIcon,
FileIcon,
GlIcon,
ChangedFileIcon,
GlTab,
},
props: {
tab: {
type: Object,
required: true,
},
},
data() {
return {
tabMouseOver: false,
};
},
computed: {
...mapGetters(['getUrlForPath']),
closeLabel() {
if (this.fileHasChanged) {
return sprintf(__('%{tabname} changed'), { tabname: this.tab.name });
}
return sprintf(__('Close %{tabname}'), { tabname: this.tab.name });
},
showChangedIcon() {
if (this.tab.pending) return true;
return this.fileHasChanged ? !this.tabMouseOver : false;
},
fileHasChanged() {
return this.tab.changed || this.tab.tempFile || this.tab.staged || this.tab.deleted;
},
},
methods: {
...mapActions(['closeFile', 'updateDelayViewerUpdated', 'openPendingTab']),
clickFile(tab) {
if (tab.active) return;
this.updateDelayViewerUpdated(true);
if (tab.pending) {
this.openPendingTab({ file: tab, keyPrefix: tab.staged ? 'staged' : 'unstaged' });
} else {
this.$router.push(this.getUrlForPath(tab.path));
}
},
mouseOverTab() {
if (this.fileHasChanged) {
this.tabMouseOver = true;
}
},
mouseOutTab() {
if (this.fileHasChanged) {
this.tabMouseOver = false;
}
},
},
};
</script>
<template>
<gl-tab
:active="tab.active"
:disabled="tab.pending"
:title="tab.name"
@click="clickFile(tab)"
@mouseover="mouseOverTab"
@mouseout="mouseOutTab"
>
<template #title>
<div :title="getUrlForPath(tab.path)" class="multi-file-tab">
<file-icon :file-name="tab.name" :size="16" />
{{ tab.name }}
<file-status-icon :file="tab" />
</div>
<button
:aria-label="closeLabel"
:disabled="tab.pending"
type="button"
class="multi-file-tab-close"
data-testid="close-button"
@click.stop.prevent="closeFile(tab)"
>
<gl-icon v-if="!showChangedIcon" :size="12" name="close" />
<changed-file-icon v-else :file="tab" />
</button>
</template>
</gl-tab>
</template>

View File

@ -1,52 +0,0 @@
<script>
import { GlTabs } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import RepoTab from './repo_tab.vue';
export default {
components: {
RepoTab,
GlTabs,
},
props: {
activeFile: {
type: Object,
required: true,
},
files: {
type: Array,
required: true,
},
viewer: {
type: String,
required: true,
},
},
computed: {
...mapGetters(['getUrlForPath']),
},
methods: {
...mapActions(['updateViewer', 'removePendingTab']),
openFileViewer(viewer) {
this.updateViewer(viewer);
if (this.activeFile.pending) {
return this.removePendingTab(this.activeFile).then(() => {
this.$router.push(this.getUrlForPath(this.activeFile.path));
});
}
return null;
},
},
};
</script>
<template>
<div class="multi-file-tabs">
<gl-tabs>
<repo-tab v-for="tab in files" :key="tab.key" :tab="tab" />
</gl-tabs>
</div>
</template>

View File

@ -1,68 +0,0 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import PanelResizer from '~/vue_shared/components/panel_resizer.vue';
import { SIDEBAR_MIN_WIDTH } from '../constants';
export default {
components: {
PanelResizer,
},
props: {
initialWidth: {
type: Number,
required: true,
},
minSize: {
type: Number,
required: false,
default: SIDEBAR_MIN_WIDTH,
},
side: {
type: String,
required: true,
},
resizable: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
width: this.initialWidth,
};
},
computed: {
panelStyle() {
if (this.resizable) {
return {
width: `${this.width}px`,
};
}
return {};
},
},
methods: {
...mapActions(['setResizingStatus']),
},
maxSize: window.innerWidth / 2,
};
</script>
<template>
<div class="gl-relative" :style="panelStyle">
<slot></slot>
<panel-resizer
v-show="resizable"
:size.sync="width"
:start-size="initialWidth"
:min-size="minSize"
:max-size="$options.maxSize"
:side="side === 'right' ? 'left' : 'right'"
@resize-start="setResizingStatus(true)"
@resize-end="setResizingStatus(false)"
/>
</div>
</template>

View File

@ -1,106 +0,0 @@
<script>
import { GlIcon } from '@gitlab/ui';
import { __ } from '~/locale';
export default {
components: {
GlIcon,
},
props: {
placeholder: {
type: String,
required: false,
default: __('Search'),
},
tokens: {
type: Array,
required: false,
default: () => [],
},
value: {
type: String,
required: false,
default: '',
},
},
data() {
return {
backspaceCount: 0,
};
},
computed: {
placeholderText() {
return this.tokens.length ? '' : this.placeholder;
},
},
watch: {
tokens() {
this.$refs.input.focus();
},
},
methods: {
onFocus() {
this.$emit('focus');
},
onBlur() {
this.$emit('blur');
},
onInput(evt) {
this.$emit('input', evt.target.value);
},
onBackspace() {
if (!this.value && this.tokens.length) {
this.backspaceCount += 1;
} else {
this.backspaceCount = 0;
return;
}
if (this.backspaceCount > 1) {
this.removeToken(this.tokens[this.tokens.length - 1]);
this.backspaceCount = 0;
}
},
removeToken(token) {
this.$emit('removeToken', token);
},
},
};
</script>
<template>
<div class="filtered-search-wrapper">
<div class="filtered-search-box">
<div class="tokens-container list-unstyled">
<div v-for="token in tokens" :key="token.label" class="filtered-search-token">
<button
class="selectable gl-rounded-none gl-border-none !gl-bg-transparent gl-p-0 !gl-shadow-none !gl-outline-none"
type="button"
@click.stop="removeToken(token)"
@keyup.delete="removeToken(token)"
>
<div class="value-container rounded">
<div class="value">{{ token.label }}</div>
<div class="remove-token inverted">
<gl-icon :size="16" name="close" />
</div>
</div>
</button>
</div>
<div class="input-token">
<input
ref="input"
:placeholder="placeholderText"
:value="value"
type="search"
class="form-control filtered-search"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@keyup.delete="onBackspace"
/>
</div>
</div>
</div>
</div>
</template>

View File

@ -1,73 +0,0 @@
<script>
import { GlLoadingIcon, GlButton, GlAlert } from '@gitlab/ui';
import SafeHtml from '~/vue_shared/directives/safe_html';
export default {
components: {
GlLoadingIcon,
GlButton,
GlAlert,
},
directives: {
SafeHtml,
},
props: {
isLoading: {
type: Boolean,
required: false,
default: true,
},
isValid: {
type: Boolean,
required: false,
default: false,
},
message: {
type: String,
required: false,
default: '',
},
helpPath: {
type: String,
required: false,
default: '',
},
illustrationPath: {
type: String,
required: false,
default: '',
},
},
methods: {
onStart() {
this.$emit('start');
},
},
};
</script>
<template>
<div class="gl-p-5 gl-text-center">
<div v-if="illustrationPath" class="svg-content svg-130"><img :src="illustrationPath" /></div>
<h4>{{ __('Web Terminal') }}</h4>
<gl-loading-icon v-if="isLoading" size="lg" class="gl-mt-3" />
<template v-else>
<p>{{ __('Run tests against your code live using the Web Terminal') }}</p>
<p>
<gl-button :disabled="!isValid" category="primary" variant="confirm" @click="onStart">
{{ __('Start Web Terminal') }}
</gl-button>
</p>
<gl-alert v-if="!isValid && message" variant="tip" :dismissible="false">
<span v-safe-html="message"></span>
</gl-alert>
<p v-else>
<a
v-if="helpPath"
:href="helpPath"
target="_blank"
v-text="__('Learn more about Web Terminal')"
></a>
</p>
</template>
</div>
</template>

View File

@ -1,57 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { __ } from '~/locale';
import { isEndingStatus } from '../../stores/modules/terminal/utils';
import Terminal from './terminal.vue';
export default {
components: {
Terminal,
GlButton,
},
computed: {
...mapState('terminal', ['session']),
actionButton() {
if (isEndingStatus(this.session.status)) {
return {
action: () => this.restartSession(),
variant: 'confirm',
category: 'primary',
text: __('Restart Terminal'),
};
}
return {
action: () => this.stopSession(),
variant: 'danger',
category: 'secondary',
text: __('Stop Terminal'),
};
},
},
methods: {
...mapActions('terminal', ['restartSession', 'stopSession']),
},
};
</script>
<template>
<div v-if="session" class="ide-terminal flex-column gl-flex">
<header class="ide-job-header gl-flex gl-items-center">
<h5>{{ __('Web Terminal') }}</h5>
<div class="ml-auto align-self-center">
<gl-button
v-if="actionButton"
:variant="actionButton.variant"
:category="actionButton.category"
@click="actionButton.action"
>{{ actionButton.text }}</gl-button
>
</div>
</header>
<terminal :terminal-path="session.terminalPath" :status="session.status" />
</div>
</template>

View File

@ -1,119 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlLoadingIcon } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import { __ } from '~/locale';
import GLTerminal from '~/terminal/terminal';
import { RUNNING, STOPPING } from '../../stores/modules/terminal/constants';
import { isStartingStatus } from '../../stores/modules/terminal/utils';
import TerminalControls from './terminal_controls.vue';
export default {
components: {
GlLoadingIcon,
TerminalControls,
},
props: {
terminalPath: {
type: String,
required: false,
default: '',
},
status: {
type: String,
required: true,
},
},
data() {
return {
glterminal: null,
canScrollUp: false,
canScrollDown: false,
};
},
computed: {
...mapState(['panelResizing']),
loadingText() {
if (isStartingStatus(this.status)) {
return __('Starting...');
}
if (this.status === STOPPING) {
return __('Stopping...');
}
return '';
},
},
watch: {
panelResizing() {
if (!this.panelResizing && this.glterminal) {
this.glterminal.fit();
}
},
status() {
this.refresh();
},
terminalPath() {
this.refresh();
},
},
beforeDestroy() {
this.destroyTerminal();
},
methods: {
refresh() {
if (this.status === RUNNING && this.terminalPath) {
this.createTerminal();
} else if (this.status === STOPPING) {
this.stopTerminal();
}
},
createTerminal() {
this.destroyTerminal();
this.glterminal = new GLTerminal(this.$refs.terminal);
this.glterminal.addScrollListener(({ canScrollUp, canScrollDown }) => {
this.canScrollUp = canScrollUp;
this.canScrollDown = canScrollDown;
});
},
destroyTerminal() {
if (this.glterminal) {
this.glterminal.dispose();
this.glterminal = null;
}
},
stopTerminal() {
if (this.glterminal) {
this.glterminal.disable();
}
},
},
};
</script>
<template>
<div class="flex-column flex-fill min-height-0 pr-3 gl-flex">
<div class="top-bar border-left-0 gl-flex gl-items-center">
<div v-if="loadingText">
<gl-loading-icon size="sm" :inline="true" />
<span>{{ loadingText }}</span>
</div>
<terminal-controls
v-if="glterminal"
class="ml-auto"
:can-scroll-up="canScrollUp"
:can-scroll-down="canScrollDown"
@scroll-up="glterminal.scrollToTop()"
@scroll-down="glterminal.scrollToBottom()"
/>
</div>
<div class="terminal-wrapper flex-fill min-height-0 gl-flex">
<div
ref="terminal"
class="ide-terminal-trace flex-fill min-height-0 gl-w-full"
:data-project-path="terminalPath"
></div>
</div>
</div>
</template>

View File

@ -1,27 +0,0 @@
<script>
import ScrollButton from '~/ide/components/jobs/detail/scroll_button.vue';
export default {
components: {
ScrollButton,
},
props: {
canScrollUp: {
type: Boolean,
required: false,
default: false,
},
canScrollDown: {
type: Boolean,
required: false,
default: false,
},
},
};
</script>
<template>
<div class="controllers">
<scroll-button :disabled="!canScrollUp" direction="up" @click="$emit('scroll-up')" />
<scroll-button :disabled="!canScrollDown" direction="down" @click="$emit('scroll-down')" />
</div>
</template>

View File

@ -1,42 +0,0 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import EmptyState from './empty_state.vue';
export default {
components: {
EmptyState,
TerminalSession: () => import(/* webpackChunkName: 'ide_terminal' */ './session.vue'),
},
computed: {
...mapState('terminal', ['isShowSplash', 'paths']),
...mapGetters('terminal', ['allCheck']),
},
methods: {
...mapActions('terminal', ['startSession', 'hideSplash']),
start() {
this.startSession();
this.hideSplash();
},
},
};
</script>
<template>
<div class="gl-h-full">
<div v-if="isShowSplash" class="flex-column justify-content-center gl-flex gl-h-full">
<empty-state
:is-loading="allCheck.isLoading"
:is-valid="allCheck.isValid"
:message="allCheck.message"
:help-path="paths.webTerminalHelpPath"
:illustration-path="paths.webTerminalSvgPath"
@start="start()"
/>
</div>
<template v-else>
<terminal-session />
</template>
</div>
</template>

View File

@ -1,80 +0,0 @@
<script>
import { GlTooltipDirective, GlLoadingIcon, GlIcon } from '@gitlab/ui';
import { throttle } from 'lodash';
// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import {
MSG_TERMINAL_SYNC_CONNECTING,
MSG_TERMINAL_SYNC_UPLOADING,
MSG_TERMINAL_SYNC_RUNNING,
} from '../../stores/modules/terminal_sync/messages';
export default {
components: {
GlIcon,
GlLoadingIcon,
},
directives: {
'gl-tooltip': GlTooltipDirective,
},
data() {
return { isLoading: false };
},
computed: {
...mapState('terminalSync', ['isError', 'isStarted', 'message']),
...mapState('terminalSync', {
isLoadingState: 'isLoading',
}),
status() {
if (this.isLoading) {
return {
icon: '',
text: this.isStarted ? MSG_TERMINAL_SYNC_UPLOADING : MSG_TERMINAL_SYNC_CONNECTING,
};
}
if (this.isError) {
return {
icon: 'warning',
text: this.message,
};
}
if (this.isStarted) {
return {
icon: 'mobile-issue-close',
text: MSG_TERMINAL_SYNC_RUNNING,
};
}
return null;
},
},
watch: {
// We want to throttle the `isLoading` updates so that
// the user actually sees an indicator that changes are sent.
isLoadingState: throttle(function watchIsLoadingState(val) {
this.isLoading = val;
}, 150),
},
created() {
this.isLoading = this.isLoadingState;
},
};
</script>
<template>
<div v-if="status" v-gl-tooltip :title="status.text" role="note" class="gl-flex gl-items-center">
<span>{{ __('Terminal') }}:</span>
<span
class="square s16 ml-1 gl-flex gl-items-center gl-justify-center"
:aria-label="status.text"
>
<gl-loading-icon
v-if="isLoading"
inline
size="sm"
class="gl-flex gl-items-center gl-justify-center"
/>
<gl-icon v-else-if="status.icon" :name="status.icon" :size="16" />
</span>
</div>
</template>

View File

@ -1,23 +0,0 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapState } from 'vuex';
import TerminalSyncStatus from './terminal_sync_status.vue';
/**
* It is possible that the vuex module is not registered.
*
* This component will gracefully handle this so the actual one can simply use `mapState(moduleName, ...)`.
*/
export default {
components: {
TerminalSyncStatus,
},
computed: {
...mapState(['terminalSync']),
},
};
</script>
<template>
<terminal-sync-status v-if="terminalSync" />
</template>

View File

@ -1,35 +1,16 @@
export const MAX_WINDOW_HEIGHT_COMPACT = 750;
export const DEFAULT_BRANCH = 'main';
// Commit message textarea
export const MAX_TITLE_LENGTH = 50;
export const MAX_BODY_LENGTH = 72;
export const GITLAB_WEB_IDE_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/377367';
export const SIDEBAR_INIT_WIDTH = 340;
export const SIDEBAR_MIN_WIDTH = 260;
export const SIDEBAR_NAV_WIDTH = 60;
export const IDE_ELEMENT_ID = 'ide';
// File view modes
export const FILE_VIEW_MODE_EDITOR = 'editor';
export const FILE_VIEW_MODE_PREVIEW = 'preview';
export const PERMISSION_CREATE_MR = 'createMergeRequestIn';
export const PERMISSION_READ_MR = 'readMergeRequest';
export const PERMISSION_PUSH_CODE = 'pushCode';
export const PUSH_RULE_REJECT_UNSIGNED_COMMITS = 'rejectUnsignedCommits';
// The default permission object to use when the project data isn't available yet.
// This helps us encapsulate checks like `canPushCode` without requiring an
// additional check like `currentProject && canPushCode`.
export const DEFAULT_PERMISSIONS = {
[PERMISSION_PUSH_CODE]: true,
};
export const viewerTypes = {
mr: 'mrdiff',
edit: 'editor',
diff: 'diff',
};
// note: This path comes from `config/routes.rb`
export const IDE_PATH = '/-/ide';
export const WEB_IDE_OAUTH_CALLBACK_URL_PATH = '/-/ide/oauth_redirect';
/**
* LEGACY WEB IDE CONSTANTS USED BY OTHER FRONTEND FEATURES. DO NOT CONTINUE USING.
*/
export const diffModes = {
replaced: 'replaced',
new: 'new',
@ -54,24 +35,6 @@ export const diffViewerErrors = Object.freeze({
stored_externally: 'server_side_but_stored_externally',
});
export const leftSidebarViews = {
edit: { name: 'ide-tree' },
review: { name: 'ide-review' },
commit: { name: 'repo-commit-section' },
};
export const rightSidebarViews = {
pipelines: { name: 'pipelines-list', keepAlive: true },
jobsDetail: { name: 'jobs-detail', keepAlive: false },
mergeRequestInfo: { name: 'merge-request-info', keepAlive: true },
terminal: { name: 'terminal', keepAlive: true },
};
export const stageKeys = {
unstaged: 'unstaged',
staged: 'staged',
};
export const commitItemIconMap = {
addition: {
icon: 'file-addition',
@ -86,33 +49,3 @@ export const commitItemIconMap = {
class: 'file-deletion ide-file-deletion',
},
};
export const modalTypes = {
rename: 'rename',
tree: 'tree',
blob: 'blob',
};
export const commitActionTypes = {
move: 'move',
delete: 'delete',
create: 'create',
update: 'update',
};
export const SIDE_LEFT = 'left';
export const SIDE_RIGHT = 'right';
// This is the maximum number of files to auto open when opening the Web IDE
// from a merge request
export const MAX_MR_FILES_AUTO_OPEN = 10;
export const DEFAULT_BRANCH = 'main';
export const GITLAB_WEB_IDE_FEEDBACK_ISSUE = 'https://gitlab.com/gitlab-org/gitlab/-/issues/377367';
export const IDE_ELEMENT_ID = 'ide';
// note: This path comes from `config/routes.rb`
export const IDE_PATH = '/-/ide';
export const WEB_IDE_OAUTH_CALLBACK_URL_PATH = '/-/ide/oauth_redirect';

View File

@ -1,3 +0,0 @@
import createEventHub from '~/helpers/event_hub_factory';
export default createEventHub();

View File

@ -1,112 +0,0 @@
import Vue from 'vue';
import IdeRouter from '~/ide/ide_router_extension';
import { joinPaths } from '~/lib/utils/url_utility';
import {
WEBIDE_MARK_FETCH_PROJECT_DATA_START,
WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH,
WEBIDE_MEASURE_FETCH_PROJECT_DATA,
} from '~/performance/constants';
import { performanceMarkAndMeasure } from '~/performance/utils';
import { syncRouterAndStore } from './sync_router_and_store';
Vue.use(IdeRouter);
/**
* Routes below /-/ide/:
/project/h5bp/html5-boilerplate/blob/main
/project/h5bp/html5-boilerplate/blob/main/app/js/test.js
/project/h5bp/html5-boilerplate/mr/123
/project/h5bp/html5-boilerplate/mr/123/app/js/test.js
/workspace/123
/workspace/project/h5bp/html5-boilerplate/blob/my-special-branch
/workspace/project/h5bp/html5-boilerplate/mr/123
/ = /workspace
/settings
*/
// Unfortunately Vue Router doesn't work without at least a fake component
// If you do only data handling
export const EmptyRouterComponent = {
render(createElement) {
return createElement('div');
},
};
export const createRouter = (store, defaultBranch) => {
const router = new IdeRouter({
mode: 'history',
base: joinPaths(gon.relative_url_root || '', '/-/ide/'),
routes: [
{
path: '/project/:namespace+/:project',
component: EmptyRouterComponent,
children: [
{
path: ':targetmode(edit|tree|blob)/:branchid+/-/*',
component: EmptyRouterComponent,
},
{
path: ':targetmode(edit|tree|blob)/:branchid+/',
redirect: (to) => joinPaths(to.path, '/-/'),
},
{
path: ':targetmode(edit|tree|blob)',
redirect: (to) => joinPaths(to.path, `/${defaultBranch}/-/`),
},
{
path: 'merge_requests/:mrid',
component: EmptyRouterComponent,
},
{
path: '',
redirect: (to) => joinPaths(to.path, `/edit/${defaultBranch}/-/`),
},
],
},
],
});
router.beforeEach((to, from, next) => {
if (to.params.namespace && to.params.project) {
const basePath = to.params.pathMatch || '';
const projectId = `${to.params.namespace}/${to.params.project}`;
const branchId = to.params.branchid;
const mergeRequestId = to.params.mrid;
performanceMarkAndMeasure({ mark: WEBIDE_MARK_FETCH_PROJECT_DATA_START });
if (branchId) {
performanceMarkAndMeasure({
mark: WEBIDE_MARK_FETCH_PROJECT_DATA_FINISH,
measures: [
{
name: WEBIDE_MEASURE_FETCH_PROJECT_DATA,
start: WEBIDE_MARK_FETCH_PROJECT_DATA_START,
},
],
});
store.dispatch('openBranch', {
projectId,
branchId,
basePath,
});
} else if (mergeRequestId) {
store.dispatch('openMergeRequest', {
projectId,
mergeRequestId,
targetProjectId: to.query.target_project,
});
}
}
next();
});
syncRouterAndStore(router, store);
return router;
};

View File

@ -1,21 +0,0 @@
import VueRouter from 'vue-router';
import { escapeFileUrl } from '~/lib/utils/url_utility';
// To allow special characters (like "#," for example) in the branch names, we
// should encode all the locations before those get processed by History API.
// Otherwise, paths get messed up so that the router receives incorrect
// branchid. The only way to do it consistently and in a more or less
// future-proof manner is, unfortunately, to monkey-patch VueRouter or, as
// suggested here, achieve the same more reliably by subclassing VueRouter and
// update the methods, used in WebIDE.
//
// More context: https://gitlab.com/gitlab-org/gitlab/issues/35473
export default class IDERouter extends VueRouter {
push(location, onComplete, onAbort) {
super.push(escapeFileUrl(location), onComplete, onAbort);
}
resolve(to, current, append) {
return super.resolve(escapeFileUrl(to), current, append);
}
}

View File

@ -1,15 +1,10 @@
import Vue from 'vue';
import { IDE_ELEMENT_ID } from '~/ide/constants';
import PerformancePlugin from '~/performance/vue_performance_plugin';
import Translate from '~/vue_shared/translate';
import { OAuthCallbackDomainMismatchErrorApp } from './oauth_callback_domain_mismatch_error';
Vue.use(Translate);
Vue.use(PerformancePlugin, {
components: ['FileTree'],
});
/**
* Start the IDE.
*

View File

@ -1,62 +0,0 @@
// legacyWebIDE.js
import { identity } from 'lodash';
import Vue from 'vue';
// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { parseBoolean } from '../lib/utils/common_utils';
import { DEFAULT_BRANCH } from './constants';
import ide from './components/ide.vue';
import { createRouter } from './ide_router';
import { DEFAULT_THEME } from './lib/themes';
import { createStore } from './stores';
export const initLegacyWebIDE = (el, options = {}) => {
if (!el) return null;
const { rootComponent = ide, extendStore = identity } = options;
const store = createStore();
const project = JSON.parse(el.dataset.project);
store.dispatch('setProject', { project });
// fire and forget fetching non-critical project info
store.dispatch('fetchProjectPermissions');
const router = createRouter(store, el.dataset.defaultBranch || DEFAULT_BRANCH);
return new Vue({
el,
store: extendStore(store, el),
router,
created() {
this.setEmptyStateSvgs({
emptyStateSvgPath: el.dataset.emptyStateSvgPath,
noChangesStateSvgPath: el.dataset.noChangesStateSvgPath,
committedStateSvgPath: el.dataset.committedStateSvgPath,
pipelinesEmptyStateSvgPath: el.dataset.pipelinesEmptyStateSvgPath,
switchEditorSvgPath: el.dataset.switchEditorSvgPath,
});
this.setLinks({
webIDEHelpPagePath: el.dataset.webIdeHelpPagePath,
newWebIDEHelpPagePath: el.dataset.newWebIdeHelpPagePath,
forkInfo: el.dataset.forkInfo ? JSON.parse(el.dataset.forkInfo) : null,
});
this.init({
renderWhitespaceInCode: parseBoolean(el.dataset.renderWhitespaceInCode),
editorTheme: window.gon?.user_color_scheme || DEFAULT_THEME,
previewMarkdownPath: el.dataset.previewMarkdownPath,
userPreferencesPath: el.dataset.userPreferencesPath,
});
},
beforeDestroy() {
// This helps tests do Singleton cleanups which we don't really have responsibility to know about here.
this.$emit('destroy');
},
methods: {
...mapActions(['setEmptyStateSvgs', 'setLinks', 'init']),
},
render(createElement) {
return createElement(rootComponent);
},
});
};

View File

@ -1,14 +0,0 @@
export default class Disposable {
constructor() {
this.disposers = new Set();
}
add(...disposers) {
disposers.forEach((disposer) => this.disposers.add(disposer));
}
dispose() {
this.disposers.forEach((disposer) => disposer.dispose());
this.disposers.clear();
}
}

View File

@ -1,136 +0,0 @@
import { editor as monacoEditor, Uri } from 'monaco-editor';
import { insertFinalNewline } from '~/lib/utils/text_utility';
import eventHub from '../../eventhub';
import { trimTrailingWhitespace } from '../../utils';
import { defaultModelOptions } from '../editor_options';
import Disposable from './disposable';
export default class Model {
constructor(file, head = null) {
this.disposable = new Disposable();
this.file = file;
this.head = head;
this.content = file.content !== '' || file.deleted ? file.content : file.raw;
this.options = { ...defaultModelOptions };
this.disposable.add(
(this.originalModel = monacoEditor.createModel(
head ? head.content : this.file.raw,
undefined,
new Uri('gitlab', false, `original/${this.path}`),
)),
(this.model = monacoEditor.createModel(
this.content,
undefined,
new Uri('gitlab', false, this.path),
)),
);
if (this.file.mrChange) {
this.disposable.add(
(this.baseModel = monacoEditor.createModel(
this.file.baseRaw,
undefined,
new Uri('gitlab', false, `target/${this.path}`),
)),
);
}
this.events = new Set();
this.updateContent = this.updateContent.bind(this);
this.updateNewContent = this.updateNewContent.bind(this);
this.dispose = this.dispose.bind(this);
eventHub.$on(`editor.update.model.dispose.${this.file.key}`, this.dispose);
eventHub.$on(`editor.update.model.content.${this.file.key}`, this.updateContent);
eventHub.$on(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent);
}
get url() {
return this.model.uri.toString();
}
get language() {
return this.model.getLanguageId();
}
get path() {
return this.file.key;
}
getModel() {
return this.model;
}
getOriginalModel() {
return this.originalModel;
}
getBaseModel() {
return this.baseModel;
}
setValue(value) {
this.getModel().setValue(value);
}
onChange(cb) {
this.events.add(this.disposable.add(this.model.onDidChangeContent((e) => cb(this, e))));
}
onDispose(cb) {
this.events.add(cb);
}
updateContent({ content, changed }) {
this.getOriginalModel().setValue(content);
if (!changed) {
this.getModel().setValue(content);
}
}
updateNewContent(content) {
this.getModel().setValue(content);
}
updateOptions(obj = {}) {
Object.assign(this.options, obj);
this.model.updateOptions(obj);
this.applyCustomOptions();
}
applyCustomOptions() {
this.updateNewContent(
Object.entries(this.options).reduce((content, [key, value]) => {
switch (key) {
case 'endOfLine':
this.model.pushEOL(value);
return this.model.getValue();
case 'insertFinalNewline':
return value ? insertFinalNewline(content) : content;
case 'trimTrailingWhitespace':
return value ? trimTrailingWhitespace(content) : content;
default:
return content;
}
}, this.model.getValue()),
);
}
dispose() {
if (!this.model.isDisposed()) this.applyCustomOptions();
this.events.forEach((cb) => {
if (typeof cb === 'function') cb();
});
this.events.clear();
eventHub.$off(`editor.update.model.dispose.${this.file.key}`, this.dispose);
eventHub.$off(`editor.update.model.content.${this.file.key}`, this.updateContent);
eventHub.$off(`editor.update.model.new.content.${this.file.key}`, this.updateNewContent);
this.disposable.dispose();
}
}

View File

@ -1,47 +0,0 @@
import eventHub from '../../eventhub';
import Disposable from './disposable';
import Model from './model';
export default class ModelManager {
constructor() {
this.disposable = new Disposable();
this.models = new Map();
}
hasCachedModel(key) {
return this.models.has(key);
}
getModel(key) {
return this.models.get(key);
}
addModel(file, head = null) {
if (this.hasCachedModel(file.key)) {
return this.getModel(file.key);
}
const model = new Model(file, head);
this.models.set(model.path, model);
this.disposable.add(model);
eventHub.$on(
`editor.update.model.dispose.${file.key}`,
this.removeCachedModel.bind(this, file),
);
return model;
}
removeCachedModel(file) {
this.models.delete(file.key);
eventHub.$off(`editor.update.model.dispose.${file.key}`, this.removeCachedModel);
}
dispose() {
// dispose of all the models
this.disposable.dispose();
this.models.clear();
}
}

View File

@ -1,87 +0,0 @@
import { commitActionTypes } from '~/ide/constants';
import { commitActionForFile } from '~/ide/stores/utils';
import createFileDiff from './create_file_diff';
const getDeletedParents = (entries, file) => {
const parent = file.parentPath && entries[file.parentPath];
if (parent && parent.deleted) {
return [parent, ...getDeletedParents(entries, parent)];
}
return [];
};
const filesWithChanges = ({ stagedFiles = [], changedFiles = [], entries = {} }) => {
// We need changed files to overwrite staged, so put them at the end.
const changes = stagedFiles.concat(changedFiles).reduce((acc, file) => {
const key = file.path;
const action = commitActionForFile(file);
const prev = acc[key];
// If a file was deleted, which was previously added, then we should do nothing.
if (action === commitActionTypes.delete && prev && prev.action === commitActionTypes.create) {
delete acc[key];
} else {
acc[key] = { action, file };
}
return acc;
}, {});
// We need to clean "move" actions, because we can only support 100% similarity moves at the moment.
// This is because the previous file's content might not be loaded.
Object.values(changes)
.filter((change) => change.action === commitActionTypes.move)
.forEach((change) => {
const prev = changes[change.file.prevPath];
if (!prev) {
return;
}
if (change.file.content === prev.file.content) {
// If content is the same, continue with the move but don't do the prevPath's delete.
delete changes[change.file.prevPath];
} else {
// Otherwise, treat the move as a delete / create.
Object.assign(change, { action: commitActionTypes.create });
}
});
// Next, we need to add deleted directories by looking at the parents
Object.values(changes)
.filter((change) => change.action === commitActionTypes.delete && change.file.parentPath)
.forEach(({ file }) => {
// Do nothing if we've already visited this directory.
if (changes[file.parentPath]) {
return;
}
getDeletedParents(entries, file).forEach((parent) => {
changes[parent.path] = { action: commitActionTypes.delete, file: parent };
});
});
return Object.values(changes);
};
const createDiff = (state) => {
const changes = filesWithChanges(state);
const toDelete = changes
.filter((x) => x.action === commitActionTypes.delete)
.map((x) => x.file.path);
const patch = changes
.filter((x) => x.action !== commitActionTypes.delete)
.map(({ file, action }) => createFileDiff(file, action))
.join('');
return {
patch,
toDelete,
};
};
export default createDiff;

View File

@ -1,112 +0,0 @@
/* eslint-disable @gitlab/require-i18n-strings */
import { createTwoFilesPatch } from 'diff';
import { commitActionTypes } from '~/ide/constants';
const DEV_NULL = '/dev/null';
const DEFAULT_MODE = '100644';
const NO_NEW_LINE = '\\ No newline at end of file';
const NEW_LINE = '\n';
/**
* Cleans patch generated by `diff` package.
*
* - Removes "=======" separator added at the beginning
*/
const cleanTwoFilesPatch = (text) => text.replace(/^(=+\s*)/, '');
const endsWithNewLine = (val) => !val || val[val.length - 1] === NEW_LINE;
const addEndingNewLine = (val) => (endsWithNewLine(val) ? val : val + NEW_LINE);
const removeEndingNewLine = (val) => (endsWithNewLine(val) ? val.substr(0, val.length - 1) : val);
const diffHead = (prevPath, newPath = '') =>
`diff --git "a/${prevPath}" "b/${newPath || prevPath}"`;
const createDiffBody = (path, content, isCreate) => {
if (!content) {
return '';
}
const prefix = isCreate ? '+' : '-';
const fromPath = isCreate ? DEV_NULL : `a/${path}`;
const toPath = isCreate ? `b/${path}` : DEV_NULL;
const hasNewLine = endsWithNewLine(content);
const lines = removeEndingNewLine(content).split(NEW_LINE);
const chunkHead = isCreate ? `@@ -0,0 +1,${lines.length} @@` : `@@ -1,${lines.length} +0,0 @@`;
const chunk = lines
.map((line) => `${prefix}${line}`)
.concat(!hasNewLine ? [NO_NEW_LINE] : [])
.join(NEW_LINE);
return `--- ${fromPath}
+++ ${toPath}
${chunkHead}
${chunk}`;
};
const createMoveFileDiff = (prevPath, newPath) => `${diffHead(prevPath, newPath)}
rename from ${prevPath}
rename to ${newPath}`;
const createNewFileDiff = (path, content) => {
const diff = createDiffBody(path, content, true);
return `${diffHead(path)}
new file mode ${DEFAULT_MODE}
${diff}`;
};
const createDeleteFileDiff = (path, content) => {
const diff = createDiffBody(path, content, false);
return `${diffHead(path)}
deleted file mode ${DEFAULT_MODE}
${diff}`;
};
const createUpdateFileDiff = (path, oldContent, newContent) => {
const patch = createTwoFilesPatch(`a/${path}`, `b/${path}`, oldContent, newContent);
return `${diffHead(path)}
${cleanTwoFilesPatch(patch)}`;
};
const createFileDiffRaw = (file, action) => {
switch (action) {
case commitActionTypes.move:
return createMoveFileDiff(file.prevPath, file.path);
case commitActionTypes.create:
return createNewFileDiff(file.path, file.content);
case commitActionTypes.delete:
return createDeleteFileDiff(file.path, file.content);
case commitActionTypes.update:
return createUpdateFileDiff(file.path, file.raw || '', file.content);
default:
return '';
}
};
/**
* Create a git diff for a single IDE file.
*
* ## Notes:
* When called with `commitActionType.move`, it assumes that the move
* is a 100% similarity move. No diff will be generated. This is because
* generating a move with changes is not support by the current IDE, since
* the source file might not have it's content loaded yet.
*
* When called with `commitActionType.delete`, it does not support
* deleting files with a mode different than 100644. For the IDE mirror, this
* isn't needed because deleting is handled outside the unified patch.
*
* ## References:
* - https://git-scm.com/docs/git-diff#_generating_patches_with_p
*/
const createFileDiff = (file, action) =>
// It's important that the file diff ends in a new line - git expects this.
addEndingNewLine(createFileDiffRaw(file, action));
export default createFileDiff;

View File

@ -1,54 +0,0 @@
export default class DecorationsController {
constructor(editor) {
this.editor = editor;
this.decorations = new Map();
this.editorDecorations = new Map();
}
getAllDecorationsForModel(model) {
if (!this.decorations.has(model.url)) return [];
const modelDecorations = this.decorations.get(model.url);
const decorations = [];
modelDecorations.forEach((val) => decorations.push(...val));
return decorations;
}
addDecorations(model, decorationsKey, decorations) {
const decorationMap = this.decorations.get(model.url) || new Map();
decorationMap.set(decorationsKey, decorations);
this.decorations.set(model.url, decorationMap);
this.decorate(model);
}
decorate(model) {
if (!this.editor.instance) return;
const decorations = this.getAllDecorationsForModel(model);
const oldDecorations = this.editorDecorations.get(model.url) || [];
this.editorDecorations.set(
model.url,
this.editor.instance.deltaDecorations(oldDecorations, decorations),
);
}
hasDecorations(model) {
return this.decorations.has(model.url);
}
removeDecorations(model) {
this.decorations.delete(model.url);
this.editorDecorations.delete(model.url);
}
dispose() {
this.decorations.clear();
this.editorDecorations.clear();
}
}

View File

@ -1,89 +0,0 @@
import { throttle } from 'lodash';
import { Range } from 'monaco-editor';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import Disposable from '../common/disposable';
import DirtyDiffWorker from './diff_worker?worker';
export const getDiffChangeType = (change) => {
if (change.modified) {
return 'modified';
}
if (change.added) {
return 'added';
}
if (change.removed) {
return 'removed';
}
return '';
};
export const getDecorator = (change) => ({
range: new Range(change.lineNumber, 1, change.endLineNumber, 1),
options: {
isWholeLine: true,
linesDecorationsClassName: `dirty-diff dirty-diff-${getDiffChangeType(change)}`,
},
});
export default class DirtyDiffController {
constructor(modelManager, decorationsController) {
this.disposable = new Disposable();
this.models = new Map();
this.editorSimpleWorker = null;
this.modelManager = modelManager;
this.decorationsController = decorationsController;
this.dirtyDiffWorker = new DirtyDiffWorker();
this.throttledComputeDiff = throttle(this.computeDiff, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
this.decorate = this.decorate.bind(this);
this.dirtyDiffWorker.addEventListener('message', this.decorate);
}
attachModel(model) {
if (this.models.has(model.url)) return;
model.onChange(() => this.throttledComputeDiff(model));
model.onDispose(() => {
this.decorationsController.removeDecorations(model);
this.models.delete(model.url);
});
this.models.set(model.url, model);
}
computeDiff(model) {
const originalModel = model.getOriginalModel();
const newModel = model.getModel();
if (originalModel.isDisposed() || newModel.isDisposed()) return;
this.dirtyDiffWorker.postMessage({
path: model.path,
originalContent: originalModel.getValue(),
newContent: newModel.getValue(),
});
}
reDecorate(model) {
if (this.decorationsController.hasDecorations(model)) {
this.decorationsController.decorate(model);
} else {
this.computeDiff(model);
}
}
decorate({ data }) {
const decorations = data.changes.map((change) => getDecorator(change));
const model = this.modelManager.getModel(data.path);
this.decorationsController.addDecorations(model, 'dirtyDiff', decorations);
}
dispose() {
this.disposable.dispose();
this.models.clear();
this.dirtyDiffWorker.removeEventListener('message', this.decorate);
this.dirtyDiffWorker.terminate();
}
}

View File

@ -1,36 +0,0 @@
import { diffLines } from 'diff';
import { defaultDiffOptions } from '../editor_options';
export const computeDiff = (originalContent, newContent) => {
// prevent EOL changes from highlighting the entire file
const changes = diffLines(
originalContent.replace(/\r\n/g, '\n'),
newContent.replace(/\r\n/g, '\n'),
defaultDiffOptions,
);
let lineNumber = 1;
return changes.reduce((acc, change) => {
const findOnLine = acc.find((c) => c.lineNumber === lineNumber);
if (findOnLine) {
Object.assign(findOnLine, change, {
modified: true,
endLineNumber: lineNumber + change.count - 1,
});
} else if ('added' in change || 'removed' in change) {
acc.push({
...change,
lineNumber,
modified: undefined,
endLineNumber: lineNumber + change.count - 1,
});
}
if (!change.removed) {
lineNumber += change.count;
}
return acc;
}, []);
};

View File

@ -1,12 +0,0 @@
import { computeDiff } from './diff';
// eslint-disable-next-line no-restricted-globals
self.addEventListener('message', (e) => {
const { data } = e;
// eslint-disable-next-line no-restricted-globals
self.postMessage({
path: data.path,
changes: computeDiff(data.originalContent, data.newContent),
});
});

View File

@ -1,241 +0,0 @@
import { debounce } from 'lodash';
import { editor as monacoEditor, KeyCode, KeyMod, Range } from 'monaco-editor';
import { clearDomElement } from '~/editor/utils';
import { registerLanguages } from '../utils';
import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller';
import { editorOptions, defaultEditorOptions, defaultDiffEditorOptions } from './editor_options';
import keymap from './keymap.json';
import languages from './languages';
import { themes } from './themes';
function setupThemes() {
themes.forEach((theme) => {
monacoEditor.defineTheme(theme.name, theme.data);
});
}
export default class Editor {
static create(...args) {
if (!this.editorInstance) {
this.editorInstance = new Editor(...args);
}
return this.editorInstance;
}
constructor(store, options = {}) {
this.currentModel = null;
this.instance = null;
this.dirtyDiffController = null;
this.disposable = new Disposable();
this.modelManager = new ModelManager();
this.decorationsController = new DecorationsController(this);
this.options = {
...defaultEditorOptions,
...options,
};
this.diffOptions = {
...defaultDiffEditorOptions,
...options,
};
this.store = store;
setupThemes();
registerLanguages(...languages);
this.debouncedUpdate = debounce(() => {
this.updateDimensions();
}, 200);
}
createInstance(domElement) {
if (!this.instance) {
clearDomElement(domElement);
this.disposable.add(
(this.instance = monacoEditor.create(domElement, {
...this.options,
})),
(this.dirtyDiffController = new DirtyDiffController(
this.modelManager,
this.decorationsController,
)),
);
this.addCommands();
window.addEventListener('resize', this.debouncedUpdate, false);
}
}
createDiffInstance(domElement) {
if (!this.instance) {
clearDomElement(domElement);
this.disposable.add(
(this.instance = monacoEditor.createDiffEditor(domElement, {
...this.diffOptions,
renderSideBySide: Editor.renderSideBySide(domElement),
})),
);
this.addCommands();
window.addEventListener('resize', this.debouncedUpdate, false);
}
}
createModel(file, head = null) {
return this.modelManager.addModel(file, head);
}
attachModel(model) {
if (this.isDiffEditorType) {
this.instance.setModel({
original: model.getOriginalModel(),
modified: model.getModel(),
});
return;
}
this.instance.setModel(model.getModel());
if (this.dirtyDiffController) this.dirtyDiffController.attachModel(model);
this.currentModel = model;
this.instance.updateOptions(
editorOptions.reduce((acc, obj) => {
Object.keys(obj).forEach((key) => {
Object.assign(acc, {
[key]: obj[key](model),
});
});
return acc;
}, {}),
);
if (this.dirtyDiffController) this.dirtyDiffController.reDecorate(model);
}
attachMergeRequestModel(model) {
this.instance.setModel({
original: model.getBaseModel(),
modified: model.getModel(),
});
monacoEditor.createDiffNavigator(this.instance, {
alwaysRevealFirst: true,
});
}
clearEditor() {
if (this.instance) {
this.instance.setModel(null);
}
}
dispose() {
window.removeEventListener('resize', this.debouncedUpdate);
// catch any potential errors with disposing the error
// this is mainly for tests caused by elements not existing
try {
this.disposable.dispose();
this.instance = null;
} catch (e) {
this.instance = null;
if (process.env.NODE_ENV !== 'test') {
// eslint-disable-next-line no-console
console.error(e);
}
}
}
updateDimensions() {
if (this.instance) {
this.instance.layout();
this.updateDiffView();
}
}
setPosition({ lineNumber, column }) {
this.instance.revealPositionInCenter({
lineNumber,
column,
});
this.instance.setPosition({
lineNumber,
column,
});
}
onPositionChange(cb) {
if (!this.instance.onDidChangeCursorPosition) return;
this.disposable.add(this.instance.onDidChangeCursorPosition((e) => cb(this.instance, e)));
}
updateDiffView() {
if (!this.isDiffEditorType) return;
this.instance.updateOptions({
renderSideBySide: Editor.renderSideBySide(this.instance.getDomNode()),
});
}
replaceSelectedText(text) {
let selection = this.instance.getSelection();
const range = new Range(
selection.startLineNumber,
selection.startColumn,
selection.endLineNumber,
selection.endColumn,
);
this.instance.executeEdits('', [{ range, text }]);
selection = this.instance.getSelection();
this.instance.setPosition({ lineNumber: selection.endLineNumber, column: selection.endColumn });
}
get isDiffEditorType() {
return this.instance.getEditorType() === 'vs.editor.IDiffEditor';
}
static renderSideBySide(domElement) {
return domElement.offsetWidth >= 700;
}
addCommands() {
const { store } = this;
const getKeyCode = (key) => {
const monacoKeyMod = key.indexOf('KEY_') === 0;
return monacoKeyMod ? KeyCode[key] : KeyMod[key];
};
keymap.forEach((command) => {
const keybindings = command.bindings.map((binding) => {
const keys = binding.split('+');
// eslint-disable-next-line no-bitwise
return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
});
this.instance.addAction({
id: command.id,
label: command.label,
keybindings,
run() {
store.dispatch(command.action.name, command.action.params);
return null;
},
});
});
}
}

View File

@ -1,59 +0,0 @@
import { parseString } from 'editorconfig/src/lib/ini';
import minimatch from 'minimatch';
import { getPathParents } from '../../utils';
const dirname = (path) => path.replace(/\.editorconfig$/, '');
function isRootConfig(config) {
return config.some(([pattern, rules]) => !pattern && rules?.root === 'true');
}
function getRulesForSection(path, [pattern, rules]) {
if (!pattern) {
return {};
}
if (minimatch(path, pattern, { matchBase: true })) {
return rules;
}
return {};
}
function getRulesWithConfigs(filePath, configFiles = [], rules = {}) {
if (!configFiles.length) return rules;
const [{ content, path: configPath }, ...nextConfigs] = configFiles;
const configDir = dirname(configPath);
if (!filePath.startsWith(configDir)) return rules;
const parsed = parseString(content);
const isRoot = isRootConfig(parsed);
const relativeFilePath = filePath.slice(configDir.length);
const sectionRules = parsed.reduce(
(acc, section) => Object.assign(acc, getRulesForSection(relativeFilePath, section)),
{},
);
// prefer existing rules by overwriting to section rules
const result = Object.assign(sectionRules, rules);
return isRoot ? result : getRulesWithConfigs(filePath, nextConfigs, result);
}
export function getRulesWithTraversal(filePath, getFileContent) {
const editorconfigPaths = [
...getPathParents(filePath).map((x) => `${x}/.editorconfig`),
'.editorconfig',
];
return Promise.all(
editorconfigPaths.map((path) => getFileContent(path).then((content) => ({ path, content }))),
).then((results) =>
getRulesWithConfigs(
filePath,
results.filter((x) => x.content),
),
);
}

View File

@ -1,33 +0,0 @@
import { isBoolean, isNumber } from 'lodash';
const map = (key, validValues) => (value) =>
value in validValues ? { [key]: validValues[value] } : {};
const bool = (key) => (value) => (isBoolean(value) ? { [key]: value } : {});
const int = (key, isValid) => (value) =>
isNumber(value) && isValid(value) ? { [key]: Math.trunc(value) } : {};
const rulesMapper = {
indent_style: map('insertSpaces', { tab: false, space: true }),
indent_size: int('tabSize', (n) => n > 0),
tab_width: int('tabSize', (n) => n > 0),
trim_trailing_whitespace: bool('trimTrailingWhitespace'),
end_of_line: map('endOfLine', { crlf: 1, lf: 0 }),
insert_final_newline: bool('insertFinalNewline'),
};
const parseValue = (x) => {
let value = typeof x === 'string' ? x.toLowerCase() : x;
if (/^[0-9.-]+$/.test(value)) value = Number(value);
if (value === 'true') value = true;
if (value === 'false') value = false;
return value;
};
export default function mapRulesToMonaco(rules) {
return Object.entries(rules).reduce((obj, [key, value]) => {
return Object.assign(obj, rulesMapper[key]?.(parseValue(value)) || {});
}, {});
}

View File

@ -1,67 +0,0 @@
import { escape } from 'lodash';
import { __ } from '~/locale';
import { COMMIT_TO_NEW_BRANCH } from '../stores/modules/commit/constants';
const CODEOWNERS_REGEX = /Push.*protected branches.*CODEOWNERS/;
const BRANCH_CHANGED_REGEX = /changed.*since.*start.*edit/;
const BRANCH_ALREADY_EXISTS = /branch.*already.*exists/;
const createNewBranchAndCommit = (store) =>
store
.dispatch('commit/updateCommitAction', COMMIT_TO_NEW_BRANCH)
.then(() => store.dispatch('commit/commitChanges'));
export const createUnexpectedCommitError = (message) => ({
title: __('Unexpected error'),
messageHTML: escape(message) || __('Could not commit. An unexpected error occurred.'),
});
export const createCodeownersCommitError = (message) => ({
title: __('CODEOWNERS rule violation'),
messageHTML: escape(message),
primaryAction: {
text: __('Create new branch'),
callback: createNewBranchAndCommit,
},
});
export const createBranchChangedCommitError = (message) => ({
title: __('Branch changed'),
messageHTML: `${escape(message)}<br/><br/>${__('Would you like to create a new branch?')}`,
primaryAction: {
text: __('Create new branch'),
callback: createNewBranchAndCommit,
},
});
export const branchAlreadyExistsCommitError = (message) => ({
title: __('Branch already exists'),
messageHTML: `${escape(message)}<br/><br/>${__(
'Would you like to try auto-generating a branch name?',
)}`,
primaryAction: {
text: __('Create new branch'),
callback: (store) =>
store.dispatch('commit/addSuffixToBranchName').then(() => createNewBranchAndCommit(store)),
},
});
export const parseCommitError = (e) => {
const { message } = e?.response?.data || {};
if (!message) {
return createUnexpectedCommitError();
}
if (CODEOWNERS_REGEX.test(message)) {
return createCodeownersCommitError(message);
}
if (BRANCH_CHANGED_REGEX.test(message)) {
return createBranchChangedCommitError(message);
}
if (BRANCH_ALREADY_EXISTS.test(message)) {
return branchAlreadyExistsCommitError(message);
}
return createUnexpectedCommitError(message);
};

View File

@ -1,110 +0,0 @@
import { decorateData, sortTree } from '../stores/utils';
export const splitParent = (path) => {
const idx = path.lastIndexOf('/');
return {
parent: idx >= 0 ? path.substring(0, idx) : null,
name: idx >= 0 ? path.substring(idx + 1) : path,
};
};
/**
* Create file objects from a list of file paths.
*
* @param {Array} options.data Array of blob paths to parse and create a file tree from.
* @param {Boolean} options.tempFile Web IDE flag for whether this is a "new" file or not.
* @param {String} options.content Content to initialize the new blob with.
* @param {String} options.rawPath Raw path used for the new blob.
* @param {Object} options.blobData Extra values to initialize each blob with.
*/
export const decorateFiles = ({
data,
tempFile = false,
content = '',
rawPath = '',
blobData = {},
}) => {
const treeList = [];
const entries = {};
// These mutable variable references end up being exported and used by `createTempEntry`
let file;
let parentPath;
const insertParent = (path) => {
if (!path) {
return null;
}
if (entries[path]) {
return entries[path];
}
const { parent, name } = splitParent(path);
const parentFolder = parent && insertParent(parent);
parentPath = parentFolder && parentFolder.path;
const tree = decorateData({
id: path,
name,
path,
type: 'tree',
tempFile,
changed: tempFile,
opened: tempFile,
parentPath,
});
Object.assign(entries, {
[path]: tree,
});
if (parentFolder) {
parentFolder.tree.push(tree);
} else {
treeList.push(tree);
}
return tree;
};
data.forEach((path) => {
const { parent, name } = splitParent(path);
const fileFolder = parent && insertParent(parent);
if (name) {
parentPath = fileFolder && fileFolder.path;
file = decorateData({
id: path,
name,
path,
type: 'blob',
tempFile,
changed: tempFile,
content,
rawPath,
parentPath,
...blobData,
});
Object.assign(entries, {
[path]: file,
});
if (fileFolder) {
fileFolder.tree.push(file);
} else {
treeList.push(file);
}
}
});
return {
entries,
treeList: sortTree(treeList),
file,
parentPath,
};
};

View File

@ -1,19 +0,0 @@
[
{
"id": "file-finder",
"label": "File finder",
"bindings": ["CtrlCmd+KEY_P"],
"action": {
"name": "toggleFileFinder",
"params": true
}
},
{
"id": "save-files",
"label": "Save files",
"bindings": ["CtrlCmd+KEY_S"],
"action": {
"name": "triggerFilesChange"
}
}
]

View File

@ -1,156 +0,0 @@
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import { getWebSocketUrl, mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import createDiff from './create_diff';
export const SERVICE_NAME = 'webide-file-sync';
export const PROTOCOL = 'webfilesync.gitlab.com';
export const MSG_CONNECTION_ERROR = __('Could not connect to Web IDE file mirror service.');
// Before actually connecting to the service, we must delay a bit
// so that the service has sufficiently started.
const noop = () => {};
export const SERVICE_DELAY = 8000;
const cancellableWait = (time) => {
let timeoutId = 0;
const cancel = () => clearTimeout(timeoutId);
const promise = new Promise((resolve) => {
timeoutId = setTimeout(resolve, time);
});
return [promise, cancel];
};
const isErrorResponse = (error) => error && error.code !== 0;
const isErrorPayload = (payload) => payload && payload.status_code !== HTTP_STATUS_OK;
const getErrorFromResponse = (data) => {
if (isErrorResponse(data.error)) {
return { message: data.error.Message };
}
if (isErrorPayload(data.payload)) {
return { message: data.payload.error_message };
}
return null;
};
const getFullPath = (path) => mergeUrlParams({ service: SERVICE_NAME }, getWebSocketUrl(path));
const createWebSocket = (fullPath) =>
new Promise((resolve, reject) => {
const socket = new WebSocket(fullPath, [PROTOCOL]);
const resetCallbacks = () => {
socket.onopen = null;
socket.onerror = null;
};
socket.onopen = () => {
resetCallbacks();
resolve(socket);
};
socket.onerror = () => {
resetCallbacks();
reject(new Error(MSG_CONNECTION_ERROR));
};
});
export const canConnect = ({ services = [] }) => services.some((name) => name === SERVICE_NAME);
export const createMirror = () => {
let socket = null;
let cancelHandler = noop;
let nextMessageHandler = noop;
const cancelConnect = () => {
cancelHandler();
cancelHandler = noop;
};
const onCancelConnect = (fn) => {
cancelHandler = fn;
};
const receiveMessage = (ev) => {
const handle = nextMessageHandler;
nextMessageHandler = noop;
handle(JSON.parse(ev.data));
};
const onNextMessage = (fn) => {
nextMessageHandler = fn;
};
const waitForNextMessage = () =>
new Promise((resolve, reject) => {
onNextMessage((data) => {
const err = getErrorFromResponse(data);
if (err) {
reject(err);
} else {
resolve();
}
});
});
const uploadDiff = ({ toDelete, patch }) => {
if (!socket) {
return Promise.resolve();
}
const response = waitForNextMessage();
const msg = {
code: 'EVENT',
namespace: '/files',
event: 'PATCH',
payload: { diff: patch, delete_files: toDelete },
};
socket.send(JSON.stringify(msg));
return response;
};
return {
upload(state) {
return uploadDiff(createDiff(state));
},
connect(path) {
if (socket) {
this.disconnect();
}
const fullPath = getFullPath(path);
const [wait, cancelWait] = cancellableWait(SERVICE_DELAY);
onCancelConnect(cancelWait);
return wait
.then(() => createWebSocket(fullPath))
.then((newSocket) => {
socket = newSocket;
socket.onmessage = receiveMessage;
});
},
disconnect() {
cancelConnect();
if (!socket) {
return;
}
socket.close();
socket = null;
},
};
};
export default createMirror();

View File

@ -1,25 +0,0 @@
import { s__ } from '~/locale';
export const MSG_CANNOT_PUSH_CODE_SHOULD_FORK = s__(
'WebIDE|You cant edit files directly in this project. Fork this project and submit a merge request with your changes.',
);
export const MSG_CANNOT_PUSH_CODE_GO_TO_FORK = s__(
'WebIDE|You cant edit files directly in this project. Go to your fork and submit a merge request with your changes.',
);
export const MSG_CANNOT_PUSH_CODE = s__(
'WebIDE|You need permission to edit files directly in this project.',
);
export const MSG_CANNOT_PUSH_UNSIGNED = s__(
'WebIDE|This project does not accept unsigned commits. You cant commit changes through the Web IDE.',
);
export const MSG_CANNOT_PUSH_UNSIGNED_SHORT = s__(
'WebIDE|This project does not accept unsigned commits.',
);
export const MSG_FORK = s__('WebIDE|Fork project');
export const MSG_GO_TO_FORK = s__('WebIDE|Go to fork');

View File

@ -1,7 +0,0 @@
#import "~/ide/queries/ide_project.fragment.graphql"
query getIdeProject($projectPath: ID!) {
project(fullPath: $projectPath) {
...IdeProject
}
}

View File

@ -1,8 +0,0 @@
fragment IdeProject on Project {
id
userPermissions {
createMergeRequestIn
readMergeRequest
pushCode
}
}

View File

@ -1,21 +0,0 @@
import { memoize } from 'lodash';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
/**
* Returns a memoized client
*
* We defer creating the client so that importing this module does not cause any side-effects.
* Creating the client immediately caused issues with miragejs where the gql client uses the
* real fetch() instead of the shimmed one.
*/
const getClient = memoize(() =>
createGqClient(
{},
{
fetchPolicy: fetchPolicies.NO_CACHE,
},
),
);
export const query = (...args) => getClient().query(...args);
export const mutate = (...args) => getClient().mutate(...args);

View File

@ -1,90 +1,7 @@
import Api from '~/api';
import getIdeProject from 'ee_else_ce/ide/queries/get_ide_project.query.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import axios from '~/lib/utils/axios_utils';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import { query } from './gql';
export default {
getFileData(endpoint) {
return axios.get(endpoint, {
params: { format: 'json', viewer: 'none' },
});
},
getRawFileData(file) {
if (file.tempFile && !file.prevPath) {
return Promise.resolve(file.content);
}
if (file.raw || !file.rawPath) {
return Promise.resolve(file.raw);
}
const options = file.binary ? { responseType: 'arraybuffer' } : {};
return axios
.get(file.rawPath, {
transformResponse: [(f) => f],
...options,
})
.then(({ data }) => data);
},
getBaseRawFileData(file, projectId, ref) {
if (file.tempFile || file.baseRaw) return Promise.resolve(file.baseRaw);
// if files are renamed, their base path has changed
const filePath =
file.mrChange && file.mrChange.renamed_file ? file.mrChange.old_path : file.path;
return axios
.get(
joinPaths(
gon.relative_url_root || '/',
projectId,
'-',
'raw',
ref,
escapeFileUrl(filePath),
),
{
transformResponse: [(f) => f],
},
)
.then(({ data }) => data);
},
getProjectMergeRequests(projectId, params = {}) {
return Api.projectMergeRequests(projectId, params);
},
getProjectMergeRequestData(projectId, mergeRequestId, params = {}) {
return Api.projectMergeRequest(projectId, mergeRequestId, params);
},
getProjectMergeRequestChanges(projectId, mergeRequestId) {
return Api.projectMergeRequestChanges(projectId, mergeRequestId);
},
getProjectMergeRequestVersions(projectId, mergeRequestId) {
return Api.projectMergeRequestVersions(projectId, mergeRequestId);
},
getBranchData(projectId, currentBranchId) {
return Api.branchSingle(projectId, currentBranchId);
},
commit(projectId, payload) {
return Api.commitMultiple(projectId, payload);
},
getFiles(projectPath, ref) {
const url = `${gon.relative_url_root}/${projectPath}/-/files/${ref}`;
return axios.get(url, { params: { format: 'json' } });
},
lastCommitPipelines({ getters }) {
const commitSha = getters.lastCommit.id;
return Api.commitPipelines(getters.currentProject.path_with_namespace, commitSha);
},
getProjectPermissionsData(projectPath) {
return query({
query: getIdeProject,
variables: { projectPath },
}).then(({ data }) => ({
...data.project,
id: getIdFromGraphQLId(data.project.id),
}));
},
};

View File

@ -1,17 +0,0 @@
import axios from '~/lib/utils/axios_utils';
import { joinPaths } from '~/lib/utils/url_utility';
export const baseUrl = (projectPath) =>
joinPaths(gon.relative_url_root || '', `/${projectPath}/ide_terminals`);
export const checkConfig = (projectPath, branch) =>
axios.post(`${baseUrl(projectPath)}/check_config`, {
branch,
format: 'json',
});
export const create = (projectPath, branch) =>
axios.post(baseUrl(projectPath), {
branch,
format: 'json',
});

Some files were not shown because too many files have changed in this diff Show More