diff --git a/.rubocop_todo/layout/first_array_element_indentation.yml b/.rubocop_todo/layout/first_array_element_indentation.yml
index a6d75b57b2f..7c07126f698 100644
--- a/.rubocop_todo/layout/first_array_element_indentation.yml
+++ b/.rubocop_todo/layout/first_array_element_indentation.yml
@@ -61,26 +61,6 @@ Layout/FirstArrayElementIndentation:
- 'spec/models/ci/daily_build_group_report_result_spec.rb'
- 'spec/models/ci/pipeline_spec.rb'
- 'spec/models/ci/runner_version_spec.rb'
- - 'spec/models/ci/unit_test_spec.rb'
- - 'spec/models/clusters/applications/cert_manager_spec.rb'
- - 'spec/models/clusters/platforms/kubernetes_spec.rb'
- - 'spec/models/commit_collection_spec.rb'
- - 'spec/models/compare_spec.rb'
- - 'spec/models/concerns/id_in_ordered_spec.rb'
- - 'spec/models/concerns/noteable_spec.rb'
- - 'spec/models/diff_note_spec.rb'
- - 'spec/models/discussion_spec.rb'
- - 'spec/models/group_spec.rb'
- - 'spec/models/integration_spec.rb'
- - 'spec/models/integrations/chat_message/issue_message_spec.rb'
- - 'spec/models/integrations/chat_message/wiki_page_message_spec.rb'
- - 'spec/models/integrations/jira_spec.rb'
- - 'spec/models/label_note_spec.rb'
- - 'spec/models/merge_request/cleanup_schedule_spec.rb'
- - 'spec/models/merge_request_diff_spec.rb'
- - 'spec/models/merge_request_spec.rb'
- - 'spec/models/operations/feature_flags/strategy_spec.rb'
- - 'spec/models/project_group_link_spec.rb'
- 'spec/models/repository_spec.rb'
- 'spec/models/user_preference_spec.rb'
- 'spec/models/user_spec.rb'
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION
index 207b5d92e1e..b6ad514204b 100644
--- a/GITALY_SERVER_VERSION
+++ b/GITALY_SERVER_VERSION
@@ -1 +1 @@
-2ff0039f15ef06063925ff6c0406f6e092ad2435
+5cba52f4acb04ddbe27d8b7cb2e936ea0be45ae1
diff --git a/app/assets/javascripts/content_editor/components/reference_dropdown.vue b/app/assets/javascripts/content_editor/components/reference_dropdown.vue
new file mode 100644
index 00000000000..7869df31a4d
--- /dev/null
+++ b/app/assets/javascripts/content_editor/components/reference_dropdown.vue
@@ -0,0 +1,146 @@
+
+
+
+
+
+
diff --git a/app/assets/javascripts/content_editor/extensions/heading.js b/app/assets/javascripts/content_editor/extensions/heading.js
index 48303cdeca4..41903162ba5 100644
--- a/app/assets/javascripts/content_editor/extensions/heading.js
+++ b/app/assets/javascripts/content_editor/extensions/heading.js
@@ -1 +1,15 @@
-export { Heading as default } from '@tiptap/extension-heading';
+import { Heading } from '@tiptap/extension-heading';
+import { textblockTypeInputRule } from '@tiptap/core';
+
+export default Heading.extend({
+ addInputRules() {
+ return this.options.levels.map((level) => {
+ return textblockTypeInputRule({
+ // make sure heading regex doesn't conflict with issue references
+ find: new RegExp(`^(#{1,${level}})[ \t]$`),
+ type: this.type,
+ getAttributes: { level },
+ });
+ });
+ },
+});
diff --git a/app/assets/javascripts/content_editor/extensions/reference.js b/app/assets/javascripts/content_editor/extensions/reference.js
index 5e459e65de2..9ac8129335e 100644
--- a/app/assets/javascripts/content_editor/extensions/reference.js
+++ b/app/assets/javascripts/content_editor/extensions/reference.js
@@ -57,7 +57,7 @@ export default Node.create({
'a',
{
class: node.attrs.className,
- href: node.attrs.href,
+ href: '#',
'data-reference-type': node.attrs.referenceType,
'data-original': node.attrs.originalText,
},
diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js
new file mode 100644
index 00000000000..b43357c1fb9
--- /dev/null
+++ b/app/assets/javascripts/content_editor/extensions/suggestions.js
@@ -0,0 +1,150 @@
+import { Node } from '@tiptap/core';
+import { VueRenderer } from '@tiptap/vue-2';
+import tippy from 'tippy.js';
+import Suggestion from '@tiptap/suggestion';
+import { PluginKey } from 'prosemirror-state';
+import { isFunction } from 'lodash';
+import axios from '~/lib/utils/axios_utils';
+import Reference from '../components/reference_dropdown.vue';
+
+function createSuggestionPlugin({ editor, char, dataSource, search, referenceProps }) {
+ return Suggestion({
+ editor,
+ char,
+ pluginKey: new PluginKey(`reference_${referenceProps.referenceType}`),
+ command: ({ editor: tiptapEditor, range, props }) => {
+ tiptapEditor
+ .chain()
+ .focus()
+ .insertContentAt(range, [{ type: 'reference', attrs: props }])
+ .run();
+ },
+
+ async items({ query }) {
+ if (!dataSource) return [];
+
+ try {
+ const items = await (isFunction(dataSource) ? dataSource() : axios.get(dataSource));
+ return items.data.filter(search(query));
+ } catch {
+ return [];
+ }
+ },
+
+ render: () => {
+ let component;
+ let popup;
+
+ return {
+ onStart: (props) => {
+ component = new VueRenderer(Reference, {
+ propsData: {
+ ...props,
+ char,
+ referenceProps,
+ },
+ editor: props.editor,
+ });
+
+ if (!props.clientRect) {
+ return;
+ }
+
+ popup = tippy('body', {
+ getReferenceClientRect: props.clientRect,
+ appendTo: () => document.body,
+ content: component.element,
+ showOnCreate: true,
+ interactive: true,
+ trigger: 'manual',
+ placement: 'bottom-start',
+ });
+ },
+
+ onUpdate(props) {
+ component.updateProps(props);
+
+ if (!props.clientRect) {
+ return;
+ }
+
+ popup?.[0].setProps({
+ getReferenceClientRect: props.clientRect,
+ });
+ },
+
+ onKeyDown(props) {
+ if (props.event.key === 'Escape') {
+ popup?.[0].hide();
+
+ return true;
+ }
+
+ return component.ref?.onKeyDown(props);
+ },
+
+ onExit() {
+ popup?.[0].destroy();
+ component.destroy();
+ },
+ };
+ },
+ });
+}
+
+export default Node.create({
+ name: 'suggestions',
+
+ addProseMirrorPlugins() {
+ return [
+ createSuggestionPlugin({
+ editor: this.editor,
+ char: '@',
+ dataSource: gl.GfmAutoComplete?.dataSources.members,
+ referenceProps: {
+ className: 'gfm gfm-project_member',
+ referenceType: 'user',
+ },
+ search: (query) => ({ name, username }) =>
+ name.toLocaleLowerCase().includes(query.toLocaleLowerCase()) ||
+ username.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
+ }),
+ createSuggestionPlugin({
+ editor: this.editor,
+ char: '#',
+ dataSource: gl.GfmAutoComplete?.dataSources.issues,
+ referenceProps: {
+ className: 'gfm gfm-issue',
+ referenceType: 'issue',
+ },
+ search: (query) => ({ iid, title }) =>
+ String(iid).toLocaleLowerCase().includes(query.toLocaleLowerCase()) ||
+ title.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
+ }),
+ createSuggestionPlugin({
+ editor: this.editor,
+ char: '!',
+ dataSource: gl.GfmAutoComplete?.dataSources.mergeRequests,
+ referenceProps: {
+ className: 'gfm gfm-issue',
+ referenceType: 'merge_request',
+ },
+ search: (query) => ({ iid, title }) =>
+ String(iid).toLocaleLowerCase().includes(query.toLocaleLowerCase()) ||
+ title.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
+ }),
+ createSuggestionPlugin({
+ editor: this.editor,
+ char: '%',
+ dataSource: gl.GfmAutoComplete?.dataSources.milestones,
+ referenceProps: {
+ className: 'gfm gfm-milestone',
+ referenceType: 'milestone',
+ },
+ search: (query) => ({ iid, title }) =>
+ String(iid).toLocaleLowerCase().includes(query.toLocaleLowerCase()) ||
+ title.toLocaleLowerCase().includes(query.toLocaleLowerCase()),
+ }),
+ ];
+ },
+});
diff --git a/app/assets/javascripts/content_editor/services/create_content_editor.js b/app/assets/javascripts/content_editor/services/create_content_editor.js
index 5ed7f3dc23d..a4621ce0c04 100644
--- a/app/assets/javascripts/content_editor/services/create_content_editor.js
+++ b/app/assets/javascripts/content_editor/services/create_content_editor.js
@@ -46,6 +46,7 @@ import ReferenceDefinition from '../extensions/reference_definition';
import Sourcemap from '../extensions/sourcemap';
import Strike from '../extensions/strike';
import Subscript from '../extensions/subscript';
+import Suggestions from '../extensions/suggestions';
import Superscript from '../extensions/superscript';
import Table from '../extensions/table';
import TableCell from '../extensions/table_cell';
@@ -133,6 +134,7 @@ export const createContentEditor = ({
Sourcemap,
Strike,
Subscript,
+ Suggestions,
Superscript,
TableCell,
TableHeader,
diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue
index c2ab7c4f298..21bf11f7b93 100644
--- a/app/assets/javascripts/issues/show/components/fields/description.vue
+++ b/app/assets/javascripts/issues/show/components/fields/description.vue
@@ -38,7 +38,12 @@ export default {
},
},
mounted() {
- this.$refs.textarea.focus();
+ this.focus();
+ },
+ methods: {
+ focus() {
+ this.$refs.textarea?.focus();
+ },
},
};
diff --git a/app/assets/javascripts/issues/show/components/form.vue b/app/assets/javascripts/issues/show/components/form.vue
index f479c8ae78d..0c6b61fb893 100644
--- a/app/assets/javascripts/issues/show/components/form.vue
+++ b/app/assets/javascripts/issues/show/components/form.vue
@@ -1,7 +1,6 @@
@@ -194,7 +182,7 @@ export default {
>