diff --git a/app/assets/javascripts/blob/blob_utils.js b/app/assets/javascripts/blob/blob_utils.js new file mode 100644 index 00000000000..27fcc7f7b79 --- /dev/null +++ b/app/assets/javascripts/blob/blob_utils.js @@ -0,0 +1,5 @@ +// capture anything starting with http:// or https:// +// up until a disallowed character or whitespace +export const blobLinkRegex = /https?:\/\/[^"<>\\^`{|}\s]+/g; + +export default { blobLinkRegex }; diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js index 07e4dde41d9..f032c2f216b 100644 --- a/app/assets/javascripts/blob/viewer/index.js +++ b/app/assets/javascripts/blob/viewer/index.js @@ -4,6 +4,10 @@ import Flash from '../../flash'; import { handleLocationHash } from '../../lib/utils/common_utils'; import axios from '../../lib/utils/axios_utils'; import { __ } from '~/locale'; +import { blobLinkRegex } from '~/blob/blob_utils'; + +const SIMPLE_VIEWER_NAME = 'simple'; +const RICH_VIEWER_NAME = 'rich'; export default class BlobViewer { constructor() { @@ -21,7 +25,7 @@ export default class BlobViewer { } static initRichViewer() { - const viewer = document.querySelector('.blob-viewer[data-type="rich"]'); + const viewer = document.querySelector(`.blob-viewer[data-type="${RICH_VIEWER_NAME}"]`); if (!viewer || !viewer.dataset.richType) return; const initViewer = promise => @@ -61,8 +65,12 @@ export default class BlobViewer { this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn'); this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn'); - this.simpleViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="simple"]'); - this.richViewer = this.$fileHolder[0].querySelector('.blob-viewer[data-type="rich"]'); + this.simpleViewer = this.$fileHolder[0].querySelector( + `.blob-viewer[data-type="${SIMPLE_VIEWER_NAME}"]`, + ); + this.richViewer = this.$fileHolder[0].querySelector( + `.blob-viewer[data-type="${RICH_VIEWER_NAME}"]`, + ); this.initBindings(); @@ -71,10 +79,10 @@ export default class BlobViewer { switchToInitialViewer() { const initialViewer = this.$fileHolder[0].querySelector('.blob-viewer:not(.hidden)'); - let initialViewerName = initialViewer.getAttribute('data-type'); + let initialViewerName = initialViewer.dataset.type; if (this.switcher && window.location.hash.indexOf('#L') === 0) { - initialViewerName = 'simple'; + initialViewerName = SIMPLE_VIEWER_NAME; } this.switchToViewer(initialViewerName); @@ -91,35 +99,41 @@ export default class BlobViewer { this.copySourceBtn.addEventListener('click', () => { if (this.copySourceBtn.classList.contains('disabled')) return this.copySourceBtn.blur(); - return this.switchToViewer('simple'); + return this.switchToViewer(SIMPLE_VIEWER_NAME); }); } } + static linkifyURLs(viewer) { + if (viewer.dataset.linkified) return; + + document.querySelectorAll('.js-blob-content .code .line').forEach(line => { + // eslint-disable-next-line no-param-reassign + line.innerHTML = line.innerHTML.replace(blobLinkRegex, '$&'); + }); + + // eslint-disable-next-line no-param-reassign + viewer.dataset.linkified = true; + } + switchViewHandler(e) { const target = e.currentTarget; e.preventDefault(); - this.switchToViewer(target.getAttribute('data-viewer')); + this.switchToViewer(target.dataset.viewer); } toggleCopyButtonState() { if (!this.copySourceBtn) return; - if (this.simpleViewer.getAttribute('data-loaded')) { - this.copySourceBtn.setAttribute('title', __('Copy file contents')); + if (this.simpleViewer.dataset.loaded) { + this.copySourceBtn.dataset.title = __('Copy file contents'); this.copySourceBtn.classList.remove('disabled'); } else if (this.activeViewer === this.simpleViewer) { - this.copySourceBtn.setAttribute( - 'title', - __('Wait for the file to load to copy its contents'), - ); + this.copySourceBtn.dataset.title = __('Wait for the file to load to copy its contents'); this.copySourceBtn.classList.add('disabled'); } else { - this.copySourceBtn.setAttribute( - 'title', - __('Switch to the source to copy the file contents'), - ); + this.copySourceBtn.dataset.title = __('Switch to the source to copy the file contents'); this.copySourceBtn.classList.add('disabled'); } @@ -159,6 +173,8 @@ export default class BlobViewer { this.$fileHolder.trigger('highlight:line'); handleLocationHash(); + if (name === SIMPLE_VIEWER_NAME) BlobViewer.linkifyURLs(viewer); + this.toggleCopyButtonState(); }) .catch(() => new Flash(__('Error loading viewer'))); @@ -166,17 +182,17 @@ export default class BlobViewer { static loadViewer(viewerParam) { const viewer = viewerParam; - const url = viewer.getAttribute('data-url'); + const { url, loaded, loading } = viewer.dataset; - if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) { + if (!url || loaded || loading) { return Promise.resolve(viewer); } - viewer.setAttribute('data-loading', 'true'); + viewer.dataset.loading = true; return axios.get(url).then(({ data }) => { viewer.innerHTML = data.html; - viewer.setAttribute('data-loaded', 'true'); + viewer.dataset.loaded = true; return viewer; }); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 011898a5e7a..8561f650e8f 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -4,7 +4,8 @@ import $ from 'jquery'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; import { __ } from '~/locale'; -import TemplateSelectorMediator from '../blob/file_template_mediator'; +import { blobLinkRegex } from '~/blob/blob_utils'; +import TemplateSelectorMediator from '~/blob/file_template_mediator'; import getModeByFileExtension from '~/lib/utils/ace_utils'; import { addEditorMarkdownListeners } from '~/lib/utils/text_markdown'; @@ -17,6 +18,7 @@ export default class EditBlob { this.initModePanesAndLinks(); this.initSoftWrap(); this.initFileSelectors(); + this.initBlobContentLinkClickability(); } configureAceEditor() { @@ -89,6 +91,22 @@ export default class EditBlob { return this.editor.focus(); } + initBlobContentLinkClickability() { + this.editor.renderer.on('afterRender', () => { + document.querySelectorAll('.ace_text-layer .ace_line > *').forEach(token => { + if (token.dataset.linkified || !token.textContent.includes('http')) return; + + // eslint-disable-next-line no-param-reassign + token.innerHTML = token.innerHTML.replace( + blobLinkRegex, + '$&', + ); + // eslint-disable-next-line no-param-reassign + token.dataset.linkified = true; + }); + }); + } + initSoftWrap() { this.isSoftWrapped = false; this.$toggleButton = $('.soft-wrap-toggle'); diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 487fbf0fcff..96dac7ba836 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -258,6 +258,17 @@ } } } + + .file-editor { + .ace_underline { + text-decoration: none; + } + + .ace_line a { + pointer-events: auto; + color: inherit; + } + } } span.idiff { diff --git a/app/assets/stylesheets/highlight/common.scss b/app/assets/stylesheets/highlight/common.scss index bdeac7e97c0..95f6fb8c333 100644 --- a/app/assets/stylesheets/highlight/common.scss +++ b/app/assets/stylesheets/highlight/common.scss @@ -29,3 +29,12 @@ color: $link; } } + +// Links to URLs, emails, or dependencies +.code .line a { + color: inherit; + + &:hover { + text-decoration: underline; + } +} diff --git a/app/assets/stylesheets/highlight/themes/dark.scss b/app/assets/stylesheets/highlight/themes/dark.scss index cbce0ba3f1e..16e6824baf8 100644 --- a/app/assets/stylesheets/highlight/themes/dark.scss +++ b/app/assets/stylesheets/highlight/themes/dark.scss @@ -193,11 +193,6 @@ $dark-il: #de935f; color: $dark-highlight-color !important; } - // Links to URLs, emails, or dependencies - .line a { - color: $dark-na; - } - .hll { background-color: $dark-hll-bg; } .c { color: $dark-c; } /* Comment */ .err { color: $dark-err; } /* Error */ diff --git a/app/assets/stylesheets/highlight/themes/monokai.scss b/app/assets/stylesheets/highlight/themes/monokai.scss index 1b61ffa37e3..cfbb7a1db94 100644 --- a/app/assets/stylesheets/highlight/themes/monokai.scss +++ b/app/assets/stylesheets/highlight/themes/monokai.scss @@ -193,11 +193,6 @@ $monokai-gi: #a6e22e; color: $black !important; } - // Links to URLs, emails, or dependencies - .line a { - color: $monokai-k; - } - .hll { background-color: $monokai-hll; } .c { color: $monokai-c; } /* Comment */ .err { color: $monokai-err-color; background-color: $monokai-err-bg; } /* Error */ diff --git a/app/assets/stylesheets/highlight/themes/none.scss b/app/assets/stylesheets/highlight/themes/none.scss index a7ede266fb5..a099563542d 100644 --- a/app/assets/stylesheets/highlight/themes/none.scss +++ b/app/assets/stylesheets/highlight/themes/none.scss @@ -143,12 +143,6 @@ background-color: $white-normal; } - // Links to URLs, emails, or dependencies - .line a { - color: $gl-text-color; - text-decoration: underline; - } - .hll { background-color: $white-light; } .gd { diff --git a/app/assets/stylesheets/highlight/themes/solarized-dark.scss b/app/assets/stylesheets/highlight/themes/solarized-dark.scss index 6569f3abc8b..d74d5c6ebda 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-dark.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-dark.scss @@ -196,11 +196,6 @@ $solarized-dark-il: #2aa198; background-color: $solarized-dark-highlight !important; } - // Links to URLs, emails, or dependencies - .line a { - color: $solarized-dark-kd; - } - /* Solarized Dark For use with Jekyll and Pygments diff --git a/app/assets/stylesheets/highlight/themes/solarized-light.scss b/app/assets/stylesheets/highlight/themes/solarized-light.scss index 4e74a9ea50a..d995c5bba1f 100644 --- a/app/assets/stylesheets/highlight/themes/solarized-light.scss +++ b/app/assets/stylesheets/highlight/themes/solarized-light.scss @@ -204,11 +204,6 @@ $solarized-light-il: #2aa198; background-color: $solarized-light-highlight !important; } - // Links to URLs, emails, or dependencies - .line a { - color: $solarized-light-kd; - } - /* Solarized Light For use with Jekyll and Pygments diff --git a/app/assets/stylesheets/highlight/white_base.scss b/app/assets/stylesheets/highlight/white_base.scss index 973f94c63aa..c58cf89f0ca 100644 --- a/app/assets/stylesheets/highlight/white_base.scss +++ b/app/assets/stylesheets/highlight/white_base.scss @@ -209,11 +209,6 @@ span.highlight_word { background-color: $white-highlight !important; } -// Links to URLs, emails, or dependencies -.line a { - color: $white-nb; -} - .hll { background-color: $white-hll-bg; } .c { color: $white-c; diff --git a/app/views/shared/_file_highlight.html.haml b/app/views/shared/_file_highlight.html.haml index d7e57fc0d01..4e609f50993 100644 --- a/app/views/shared/_file_highlight.html.haml +++ b/app/views/shared/_file_highlight.html.haml @@ -10,7 +10,7 @@ %a.diff-line-num{ href: "#{link}#L#{i}", id: "L#{i}", 'data-line-number' => i } = link_icon = i - .blob-content{ data: { blob_id: blob.id } } + .blob-content.js-blob-content{ data: { blob_id: blob.id } } %pre.code.highlight %code = blob.present.highlight diff --git a/changelogs/unreleased/mfluharty-clickable-links-in-file-view.yml b/changelogs/unreleased/mfluharty-clickable-links-in-file-view.yml new file mode 100644 index 00000000000..e6d42e10c19 --- /dev/null +++ b/changelogs/unreleased/mfluharty-clickable-links-in-file-view.yml @@ -0,0 +1,5 @@ +--- +title: Make URLs in blob viewer and blob editor into clickable links +merge_request: 18305 +author: +type: added diff --git a/doc/administration/geo/replication/configuration.md b/doc/administration/geo/replication/configuration.md index f09d9f20dab..44baab40153 100644 --- a/doc/administration/geo/replication/configuration.md +++ b/doc/administration/geo/replication/configuration.md @@ -187,14 +187,18 @@ keys must be manually replicated to the **secondary** node. 1. Visit the **primary** node's **Admin Area > Geo** (`/admin/geo/nodes`) in your browser. 1. Click the **New node** button. -1. Add the **secondary** node. Use the **exact** name you inputed for `gitlab_rails['geo_node_name']` as the Name and the full URL as the URL. **Do NOT** check the - **This is a primary node** checkbox. - ![Add secondary node](img/adding_a_secondary_node.png) +1. Fill in **Name** with the `gitlab_rails['geo_node_name']` in + `/etc/gitlab/gitlab.rb`. These values must always match *exactly*, character + for character. +1. Fill in **URL** with the `external_url` in `/etc/gitlab/gitlab.rb`. These + values must always match, but it doesn't matter if one ends with a `/` and + the other doesn't. +1. **Do NOT** check the **This is a primary node** checkbox. 1. Optionally, choose which groups or storage shards should be replicated by the **secondary** node. Leave blank to replicate all. Read more in [selective synchronization](#selective-synchronization). -1. Click the **Add node** button. +1. Click the **Add node** button to add the **secondary** node. 1. SSH into your GitLab **secondary** server and restart the services: ```sh diff --git a/doc/administration/geo/replication/troubleshooting.md b/doc/administration/geo/replication/troubleshooting.md index 4d64941411a..0c74e2d6ac7 100644 --- a/doc/administration/geo/replication/troubleshooting.md +++ b/doc/administration/geo/replication/troubleshooting.md @@ -115,11 +115,19 @@ Any **secondary** nodes should point only to read-only instances. #### Can Geo detect the current node correctly? -Geo uses the defined node from the **Admin Area > Geo** screen, and tries to match -it with the value defined in the `/etc/gitlab/gitlab.rb` configuration file. -The relevant line looks like: `external_url "http://gitlab.example.com"`. +Geo finds the current machine's name in `/etc/gitlab/gitlab.rb` by first looking +for `gitlab_rails['geo_node_name']`. If it is not defined, then it defaults to +the external URL defined in e.g. `external_url "http://gitlab.example.com"`. To +get a machine's name, run: -To check if the node on the current machine is correctly detected type: +```sh +sudo gitlab-rails runner "puts GeoNode.current_node_name" +``` + +This name is used to look up the node with the same **Name** in +**Admin Area > Geo**. + +To check if current machine is correctly finding its node: ```sh sudo gitlab-rails runner "puts Gitlab::Geo.current_node.inspect" @@ -511,6 +519,20 @@ to [cleanup orphan artifact files](../../../raketasks/cleanup.md#remove-orphan-a On a Geo **secondary** node, this command will also clean up all Geo registry record related to the orphan files on disk. +## Fixing sign in errors + +### Message: The redirect URI included is not valid + +If you are able to log in to the **primary** node, but you receive this error +when attempting to log into a **secondary**, you should check that the Geo +node's URL matches its external URL. + +1. On the primary, visit **Admin Area > Geo**. +1. Find the affected **secondary** and click **Edit**. +1. Ensure the **URL** field matches the value found in `/etc/gitlab/gitlab.rb` + in `external_url "https://gitlab.example.com"` on the frontend server(s) of + the **secondary** node. + ## Fixing common errors This section documents common errors reported in the Admin UI and how to fix them. diff --git a/doc/ci/multi_project_pipelines.md b/doc/ci/multi_project_pipelines.md index 093d334e937..f13d05716f1 100644 --- a/doc/ci/multi_project_pipelines.md +++ b/doc/ci/multi_project_pipelines.md @@ -87,10 +87,9 @@ not be found, or a user does not have access rights to create pipeline there, the `staging` job is going to be marked as _failed_. CAUTION: **Caution:** -`staging` will succeed as soon as a downstream pipeline gets created. -GitLab does not support status attribution yet, however adding first-class -`trigger` configuration syntax is ground work for implementing -[status attribution](https://gitlab.com/gitlab-org/gitlab-foss/issues/39640). +In the example, `staging` will be marked as succeeded as soon as a downstream pipeline +gets created. If you want to display the downstream pipeline's status instead, see +[Mirroring status from triggered pipeline](#mirroring-status-from-triggered-pipeline). NOTE: **Note:** Bridge jobs do not support every configuration entry that a user can use diff --git a/doc/development/profiling.md b/doc/development/profiling.md index 04897e770f8..18683fa10f8 100644 --- a/doc/development/profiling.md +++ b/doc/development/profiling.md @@ -42,6 +42,10 @@ Passing a `logger:` keyword argument to `Gitlab::Profiler.profile` will send ActiveRecord and ActionController log output to that logger. Further options are documented with the method source. +```ruby +Gitlab::Profiler.profile('/gitlab-org/gitlab-test', user: User.first, logger: Logger.new(STDOUT)) +``` + There is also a RubyProf printer available: `Gitlab::Profiler::TotalTimeFlatPrinter`. This acts like `RubyProf::FlatPrinter`, but its `min_percent` option works on the method's diff --git a/qa/qa/page/project/issue/show.rb b/qa/qa/page/project/issue/show.rb index 8f5632f7e6e..6ec80b7c9cc 100644 --- a/qa/qa/page/project/issue/show.rb +++ b/qa/qa/page/project/issue/show.rb @@ -108,6 +108,10 @@ module QA find_element(:more_assignees_link) end + def noteable_note_item + find_element(:noteable_note_item) + end + def select_all_activities_filter select_filter_with_text('Show all activity') end diff --git a/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb b/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb index 04ae4963d3a..300bf59eba4 100644 --- a/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb +++ b/qa/qa/specs/features/browser_ui/2_plan/issue/create_issue_spec.rb @@ -21,8 +21,9 @@ module QA end context 'when using attachments in comments', :object_storage do + let(:gif_file_name) { 'banana_sample.gif' } let(:file_to_attach) do - File.absolute_path(File.join('spec', 'fixtures', 'banana_sample.gif')) + File.absolute_path(File.join('spec', 'fixtures', gif_file_name)) end before do @@ -37,15 +38,7 @@ module QA Page::Project::Issue::Show.perform do |show| show.comment('See attached banana for scale', attachment: file_to_attach) - show.refresh - - image_url = find('a[href$="banana_sample.gif"]')[:href] - - found = show.wait(reload: false) do - show.asset_exists?(image_url) - end - - expect(found).to be_truthy + expect(show.noteable_note_item.find("img[src$='#{gif_file_name}']")).to be_visible end end end diff --git a/spec/features/projects/blobs/edit_spec.rb b/spec/features/projects/blobs/edit_spec.rb index 3b32d213754..e02a4579095 100644 --- a/spec/features/projects/blobs/edit_spec.rb +++ b/spec/features/projects/blobs/edit_spec.rb @@ -60,6 +60,13 @@ describe 'Editing file blob', :js do expect(page).to have_content 'NextFeature' end + it 'renders a URL in the content of file as a link' do + project.repository.create_file(user, 'file.yml', '# go to https://gitlab.com', message: 'testing', branch_name: branch) + visit project_edit_blob_path(project, tree_join(branch, 'file.yml')) + + expect(page).to have_selector('.ace_content .ace_line a') + end + context 'from blob file path' do before do visit project_blob_path(project, tree_join(branch, file_path)) diff --git a/spec/javascripts/blob/viewer/index_spec.js b/spec/javascripts/blob/viewer/index_spec.js index 06c06613887..bbc59632f3c 100644 --- a/spec/javascripts/blob/viewer/index_spec.js +++ b/spec/javascripts/blob/viewer/index_spec.js @@ -11,6 +11,13 @@ describe('Blob viewer', () => { preloadFixtures('snippets/show.html'); + const asyncClick = () => + new Promise(resolve => { + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); + + setTimeout(resolve); + }); + beforeEach(() => { mock = new MockAdapter(axios); @@ -66,19 +73,12 @@ describe('Blob viewer', () => { }); it('doesnt reload file if already loaded', done => { - const asyncClick = () => - new Promise(resolve => { - document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); - - setTimeout(resolve); - }); - asyncClick() .then(() => asyncClick()) .then(() => { - expect( - document.querySelector('.blob-viewer[data-type="simple"]').getAttribute('data-loaded'), - ).toBe('true'); + expect(document.querySelector('.blob-viewer[data-type="simple"]').dataset.loaded).toBe( + 'true', + ); done(); }) @@ -100,9 +100,7 @@ describe('Blob viewer', () => { }); it('has tooltip when disabled', () => { - expect(copyButton.getAttribute('data-original-title')).toBe( - 'Switch to the source to copy the file contents', - ); + expect(copyButton.dataset.title).toBe('Switch to the source to copy the file contents'); }); it('is blurred when clicked and disabled', () => { @@ -136,7 +134,7 @@ describe('Blob viewer', () => { document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); setTimeout(() => { - expect(copyButton.getAttribute('data-original-title')).toBe('Copy file contents'); + expect(copyButton.dataset.title).toBe('Copy file contents'); done(); }); @@ -177,4 +175,27 @@ describe('Blob viewer', () => { expect(axios.get.calls.count()).toBe(1); }); }); + + describe('a URL inside the blob content', () => { + beforeEach(() => { + mock.onGet('http://test.host/snippets/1.json?viewer=simple').reply(200, { + html: + '
To install gitlab-shell you also need a Go compiler version 1.8 or newer. https://golang.org/dl/
', + }); + }); + + it('is rendered as a link in simple view', done => { + asyncClick() + .then(() => { + expect(document.querySelector('.blob-viewer[data-type="simple"]').innerHTML).toContain( + 'https://golang.org/dl/', + ); + done(); + }) + .catch(() => { + fail(); + done(); + }); + }); + }); });