diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index a3d11d90ed295b1e1d85dcc1deab87dc8231e15a..e2575a80780aeff50a70db363ad3c218350676be 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { FileTemplateExtension } from '~/editor/extensions/source_editor_file_template_ext'; +import { SnippetsExtension } from '~/editor/extensions/source_editor_snippets_ext'; import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext'; import SourceEditor from '~/editor/source_editor'; import { createAlert } from '~/flash'; @@ -71,6 +72,7 @@ export default class EditBlob { { definition: SourceEditorExtension }, { definition: FileTemplateExtension }, { definition: ToolbarExtension }, + { definition: SnippetsExtension }, ]); fileNameEl.addEventListener('change', () => { diff --git a/app/assets/javascripts/editor/components/source_editor_quickfind_widget.vue b/app/assets/javascripts/editor/components/source_editor_quickfind_widget.vue new file mode 100644 index 0000000000000000000000000000000000000000..a2668285de835d678c8223621d5aa089fe01fda6 --- /dev/null +++ b/app/assets/javascripts/editor/components/source_editor_quickfind_widget.vue @@ -0,0 +1,122 @@ + + diff --git a/app/assets/javascripts/editor/extensions/source_editor_snippets_ext.js b/app/assets/javascripts/editor/extensions/source_editor_snippets_ext.js new file mode 100644 index 0000000000000000000000000000000000000000..d46670171c14f1ff9db09918f91102f6e8122152 --- /dev/null +++ b/app/assets/javascripts/editor/extensions/source_editor_snippets_ext.js @@ -0,0 +1,199 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import GetBlobContent from 'shared_queries/snippet/snippet_blob_content.query.graphql'; +import QuickFindWidget from '../components/source_editor_quickfind_widget.vue'; + +const EXTENSION_SNIPPET_INSERT_ACTION_ID = 'snippet-insert'; +const EXTENSION_SNIPPET_CREATE_ACTION_ID = 'snippet-create'; + +const EXTENSION_SNIPPET_INSERT_LABEL = 'Insert Snippet'; +const EXTENSION_SNIPPET_CREATE_LABEL = 'Create Snippet from selection'; + +Vue.use(VueApollo); + +class QuickFinderWidget { + constructor(codeEditor) { + this.codeEditor = codeEditor; + const wrapper = document.createElement('div'); + const content = document.createElement('div'); + wrapper.appendChild(content); + this.domNode = wrapper; + this.contentNode = content; + this.codeEditor.addOverlayWidget(this); + } + getId() { + return 'se.extension.snippets.file.widget'; + } + getDomNode() { + return this.domNode; + } + getContentNode() { + return this.contentNode; + } + getPosition() { + return { preference: 2 /* TOP_CENTER */ }; + } + dispose() { + this.codeEditor.removeOverlayWidget(this); + } +} + +export class SnippetsExtension { + /** + * A required getter returning the extension's name + * We have to provide it for every extension instead of relying on the built-in + * `name` prop because the prop does not survive the webpack's minification + * and the name mangling. + * @returns {string} + */ + static get extensionName() { + return 'SnippetsExtension'; + } + + /** + * Is called before the extension gets used by an instance, + * Use `onSetup` to setup Monaco directly: + * actions, keystrokes, update options, etc. + * Is called only once before the extension gets registered + * + * @param { Object } [instance] The Source Editor instance + * @param { Object } [setupOptions] The setupOptions object + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars + onSetup(instance, setupOptions) { + this.hasSelectionKey = instance.createContextKey('hasSelection', false); + this.finderWidget = new QuickFinderWidget(instance); + instance.onContextMenu(() => { + if(!instance.getSelection().isEmpty()) { + this.hasSelectionKey.set(true); + } else { + this.hasSelectionKey.set(false); + } + }); + this.setupInsertAction(instance); + // this.setupCreateAction(instance); + } + + setupVue(el, instance) { + const defaultClient = createDefaultClient(); + const apolloProvider = new VueApollo({ + defaultClient, + }); + const QuickFindComponent = Vue.extend(QuickFindWidget); + + this.finder = new QuickFindComponent({ + el, + apolloProvider, + }).$mount(); + this.finder.$on('snippet-selected', (snippet, path) => { + defaultClient.query({ + query: GetBlobContent, + variables: { + ids: [snippet.id], + rich: false, + paths: [path || snippet.blobs.nodes[0].name], + }, + }).then((result) => { + this.pasteContentAtCursor(result.data.snippets.nodes[0].blobs.nodes[0].rawPlainData, instance); + }); + }); + } + + pasteContentAtCursor(text, instance) { + instance.executeEdits('', [{ range: instance.getSelection(), text, forceMoveMarkers: true }]); + this.finderWidget.dispose(); + } + + setupInsertAction(instance) { + if (instance.getAction(EXTENSION_SNIPPET_INSERT_ACTION_ID)) return; + const actionBasis = { + contextMenuGroupId: 'navigation', + contextMenuOrder: 1.5, + // Method that will be executed when the action is triggered. + // @param ed The editor instance is passed in as a convenience + run(inst) { + inst.insertSnippetRequested(); + }, + }; + + instance.addAction({ + ...actionBasis, + id: EXTENSION_SNIPPET_INSERT_ACTION_ID, + label: EXTENSION_SNIPPET_INSERT_LABEL, + }); + } + + // setupCreateAction(instance) { + // if (instance.getAction(EXTENSION_SNIPPET_CREATE_ACTION_ID)) return; + // const actionBasis = { + // contextMenuGroupId: 'navigation', + // contextMenuOrder: 1.6, + // // Method that will be executed when the action is triggered. + // // @param ed The editor instance is passed in as a convenience + // run(inst) { + // inst.createSnippetRequested(); + // }, + // }; + + // instance.addAction({ + // ...actionBasis, + // id: EXTENSION_SNIPPET_CREATE_ACTION_ID, + // label: EXTENSION_SNIPPET_CREATE_LABEL, + // precondition: 'hasSelection', + // }); + // } + + // fetchSnippets() { + // return gqClient + // .query({ + // query: GetUserSnippetsQuery, + // }) + // .then(({ data }) => { + // const snippets = data.currentUser.snippets.nodes; + // const widgetNode = this.finderWidget.getDomNode(); + // const widgetContent = widgetNode.querySelector('quick-input-list'); + // const frag = new DocumentFragment(); + // snippets.forEach((snippet) => { + // const node = document.createElement('div'); + // node.classList.add('quick-input-list-entry', 'quick-input-list-separator-border'); + // node.innerHTML = '<>' + // frag.appendChild(node); + // }); + // // .innerHTML = this.snippets; + // }) + // .catch((e) => { + // debgugger; + // }); + // } + + showQuickFinderWidget(instance) { + this.finderWidget = new QuickFinderWidget(instance); + this.setupVue(this.finderWidget.getContentNode(), instance); + } + + /** + * Is called right after an extension is removed from an instance (un-used) + * Can be used for non time-critical tasks like cleanup on the Monaco level + * (removing actions, keystrokes, etc.). + * onUnuse() will be executed during the browser's idle period + * (https://developer.mozilla.org/en-US/docs/Web/API/Window/requestIdleCallback) + * + * @param { Object } [instance] The Source Editor instance + */ + // eslint-disable-next-line class-methods-use-this,no-unused-vars + onUnuse(instance) {} + + /** + * The public API of the extension: these are the methods that will be exposed + * to the end user + * @returns {Object} + */ + provides() { + return { + insertSnippetRequested: (instance) => { + this.showQuickFinderWidget(instance); + }, + }; + } +} diff --git a/app/graphql/queries/repository/user_snippets.query.graphql b/app/graphql/queries/repository/user_snippets.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..0f101aacba362d68c0b712d5108b512eb6ffd93c --- /dev/null +++ b/app/graphql/queries/repository/user_snippets.query.graphql @@ -0,0 +1,25 @@ +query getUserSnippetsQuery { + currentUser { + __typename + snippets { + __typename + nodes { + __typename + id + title + description + blobs { + __typename + nodes { + __typename + name + binary + simpleViewer { + tooLarge + } + } + } + } + } + } +} diff --git a/app/graphql/queries/snippet/snippet_blob_content.query.graphql b/app/graphql/queries/snippet/snippet_blob_content.query.graphql index 0ac7d4d23a0928f90eac969b8a47a1a9f5b1734b..4cc0b2fae113a164ae2767f3f3ad39f9f6404d07 100644 --- a/app/graphql/queries/snippet/snippet_blob_content.query.graphql +++ b/app/graphql/queries/snippet/snippet_blob_content.query.graphql @@ -11,6 +11,7 @@ query SnippetBlobContent($ids: [SnippetID!], $rich: Boolean!, $paths: [String!]) path richData @include(if: $rich) plainData @skip(if: $rich) + rawPlainData @skip(if: $rich) } hasUnretrievableBlobs }