diff --git a/app/assets/javascripts/blob/pdf/index.js b/app/assets/javascripts/blob/pdf/index.js index a74c2db9a61f181ee97afb4f54de7fb2b6433079..9161be98853f3b86bc5f8e31dca8ca815569cdda 100644 --- a/app/assets/javascripts/blob/pdf/index.js +++ b/app/assets/javascripts/blob/pdf/index.js @@ -31,7 +31,7 @@ export default () => { }, }, template: ` - <div class="container-fluid md prepend-top-default append-bottom-default"> + <div class="js-pdf-viewer container-fluid md prepend-top-default append-bottom-default"> <div class="text-center loading" v-if="loading && !error"> diff --git a/app/assets/javascripts/blob/viewer/index.js b/app/assets/javascripts/blob/viewer/index.js new file mode 100644 index 0000000000000000000000000000000000000000..7efa85372982e7366e2bea3c7acf1f5dc7508433 --- /dev/null +++ b/app/assets/javascripts/blob/viewer/index.js @@ -0,0 +1,120 @@ +/* global Flash */ +export default class BlobViewer { + constructor() { + this.switcher = document.querySelector('.js-blob-viewer-switcher'); + this.switcherBtns = document.querySelectorAll('.js-blob-viewer-switch-btn'); + this.copySourceBtn = document.querySelector('.js-copy-blob-source-btn'); + this.simpleViewer = document.querySelector('.blob-viewer[data-type="simple"]'); + this.richViewer = document.querySelector('.blob-viewer[data-type="rich"]'); + this.$blobContentHolder = $('#blob-content-holder'); + + let initialViewerName = document.querySelector('.blob-viewer:not(.hidden)').getAttribute('data-type'); + + this.initBindings(); + + if (this.switcher && location.hash.indexOf('#L') === 0) { + initialViewerName = 'simple'; + } + + this.switchToViewer(initialViewerName); + } + + initBindings() { + if (this.switcherBtns.length) { + Array.from(this.switcherBtns) + .forEach((el) => { + el.addEventListener('click', this.switchViewHandler.bind(this)); + }); + } + + if (this.copySourceBtn) { + this.copySourceBtn.addEventListener('click', () => { + if (this.copySourceBtn.classList.contains('disabled')) return; + + this.switchToViewer('simple'); + }); + } + } + + switchViewHandler(e) { + const target = e.currentTarget; + + e.preventDefault(); + + this.switchToViewer(target.getAttribute('data-viewer')); + } + + toggleCopyButtonState() { + if (!this.copySourceBtn) return; + + if (this.simpleViewer.getAttribute('data-loaded')) { + this.copySourceBtn.setAttribute('title', 'Copy source to clipboard'); + this.copySourceBtn.classList.remove('disabled'); + } else if (this.activeViewer === this.simpleViewer) { + this.copySourceBtn.setAttribute('title', 'Wait for the source to load to copy it to the clipboard'); + this.copySourceBtn.classList.add('disabled'); + } else { + this.copySourceBtn.setAttribute('title', 'Switch to the source to copy it to the clipboard'); + this.copySourceBtn.classList.add('disabled'); + } + + $(this.copySourceBtn).tooltip('fixTitle'); + } + + loadViewer(viewerParam) { + const viewer = viewerParam; + const url = viewer.getAttribute('data-url'); + + if (!url || viewer.getAttribute('data-loaded') || viewer.getAttribute('data-loading')) { + return; + } + + viewer.setAttribute('data-loading', 'true'); + + $.ajax({ + url, + dataType: 'JSON', + }) + .fail(() => new Flash('Error loading source view')) + .done((data) => { + viewer.innerHTML = data.html; + $(viewer).syntaxHighlight(); + + viewer.setAttribute('data-loaded', 'true'); + + this.$blobContentHolder.trigger('highlight:line'); + + this.toggleCopyButtonState(); + }); + } + + switchToViewer(name) { + const newViewer = document.querySelector(`.blob-viewer[data-type='${name}']`); + if (this.activeViewer === newViewer) return; + + const oldButton = document.querySelector('.js-blob-viewer-switch-btn.active'); + const newButton = document.querySelector(`.js-blob-viewer-switch-btn[data-viewer='${name}']`); + const oldViewer = document.querySelector(`.blob-viewer:not([data-type='${name}'])`); + + if (oldButton) { + oldButton.classList.remove('active'); + } + + if (newButton) { + newButton.classList.add('active'); + newButton.blur(); + } + + if (oldViewer) { + oldViewer.classList.add('hidden'); + } + + newViewer.classList.remove('hidden'); + + this.activeViewer = newViewer; + + this.toggleCopyButtonState(); + + this.loadViewer(newViewer); + } +} diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index b20673cd03c8dd90ae7da17a867805da97272218..d3d75c4bf4a1582f76d11348add6eec982dd0598 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -48,6 +48,7 @@ import BlobForkSuggestion from './blob/blob_fork_suggestion'; import UserCallout from './user_callout'; import { ProtectedTagCreate, ProtectedTagEditList } from './protected_tags'; import ShortcutsWiki from './shortcuts_wiki'; +import BlobViewer from './blob/viewer/index'; const ShortcutsBlob = require('./shortcuts_blob'); @@ -299,6 +300,7 @@ const ShortcutsBlob = require('./shortcuts_blob'); gl.TargetBranchDropDown.bootstrap(); break; case 'projects:blob:show': + new BlobViewer(); gl.TargetBranchDropDown.bootstrap(); initBlob(); break; diff --git a/app/assets/javascripts/line_highlighter.js b/app/assets/javascripts/line_highlighter.js index 1821ca1805376dcc647238d951c4b1713724768f..a6f7bea99f5735fa879b143c3c6df7f13175a270 100644 --- a/app/assets/javascripts/line_highlighter.js +++ b/app/assets/javascripts/line_highlighter.js @@ -41,7 +41,6 @@ require('vendor/jquery.scrollTo'); LineHighlighter.prototype._hash = ''; function LineHighlighter(hash) { - var range; if (hash == null) { // Initialize a LineHighlighter object // @@ -51,10 +50,22 @@ require('vendor/jquery.scrollTo'); this.setHash = bind(this.setHash, this); this.highlightLine = bind(this.highlightLine, this); this.clickHandler = bind(this.clickHandler, this); + this.highlightHash = this.highlightHash.bind(this); this._hash = hash; this.bindEvents(); - if (hash !== '') { - range = this.hashToRange(hash); + this.highlightHash(); + } + + LineHighlighter.prototype.bindEvents = function() { + const $blobContentHolder = $('#blob-content-holder'); + $blobContentHolder.on('click', 'a[data-line-number]', this.clickHandler); + $blobContentHolder.on('highlight:line', this.highlightHash); + }; + + LineHighlighter.prototype.highlightHash = function() { + var range; + if (this._hash !== '') { + range = this.hashToRange(this._hash); if (range[0]) { this.highlightRange(range); $.scrollTo("#L" + range[0], { @@ -64,10 +75,6 @@ require('vendor/jquery.scrollTo'); }); } } - } - - LineHighlighter.prototype.bindEvents = function() { - $('#blob-content-holder').on('click', 'a[data-line-number]', this.clickHandler); }; LineHighlighter.prototype.clickHandler = function(event) { diff --git a/app/controllers/concerns/renders_blob.rb b/app/controllers/concerns/renders_blob.rb new file mode 100644 index 0000000000000000000000000000000000000000..d478c3bb6caf89a0ca8135f1a6f0c9b61464ce06 --- /dev/null +++ b/app/controllers/concerns/renders_blob.rb @@ -0,0 +1,17 @@ +module RendersBlob + extend ActiveSupport::Concern + + def render_blob_json(blob) + viewer = + if params[:viewer] == 'rich' + blob.rich_viewer + else + blob.simple_viewer + end + return render_404 unless viewer + + render json: { + html: view_to_html_string("projects/blob/_viewer", viewer: viewer, load_asynchronously: false) + } + end +end diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 9fce1db6742d1d3f26e550b52bace0889e5b937a..be5822b2cd4afe06a014c15920e903724ce1ec6c 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -2,6 +2,7 @@ class Projects::BlobController < Projects::ApplicationController include ExtractsPath include CreatesCommit + include RendersBlob include ActionView::Helpers::SanitizeHelper # Raised when given an invalid file path @@ -34,8 +35,20 @@ def create end def show - environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } - @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last + @blob.override_max_size! if params[:override_max_size] == 'true' + + respond_to do |format| + format.html do + environment_params = @repository.branch_exists?(@ref) ? { ref: @ref } : { commit: @commit } + @environment = EnvironmentsFinder.new(@project, current_user, environment_params).execute.last + + render 'show' + end + + format.json do + render_blob_json(@blob) + end + end end def edit @@ -96,7 +109,7 @@ def diff private def blob - @blob ||= Blob.decorate(@repository.blob_at(@commit.id, @path)) + @blob ||= Blob.decorate(@repository.blob_at(@commit.id, @path), @project) if @blob @blob diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index c55b37ae0dd6359402d5420186935678309e8420..a0b08ad130fe9524bf6a52750a2e58ce624b947a 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -15,7 +15,7 @@ def show return if cached_blob? - if @blob.lfs_pointer? && project.lfs_enabled? + if @blob.valid_lfs_pointer? send_lfs_object else send_git_blob @repository, @blob diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 36b16421e8f0788026ab67d9dbe63f43861e9e7c..cc47654dc063cce31c7114149e2aae4eeca24565 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -52,7 +52,7 @@ def modify_file_link(project = @project, ref = @ref, path = @path, label:, actio if !on_top_of_branch?(project, ref) button_tag label, class: "#{common_classes} disabled has-tooltip", title: "You can only #{action} files when you are on a branch", data: { container: 'body' } - elsif blob.lfs_pointer? + elsif blob.valid_lfs_pointer? button_tag label, class: "#{common_classes} disabled has-tooltip", title: "It is not possible to #{action} files that are stored in LFS using the web interface", data: { container: 'body' } elsif can_modify_blob?(blob, project, ref) button_tag label, class: "#{common_classes}", 'data-target' => "#modal-#{modal_type}-blob", 'data-toggle' => 'modal' @@ -95,7 +95,7 @@ def delete_blob_link(project = @project, ref = @ref, path = @path) end def can_modify_blob?(blob, project = @project, ref = @ref) - !blob.lfs_pointer? && can_edit_tree?(project, ref) + !blob.valid_lfs_pointer? && can_edit_tree?(project, ref) end def leave_edit_message @@ -118,28 +118,15 @@ def blob_icon(mode, name) icon("#{file_type_icon_class('file', mode, name)} fw") end - def blob_text_viewable?(blob) - blob && blob.text? && !blob.lfs_pointer? && !blob.only_display_raw? - end - - def blob_rendered_as_text?(blob) - blob_text_viewable?(blob) && blob.to_partial_path(@project) == 'text' - end - - def blob_size(blob) - if blob.lfs_pointer? - blob.lfs_size - else - blob.size - end + def blob_raw_url + namespace_project_raw_path(@project.namespace, @project, @id) end # SVGs can contain malicious JavaScript; only include whitelisted # elements and attributes. Note that this whitelist is by no means complete # and may omit some elements. - def sanitize_svg(blob) - blob.data = Gitlab::Sanitizers::SVG.clean(blob.data) - blob + def sanitize_svg_data(data) + Gitlab::Sanitizers::SVG.clean(data) end # If we blindly set the 'real' content type when serving a Git blob we @@ -221,13 +208,42 @@ def copy_file_path_button(file_path) clipboard_button(text: file_path, gfm: "`#{file_path}`", class: 'btn-clipboard btn-transparent prepend-left-5', title: 'Copy file path to clipboard') end - def copy_blob_content_button(blob) - return if markup?(blob.name) - - clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm", title: "Copy content to clipboard") + def copy_blob_source_button(blob) + clipboard_button(target: ".blob-content[data-blob-id='#{blob.id}']", class: "btn btn-sm js-copy-blob-source-btn", title: "Copy source to clipboard") end def open_raw_file_button(path) link_to icon('file-code-o'), path, class: 'btn btn-sm has-tooltip', target: '_blank', rel: 'noopener noreferrer', title: 'Open raw', data: { container: 'body' } end + + def blob_render_error_reason(viewer) + case viewer.render_error + when :too_large + max_size = + if viewer.absolutely_too_large? + viewer.absolute_max_size + elsif viewer.too_large? + viewer.max_size + end + "it is larger than #{number_to_human_size(max_size)}" + when :server_side_but_stored_in_lfs + "it is stored in LFS" + end + end + + def blob_render_error_options(viewer) + options = [] + + if viewer.render_error == :too_large && viewer.can_override_max_size? + options << link_to('load it anyway', url_for(params.merge(viewer: viewer.type, override_max_size: true, format: nil))) + end + + if viewer.rich? && viewer.blob.rendered_as_text? + options << link_to('view the source', '#', class: 'js-blob-viewer-switch-btn', data: { viewer: 'simple' }) + end + + options << link_to('download it', blob_raw_url, target: '_blank', rel: 'noopener noreferrer') + + options + end end diff --git a/app/models/blob.rb b/app/models/blob.rb index 55872acef5101bfffa65d4fd890aa985ea9f409f..290df5d5520583adb7d0251ac75fe975e603bea4 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -3,8 +3,40 @@ class Blob < SimpleDelegator CACHE_TIME = 60 # Cache raw blobs referred to by a (mutable) ref for 1 minute CACHE_TIME_IMMUTABLE = 3600 # Cache blobs referred to by an immutable reference for 1 hour - # The maximum size of an SVG that can be displayed. - MAXIMUM_SVG_SIZE = 2.megabytes + MAXIMUM_TEXT_HIGHLIGHT_SIZE = 1.megabyte + + # Finding a viewer for a blob happens based only on extension and whether the + # blob is binary or text, which means 1 blob should only be matched by 1 viewer, + # and the order of these viewers doesn't really matter. + # + # However, when the blob is an LFS pointer, we cannot know for sure whether the + # file being pointed to is binary or text. In this case, we match only on + # extension, preferring binary viewers over text ones if both exist, since the + # large files referred to in "Large File Storage" are much more likely to be + # binary than text. + # + # `.stl` files, for example, exist in both binary and text forms, and are + # handled by different viewers (`BinarySTL` and `TextSTL`) depending on blob + # type. LFS pointers to `.stl` files are assumed to always be the binary kind, + # and use the `BinarySTL` viewer. + RICH_VIEWERS = [ + BlobViewer::Markup, + BlobViewer::Notebook, + BlobViewer::SVG, + + BlobViewer::Image, + BlobViewer::Sketch, + + BlobViewer::PDF, + + BlobViewer::BinarySTL, + BlobViewer::TextSTL, + ].freeze + + BINARY_VIEWERS = RICH_VIEWERS.select(&:binary?).freeze + TEXT_VIEWERS = RICH_VIEWERS.select(&:text?).freeze + + attr_reader :project # Wrap a Gitlab::Git::Blob object, or return nil when given nil # @@ -16,10 +48,16 @@ class Blob < SimpleDelegator # # blob = Blob.decorate(nil) # puts "truthy" if blob # No output - def self.decorate(blob) + def self.decorate(blob, project = nil) return if blob.nil? - new(blob) + new(blob, project) + end + + def initialize(blob, project = nil) + @project = project + + super(blob) end # Returns the data of the blob. @@ -35,82 +73,107 @@ def data end def no_highlighting? - size && size > 1.megabyte + size && size > MAXIMUM_TEXT_HIGHLIGHT_SIZE end - def only_display_raw? + def too_large? size && truncated? end + # Returns the size of the file that this blob represents. If this blob is an + # LFS pointer, this is the size of the file stored in LFS. Otherwise, this is + # the size of the blob itself. + def raw_size + if valid_lfs_pointer? + lfs_size + else + size + end + end + + # Returns whether the file that this blob represents is binary. If this blob is + # an LFS pointer, we assume the file stored in LFS is binary, unless a + # text-based rich blob viewer matched on the file's extension. Otherwise, this + # depends on the type of the blob itself. + def raw_binary? + if valid_lfs_pointer? + if rich_viewer + rich_viewer.binary? + else + true + end + else + binary? + end + end + def extension - extname.downcase.delete('.') + @extension ||= extname.downcase.delete('.') end - def svg? - text? && language && language.name == 'SVG' + def video? + UploaderHelper::VIDEO_EXT.include?(extension) end - def pdf? - extension == 'pdf' + def readable_text? + text? && !valid_lfs_pointer? && !too_large? end - def ipython_notebook? - text? && language&.name == 'Jupyter Notebook' + def valid_lfs_pointer? + lfs_pointer? && project&.lfs_enabled? end - def sketch? - binary? && extension == 'sketch' + def invalid_lfs_pointer? + lfs_pointer? && !project&.lfs_enabled? end - def stl? - extension == 'stl' + def simple_viewer + @simple_viewer ||= simple_viewer_class.new(self) end - def markup? - text? && Gitlab::MarkupHelper.markup?(name) + def rich_viewer + return @rich_viewer if defined?(@rich_viewer) + + @rich_viewer = rich_viewer_class&.new(self) end - def size_within_svg_limits? - size <= MAXIMUM_SVG_SIZE + def rendered_as_text?(ignore_errors: true) + simple_viewer.text? && (ignore_errors || simple_viewer.render_error.nil?) end - def video? - UploaderHelper::VIDEO_EXT.include?(extname.downcase.delete('.')) + def show_viewer_switcher? + rendered_as_text? && rich_viewer end - def to_partial_path(project) - if lfs_pointer? - if project.lfs_enabled? - 'download' - else - 'text' - end - elsif image? - 'image' - elsif svg? - 'svg' - elsif pdf? - 'pdf' - elsif ipython_notebook? - 'notebook' - elsif sketch? - 'sketch' - elsif stl? - 'stl' - elsif markup? - if only_display_raw? - 'too_large' - else - 'markup' - end - elsif text? - if only_display_raw? - 'too_large' - else - 'text' - end - else - 'download' + def override_max_size! + simple_viewer&.override_max_size = true + rich_viewer&.override_max_size = true + end + + private + + def simple_viewer_class + if empty? + BlobViewer::Empty + elsif raw_binary? + BlobViewer::Download + else # text + BlobViewer::Text end end + + def rich_viewer_class + return if invalid_lfs_pointer? || empty? + + classes = + if valid_lfs_pointer? + BINARY_VIEWERS + TEXT_VIEWERS + elsif binary? + BINARY_VIEWERS + else # text + TEXT_VIEWERS + end + + classes.find { |viewer_class| viewer_class.can_render?(self) } + end end diff --git a/app/models/blob_viewer/base.rb b/app/models/blob_viewer/base.rb new file mode 100644 index 0000000000000000000000000000000000000000..f944b00c9d3105c80ed8c897368c0a68a5b04a07 --- /dev/null +++ b/app/models/blob_viewer/base.rb @@ -0,0 +1,96 @@ +module BlobViewer + class Base + class_attribute :partial_name, :type, :extensions, :client_side, :binary, :switcher_icon, :switcher_title, :max_size, :absolute_max_size + + delegate :partial_path, :rich?, :simple?, :client_side?, :server_side?, :text?, :binary?, to: :class + + attr_reader :blob + attr_accessor :override_max_size + + def initialize(blob) + @blob = blob + end + + def self.partial_path + "projects/blob/viewers/#{partial_name}" + end + + def self.rich? + type == :rich + end + + def self.simple? + type == :simple + end + + def self.client_side? + client_side + end + + def self.server_side? + !client_side? + end + + def self.binary? + binary + end + + def self.text? + !binary? + end + + def self.can_render?(blob) + !extensions || extensions.include?(blob.extension) + end + + def too_large? + blob.raw_size > max_size + end + + def absolutely_too_large? + blob.raw_size > absolute_max_size + end + + def can_override_max_size? + too_large? && !absolutely_too_large? + end + + # This method is used on the server side to check whether we can attempt to + # render the blob at all. Human-readable error messages are found in the + # `BlobHelper#blob_render_error_reason` helper. + # + # This method does not and should not load the entire blob contents into + # memory, and should not be overridden to do so in order to validate the + # format of the blob. + # + # Prefer to implement a client-side viewer, where the JS component loads the + # binary from `blob_raw_url` and does its own format validation and error + # rendering, especially for potentially large binary formats. + def render_error + return @render_error if defined?(@render_error) + + @render_error = + if server_side_but_stored_in_lfs? + # Files stored in LFS can only be rendered using a client-side viewer, + # since we do not want to read large amounts of data into memory on the + # server side. Client-side viewers use JS and can fetch the file from + # `blob_raw_url` using AJAX. + :server_side_but_stored_in_lfs + elsif override_max_size ? absolutely_too_large? : too_large? + :too_large + end + end + + def prepare! + if server_side? && blob.project + blob.load_all_data!(blob.project.repository) + end + end + + private + + def server_side_but_stored_in_lfs? + server_side? && blob.valid_lfs_pointer? + end + end +end diff --git a/app/models/blob_viewer/binary_stl.rb b/app/models/blob_viewer/binary_stl.rb new file mode 100644 index 0000000000000000000000000000000000000000..80393471ef2d16e993698ca4b636d3c82bbd6f95 --- /dev/null +++ b/app/models/blob_viewer/binary_stl.rb @@ -0,0 +1,10 @@ +module BlobViewer + class BinarySTL < Base + include Rich + include ClientSide + + self.partial_name = 'stl' + self.extensions = %w(stl) + self.binary = true + end +end diff --git a/app/models/blob_viewer/client_side.rb b/app/models/blob_viewer/client_side.rb new file mode 100644 index 0000000000000000000000000000000000000000..42ec68f864bcb9b5fe50db20fc9312fba9fce0c6 --- /dev/null +++ b/app/models/blob_viewer/client_side.rb @@ -0,0 +1,11 @@ +module BlobViewer + module ClientSide + extend ActiveSupport::Concern + + included do + self.client_side = true + self.max_size = 10.megabytes + self.absolute_max_size = 50.megabytes + end + end +end diff --git a/app/models/blob_viewer/download.rb b/app/models/blob_viewer/download.rb new file mode 100644 index 0000000000000000000000000000000000000000..adc06587f6981d3a40d3b6d66cc9f7220904ea11 --- /dev/null +++ b/app/models/blob_viewer/download.rb @@ -0,0 +1,17 @@ +module BlobViewer + class Download < Base + include Simple + # We treat the Download viewer as if it renders the content client-side, + # so that it doesn't attempt to load the entire blob contents and is + # rendered synchronously instead of loaded asynchronously. + include ClientSide + + self.partial_name = 'download' + self.binary = true + + # We can always render the Download viewer, even if the blob is in LFS or too large. + def render_error + nil + end + end +end diff --git a/app/models/blob_viewer/empty.rb b/app/models/blob_viewer/empty.rb new file mode 100644 index 0000000000000000000000000000000000000000..d9d128eb273707625d480cbf0b40992e8ac2c728 --- /dev/null +++ b/app/models/blob_viewer/empty.rb @@ -0,0 +1,9 @@ +module BlobViewer + class Empty < Base + include Simple + include ServerSide + + self.partial_name = 'empty' + self.binary = true + end +end diff --git a/app/models/blob_viewer/image.rb b/app/models/blob_viewer/image.rb new file mode 100644 index 0000000000000000000000000000000000000000..c4eae5c79c2747001075ea4838734ba525e6b0b0 --- /dev/null +++ b/app/models/blob_viewer/image.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Image < Base + include Rich + include ClientSide + + self.partial_name = 'image' + self.extensions = UploaderHelper::IMAGE_EXT + self.binary = true + self.switcher_icon = 'picture-o' + self.switcher_title = 'image' + end +end diff --git a/app/models/blob_viewer/markup.rb b/app/models/blob_viewer/markup.rb new file mode 100644 index 0000000000000000000000000000000000000000..8fdbab30dd1a9a5e3e244b6ba0e67e0dc28242ca --- /dev/null +++ b/app/models/blob_viewer/markup.rb @@ -0,0 +1,10 @@ +module BlobViewer + class Markup < Base + include Rich + include ServerSide + + self.partial_name = 'markup' + self.extensions = Gitlab::MarkupHelper::EXTENSIONS + self.binary = false + end +end diff --git a/app/models/blob_viewer/notebook.rb b/app/models/blob_viewer/notebook.rb new file mode 100644 index 0000000000000000000000000000000000000000..8632b8a9885f5ab56dc4e0e10d451587b4a542d9 --- /dev/null +++ b/app/models/blob_viewer/notebook.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Notebook < Base + include Rich + include ClientSide + + self.partial_name = 'notebook' + self.extensions = %w(ipynb) + self.binary = false + self.switcher_icon = 'file-text-o' + self.switcher_title = 'notebook' + end +end diff --git a/app/models/blob_viewer/pdf.rb b/app/models/blob_viewer/pdf.rb new file mode 100644 index 0000000000000000000000000000000000000000..65805f5f388fa2abd0300a3d027c7bc124741ab8 --- /dev/null +++ b/app/models/blob_viewer/pdf.rb @@ -0,0 +1,12 @@ +module BlobViewer + class PDF < Base + include Rich + include ClientSide + + self.partial_name = 'pdf' + self.extensions = %w(pdf) + self.binary = true + self.switcher_icon = 'file-pdf-o' + self.switcher_title = 'PDF' + end +end diff --git a/app/models/blob_viewer/rich.rb b/app/models/blob_viewer/rich.rb new file mode 100644 index 0000000000000000000000000000000000000000..be373dbc948fb281b8cb4bf184073e8bbf7e8658 --- /dev/null +++ b/app/models/blob_viewer/rich.rb @@ -0,0 +1,11 @@ +module BlobViewer + module Rich + extend ActiveSupport::Concern + + included do + self.type = :rich + self.switcher_icon = 'file-text-o' + self.switcher_title = 'rendered file' + end + end +end diff --git a/app/models/blob_viewer/server_side.rb b/app/models/blob_viewer/server_side.rb new file mode 100644 index 0000000000000000000000000000000000000000..899107d02ea805b6dadb37e175f60cae703cfd4a --- /dev/null +++ b/app/models/blob_viewer/server_side.rb @@ -0,0 +1,11 @@ +module BlobViewer + module ServerSide + extend ActiveSupport::Concern + + included do + self.client_side = false + self.max_size = 2.megabytes + self.absolute_max_size = 5.megabytes + end + end +end diff --git a/app/models/blob_viewer/simple.rb b/app/models/blob_viewer/simple.rb new file mode 100644 index 0000000000000000000000000000000000000000..454a20495fc361fa100dc781596e7c7e7bcd655a --- /dev/null +++ b/app/models/blob_viewer/simple.rb @@ -0,0 +1,11 @@ +module BlobViewer + module Simple + extend ActiveSupport::Concern + + included do + self.type = :simple + self.switcher_icon = 'code' + self.switcher_title = 'source' + end + end +end diff --git a/app/models/blob_viewer/sketch.rb b/app/models/blob_viewer/sketch.rb new file mode 100644 index 0000000000000000000000000000000000000000..818456778e135d18a7af49a8e2f3f9dd3641e4ec --- /dev/null +++ b/app/models/blob_viewer/sketch.rb @@ -0,0 +1,12 @@ +module BlobViewer + class Sketch < Base + include Rich + include ClientSide + + self.partial_name = 'sketch' + self.extensions = %w(sketch) + self.binary = true + self.switcher_icon = 'file-image-o' + self.switcher_title = 'preview' + end +end diff --git a/app/models/blob_viewer/svg.rb b/app/models/blob_viewer/svg.rb new file mode 100644 index 0000000000000000000000000000000000000000..b7e5cd71e6bef290705ca0e1be8da1c589d9dba8 --- /dev/null +++ b/app/models/blob_viewer/svg.rb @@ -0,0 +1,12 @@ +module BlobViewer + class SVG < Base + include Rich + include ServerSide + + self.partial_name = 'svg' + self.extensions = %w(svg) + self.binary = false + self.switcher_icon = 'picture-o' + self.switcher_title = 'image' + end +end diff --git a/app/models/blob_viewer/text.rb b/app/models/blob_viewer/text.rb new file mode 100644 index 0000000000000000000000000000000000000000..e27b2c2b4932fe24bd306d7fd582069f006f79ba --- /dev/null +++ b/app/models/blob_viewer/text.rb @@ -0,0 +1,11 @@ +module BlobViewer + class Text < Base + include Simple + include ServerSide + + self.partial_name = 'text' + self.binary = false + self.max_size = 1.megabyte + self.absolute_max_size = 10.megabytes + end +end diff --git a/app/models/blob_viewer/text_stl.rb b/app/models/blob_viewer/text_stl.rb new file mode 100644 index 0000000000000000000000000000000000000000..8184dc0104c8a645a7d15cdd5ef261cbba6b4b6b --- /dev/null +++ b/app/models/blob_viewer/text_stl.rb @@ -0,0 +1,5 @@ +module BlobViewer + class TextSTL < BinarySTL + self.binary = false + end +end diff --git a/app/models/commit.rb b/app/models/commit.rb index 8b8b3f002020db0f2b061582fdd973541d592c49..bb4cb8efd151977fc017957d7bb16728604d01d8 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -316,7 +316,7 @@ def change_type_title(user) def uri_type(path) entry = @raw.tree.path(path) if entry[:type] == :blob - blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name])) + blob = ::Blob.decorate(Gitlab::Git::Blob.new(name: entry[:name]), @project) blob.image? || blob.video? ? :raw : :blob else entry[:type] diff --git a/app/models/repository.rb b/app/models/repository.rb index e74edb8e6f7c5fd47378589fc7614a0f9ab68976..d02aea49689b909fd4b80aebd97d0d20c2e86dd5 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -450,7 +450,7 @@ def respond_to_missing?(method, include_private = false) def blob_at(sha, path) unless Gitlab::Git.blank_ref?(sha) - Blob.decorate(Gitlab::Git::Blob.find(self, sha, path)) + Blob.decorate(Gitlab::Git::Blob.find(self, sha, path), project) end rescue Gitlab::Git::Repository::NoRepository nil diff --git a/app/views/notify/repository_push_email.html.haml b/app/views/notify/repository_push_email.html.haml index c6b1db17f91e9ce483a5a73952acc4b001f5ea46..02eb7c8462c58941e76d88eef325731a8a121c9a 100644 --- a/app/views/notify/repository_push_email.html.haml +++ b/app/views/notify/repository_push_email.html.haml @@ -74,7 +74,7 @@ - else %hr - blob = diff_file.blob - - if blob && blob.respond_to?(:text?) && blob_text_viewable?(blob) + - if blob && blob.readable_text? %table.code.white = render partial: "projects/diffs/line", collection: diff_file.highlighted_diff_lines, as: :line, locals: { diff_file: diff_file, plain: true, email: true } - else diff --git a/app/views/projects/blob/_blob.html.haml b/app/views/projects/blob/_blob.html.haml index 9aafff343f0e5049da77c7d8b2b8aa626a8b34d8..3f12d64d044bc7115d7d45a7c7de6822e81c83df 100644 --- a/app/views/projects/blob/_blob.html.haml +++ b/app/views/projects/blob/_blob.html.haml @@ -26,9 +26,4 @@ %article.file-holder = render "projects/blob/header", blob: blob - - if blob.empty? - .file-content.code - .nothing-here-block - Empty file - - else - = render blob.to_partial_path(@project), blob: blob + = render 'projects/blob/content', blob: blob diff --git a/app/views/projects/blob/_content.html.haml b/app/views/projects/blob/_content.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..7afbd85cd6d4e50ebf37a541ffb8e7b48135a927 --- /dev/null +++ b/app/views/projects/blob/_content.html.haml @@ -0,0 +1,8 @@ +- simple_viewer = blob.simple_viewer +- rich_viewer = blob.rich_viewer +- rich_viewer_active = rich_viewer && params[:viewer] != 'simple' + += render 'projects/blob/viewer', viewer: simple_viewer, hidden: rich_viewer_active + +- if rich_viewer + = render 'projects/blob/viewer', viewer: rich_viewer, hidden: !rich_viewer_active diff --git a/app/views/projects/blob/_download.html.haml b/app/views/projects/blob/_download.html.haml deleted file mode 100644 index 7908fcae3de1db6f9c32aae52a2111a4c9f19799..0000000000000000000000000000000000000000 --- a/app/views/projects/blob/_download.html.haml +++ /dev/null @@ -1,7 +0,0 @@ -.file-content.blob_file.blob-no-preview - .center - = link_to namespace_project_raw_path(@project.namespace, @project, @id) do - %h1.light - %i.fa.fa-download - %h4 - Download (#{number_to_human_size blob_size(blob)}) diff --git a/app/views/projects/blob/_header.html.haml b/app/views/projects/blob/_header.html.haml index c553db84ee0f7546ba857aec833a00242252d96e..b89cd460455a1489eb9c5ee8df778318b944d6eb 100644 --- a/app/views/projects/blob/_header.html.haml +++ b/app/views/projects/blob/_header.html.haml @@ -9,17 +9,19 @@ = copy_file_path_button(blob.path) %small - = number_to_human_size(blob_size(blob)) + = number_to_human_size(blob.raw_size) .file-actions.hidden-xs + = render 'projects/blob/viewer_switcher', blob: blob unless blame + .btn-group{ role: "group" }< - = copy_blob_content_button(blob) if !blame && blob_rendered_as_text?(blob) + = copy_blob_source_button(blob) if !blame && blob.rendered_as_text?(ignore_errors: false) = open_raw_file_button(namespace_project_raw_path(@project.namespace, @project, @id)) = view_on_environment_button(@commit.sha, @path, @environment) if @environment .btn-group{ role: "group" }< -# only show normal/blame view links for text files - - if blob_text_viewable?(blob) + - if blob.readable_text? - if blame = link_to 'Normal view', namespace_project_blob_path(@project.namespace, @project, @id), class: 'btn btn-sm' @@ -34,7 +36,7 @@ tree_join(@commit.sha, @path)), class: 'btn btn-sm js-data-file-blob-permalink-url' .btn-group{ role: "group" }< - = edit_blob_link if blob_text_viewable?(blob) + = edit_blob_link if blob.readable_text? - if current_user = replace_blob_link = delete_blob_link diff --git a/app/views/projects/blob/_image.html.haml b/app/views/projects/blob/_image.html.haml deleted file mode 100644 index 73877d730f5de93f98ca47c4687b5cb3815fae80..0000000000000000000000000000000000000000 --- a/app/views/projects/blob/_image.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -.file-content.image_file - %img{ src: namespace_project_raw_path(@project.namespace, @project, @id), alt: blob.name } diff --git a/app/views/projects/blob/_render_error.html.haml b/app/views/projects/blob/_render_error.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..9eef6cafd04c955fc355f05c1d5f02d7c220eedb --- /dev/null +++ b/app/views/projects/blob/_render_error.html.haml @@ -0,0 +1,7 @@ +.file-content.code + .nothing-here-block + The #{viewer.switcher_title} could not be displayed because #{blob_render_error_reason(viewer)}. + + You can + = blob_render_error_options(viewer).to_sentence(two_words_connector: ' or ', last_word_connector: ', or ').html_safe + instead. diff --git a/app/views/projects/blob/_svg.html.haml b/app/views/projects/blob/_svg.html.haml deleted file mode 100644 index 93be58fc65826f614ec4edc9ec8a111cb793b561..0000000000000000000000000000000000000000 --- a/app/views/projects/blob/_svg.html.haml +++ /dev/null @@ -1,9 +0,0 @@ -- if blob.size_within_svg_limits? - -# We need to scrub SVG but we cannot do so in the RawController: it would - -# be wrong/strange if RawController modified the data. - - blob.load_all_data!(@repository) - - blob = sanitize_svg(blob) - .file-content.image_file - %img{ src: "data:#{blob.mime_type};base64,#{Base64.encode64(blob.data)}", alt: blob.name } -- else - = render 'too_large' diff --git a/app/views/projects/blob/_text.html.haml b/app/views/projects/blob/_text.html.haml deleted file mode 100644 index 20638f6961db4960d1934f9a804211427cfae2b5..0000000000000000000000000000000000000000 --- a/app/views/projects/blob/_text.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -- blob.load_all_data!(@repository) -= render 'shared/file_highlight', blob: blob, repository: @repository diff --git a/app/views/projects/blob/_too_large.html.haml b/app/views/projects/blob/_too_large.html.haml deleted file mode 100644 index a505f87df402b19d471caa1beb3114d51d7bb715..0000000000000000000000000000000000000000 --- a/app/views/projects/blob/_too_large.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -.file-content.code - .nothing-here-block - The file could not be displayed as it is too large, you can - #{link_to('view the raw file', namespace_project_raw_path(@project.namespace, @project, @id), target: '_blank', rel: 'noopener noreferrer')} - instead. diff --git a/app/views/projects/blob/_viewer.html.haml b/app/views/projects/blob/_viewer.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..5326bb3e0cf070812ba73a4f068f3da9ca24eaaf --- /dev/null +++ b/app/views/projects/blob/_viewer.html.haml @@ -0,0 +1,14 @@ +- hidden = local_assigns.fetch(:hidden, false) +- render_error = viewer.render_error +- load_asynchronously = local_assigns.fetch(:load_asynchronously, viewer.server_side?) && render_error.nil? + +- url = url_for(params.merge(viewer: viewer.type, format: :json)) if load_asynchronously +.blob-viewer{ data: { type: viewer.type, url: url }, class: ('hidden' if hidden) } + - if load_asynchronously + .text-center.prepend-top-default.append-bottom-default + = icon('spinner spin 2x', 'aria-hidden' => 'true', 'aria-label' => 'Loading content') + - elsif render_error + = render 'projects/blob/render_error', viewer: viewer + - else + - viewer.prepare! + = render viewer.partial_path, viewer: viewer diff --git a/app/views/projects/blob/_viewer_switcher.html.haml b/app/views/projects/blob/_viewer_switcher.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..6a521069418e79cb612a0feeea604138cbbc035f --- /dev/null +++ b/app/views/projects/blob/_viewer_switcher.html.haml @@ -0,0 +1,12 @@ +- if blob.show_viewer_switcher? + - simple_viewer = blob.simple_viewer + - rich_viewer = blob.rich_viewer + + .btn-group.js-blob-viewer-switcher{ role: "group" } + - simple_label = "Display #{simple_viewer.switcher_title}" + %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => simple_label, title: simple_label, data: { viewer: 'simple', container: 'body' } }> + = icon(simple_viewer.switcher_icon) + + - rich_label = "Display #{rich_viewer.switcher_title}" + %button.btn.btn-default.btn-sm.js-blob-viewer-switch-btn.has-tooltip{ 'aria-label' => rich_label, title: rich_label, data: { viewer: 'rich', container: 'body' } }> + = icon(rich_viewer.switcher_icon) diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index b9b3f3ec7a3bcbbfcae7d429ca1e6235935da8d9..67f57b5e4b905d2e047fd09439957b05414cd32b 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -2,6 +2,9 @@ - page_title @blob.path, @ref = render "projects/commits/head" +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('blob') + %div{ class: container_class } = render 'projects/last_push' diff --git a/app/views/projects/blob/viewers/_download.html.haml b/app/views/projects/blob/viewers/_download.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..684240d02c79720a6036af4e200f4443d1832a97 --- /dev/null +++ b/app/views/projects/blob/viewers/_download.html.haml @@ -0,0 +1,7 @@ +.file-content.blob_file.blob-no-preview + .center + = link_to blob_raw_url do + %h1.light + = icon('download') + %h4 + Download (#{number_to_human_size(viewer.blob.raw_size)}) diff --git a/app/views/projects/blob/viewers/_empty.html.haml b/app/views/projects/blob/viewers/_empty.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..a293a8de231dbbad24879b30df5a4927a6c78e33 --- /dev/null +++ b/app/views/projects/blob/viewers/_empty.html.haml @@ -0,0 +1,3 @@ +.file-content.code + .nothing-here-block + Empty file diff --git a/app/views/projects/blob/viewers/_image.html.haml b/app/views/projects/blob/viewers/_image.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..640d59b31745d875422ae6fc7e24ca152d7cf497 --- /dev/null +++ b/app/views/projects/blob/viewers/_image.html.haml @@ -0,0 +1,2 @@ +.file-content.image_file + %img{ src: blob_raw_url, alt: viewer.blob.name } diff --git a/app/views/projects/blob/viewers/_markup.html.haml b/app/views/projects/blob/viewers/_markup.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..b9a998d96ffbfc0589988d6c65e76c55a6916b15 --- /dev/null +++ b/app/views/projects/blob/viewers/_markup.html.haml @@ -0,0 +1,3 @@ +- blob = viewer.blob +.file-content.wiki + = markup(blob.name, blob.data) diff --git a/app/views/projects/blob/_notebook.html.haml b/app/views/projects/blob/viewers/_notebook.html.haml similarity index 57% rename from app/views/projects/blob/_notebook.html.haml rename to app/views/projects/blob/viewers/_notebook.html.haml index ab1cf933944128e5d6fae47f5160a330622b09e6..2399fb1626533ba6309a46435d10bbdeb55b419a 100644 --- a/app/views/projects/blob/_notebook.html.haml +++ b/app/views/projects/blob/viewers/_notebook.html.haml @@ -2,4 +2,4 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('notebook_viewer') -.file-content#js-notebook-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } +.file-content#js-notebook-viewer{ data: { endpoint: blob_raw_url } } diff --git a/app/views/projects/blob/_pdf.html.haml b/app/views/projects/blob/viewers/_pdf.html.haml similarity index 57% rename from app/views/projects/blob/_pdf.html.haml rename to app/views/projects/blob/viewers/_pdf.html.haml index 58dc88e3bf7e8400ab90f488a6bc459d4c41c193..1dd179c4fdc97ee455054cc5295ede56385af1eb 100644 --- a/app/views/projects/blob/_pdf.html.haml +++ b/app/views/projects/blob/viewers/_pdf.html.haml @@ -2,4 +2,4 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('pdf_viewer') -.file-content#js-pdf-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } +.file-content#js-pdf-viewer{ data: { endpoint: blob_raw_url } } diff --git a/app/views/projects/blob/_sketch.html.haml b/app/views/projects/blob/viewers/_sketch.html.haml similarity index 74% rename from app/views/projects/blob/_sketch.html.haml rename to app/views/projects/blob/viewers/_sketch.html.haml index dad9369cb2ae935ab828bad18ceca8b78e34ad68..49f716c2c59eb5975514c419c79f95ceb72a46a0 100644 --- a/app/views/projects/blob/_sketch.html.haml +++ b/app/views/projects/blob/viewers/_sketch.html.haml @@ -2,6 +2,6 @@ = page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('sketch_viewer') -.file-content#js-sketch-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } +.file-content#js-sketch-viewer{ data: { endpoint: blob_raw_url } } .js-loading-icon.text-center.prepend-top-default.append-bottom-default.js-loading-icon{ 'aria-label' => 'Loading Sketch preview' } = icon('spinner spin 2x', 'aria-hidden' => 'true'); diff --git a/app/views/projects/blob/_stl.html.haml b/app/views/projects/blob/viewers/_stl.html.haml similarity index 83% rename from app/views/projects/blob/_stl.html.haml rename to app/views/projects/blob/viewers/_stl.html.haml index a9332a0eeb6f7fc99c9ae1b11308812e48b65482..e4e9d7461766f016e9765ce4a7ee176848a418b7 100644 --- a/app/views/projects/blob/_stl.html.haml +++ b/app/views/projects/blob/viewers/_stl.html.haml @@ -2,7 +2,7 @@ = page_specific_javascript_bundle_tag('stl_viewer') .file-content.is-stl-loading - .text-center#js-stl-viewer{ data: { endpoint: namespace_project_raw_path(@project.namespace, @project, @id) } } + .text-center#js-stl-viewer{ data: { endpoint: blob_raw_url } } = icon('spinner spin 2x', class: 'prepend-top-default append-bottom-default', 'aria-hidden' => 'true', 'aria-label' => 'Loading') .text-center.prepend-top-default.append-bottom-default.stl-controls .btn-group diff --git a/app/views/projects/blob/viewers/_svg.html.haml b/app/views/projects/blob/viewers/_svg.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..62f647581b614ebe3672f1bf3d38587681290532 --- /dev/null +++ b/app/views/projects/blob/viewers/_svg.html.haml @@ -0,0 +1,4 @@ +- blob = viewer.blob +- data = sanitize_svg_data(blob.data) +.file-content.image_file + %img{ src: "data:#{blob.mime_type};base64,#{Base64.encode64(data)}", alt: blob.name } diff --git a/app/views/projects/blob/viewers/_text.html.haml b/app/views/projects/blob/viewers/_text.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..a91df321ca087d90bced66cc270b1923cf3a2d7b --- /dev/null +++ b/app/views/projects/blob/viewers/_text.html.haml @@ -0,0 +1 @@ += render 'shared/file_highlight', blob: viewer.blob, repository: @repository diff --git a/app/views/projects/diffs/_content.html.haml b/app/views/projects/diffs/_content.html.haml index 438a98c3e955bda2c05d789377ac00b07cc2c9be..c781e423c4d6571e6a9188a035cd0a3d9c7ec9b8 100644 --- a/app/views/projects/diffs/_content.html.haml +++ b/app/views/projects/diffs/_content.html.haml @@ -3,9 +3,9 @@ - return unless blob.respond_to?(:text?) - if diff_file.too_large? .nothing-here-block This diff could not be displayed because it is too large. - - elsif blob.only_display_raw? + - elsif blob.too_large? .nothing-here-block The file could not be displayed because it is too large. - - elsif blob_text_viewable?(blob) + - elsif blob.readable_text? - if !project.repository.diffable?(blob) .nothing-here-block This diff was suppressed by a .gitattributes entry. - elsif diff_file.collapsed? diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index 4b49bed835f6d41ee00333eeb67a282c8f61203a..71a1b9e6c05edb437b11ff1841223de83cf6d472 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -27,7 +27,7 @@ - diff_commit = commit_for_diff(diff_file) - blob = diff_file.blob(diff_commit) - next unless blob - - blob.load_all_data!(diffs.project.repository) unless blob.only_display_raw? + - blob.load_all_data!(diffs.project.repository) unless blob.too_large? - file_hash = hexdigest(diff_file.file_path) = render 'projects/diffs/file', file_hash: file_hash, project: diffs.project, diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index 4622b9807549c7fd42a9b7f838c8d3d5ac025e84..f22b385fc0f632f500db84f73c53647163a60f0d 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -6,7 +6,7 @@ - unless diff_file.submodule? .file-actions.hidden-xs - - if blob_text_viewable?(blob) + - if blob.readable_text? = link_to '#', class: 'js-toggle-diff-comments btn active has-tooltip', title: "Toggle comments for this file", disabled: @diff_notes_disabled do = icon('comment') \ diff --git a/app/views/shared/snippets/_blob.html.haml b/app/views/shared/snippets/_blob.html.haml index 895c3f1e99d1c40c63e37a3cbed437511e233e40..37c66ff2595086f0dbbae15e1cbc3883b360387e 100644 --- a/app/views/shared/snippets/_blob.html.haml +++ b/app/views/shared/snippets/_blob.html.haml @@ -9,7 +9,7 @@ .file-actions.hidden-xs .btn-group{ role: "group" }< - = copy_blob_content_button(@snippet) + = copy_blob_source_button(@snippet) = open_raw_file_button(raw_path) - if defined?(download_path) && download_path diff --git a/changelogs/unreleased/dm-blob-viewers.yml b/changelogs/unreleased/dm-blob-viewers.yml new file mode 100644 index 0000000000000000000000000000000000000000..5e0d41f3f29828b6c3bfe5d9be379ba1300016c4 --- /dev/null +++ b/changelogs/unreleased/dm-blob-viewers.yml @@ -0,0 +1,5 @@ +--- +title: Add Source/Rendered switch to blobs for SVG, Markdown, Asciidoc and other text + files that can be rendered +merge_request: +author: diff --git a/features/project/source/browse_files.feature b/features/project/source/browse_files.feature index d81bc9802bcf4b3ebaab7a975fe8f93b2c920b30..472ec9544f3024952977f83298d0df33c33f61fc 100644 --- a/features/project/source/browse_files.feature +++ b/features/project/source/browse_files.feature @@ -10,7 +10,8 @@ Feature: Project Source Browse Files Scenario: I browse files for specific ref Given I visit project source page for "6d39438" Then I should see files from repository for "6d39438" - + + @javascript Scenario: I browse file content Given I click on ".gitignore" file in repo Then I should see its content diff --git a/features/project/source/markdown_render.feature b/features/project/source/markdown_render.feature index ecbd721c28161646aa4a426361f9f04c20118e05..fd583618dcfba7dfef55ec74ee33dbb5924a8cad 100644 --- a/features/project/source/markdown_render.feature +++ b/features/project/source/markdown_render.feature @@ -6,11 +6,13 @@ Feature: Project Source Markdown Render # Tree README + @javascript Scenario: Tree view should have correct links in README Given I go directory which contains README file And I click on a relative link in README Then I should see the correct markdown + @javascript Scenario: I browse files from markdown branch Then I should see files from repository in markdown And I should see rendered README which contains correct links @@ -29,36 +31,42 @@ Feature: Project Source Markdown Render And I click on GitLab API doc directory in README Then I should see correct doc/api directory rendered + @javascript Scenario: I view README in markdown branch to see reference links to file Then I should see files from repository in markdown And I should see rendered README which contains correct links And I click on Maintenance in README Then I should see correct maintenance file rendered + @javascript Scenario: README headers should have header links Then I should see rendered README which contains correct links And Header "Application details" should have correct id and link # Blob + @javascript Scenario: I navigate to doc directory to view documentation in markdown And I navigate to the doc/api/README And I see correct file rendered And I click on users in doc/api/README Then I should see the correct document file + @javascript Scenario: I navigate to doc directory to view user doc in markdown And I navigate to the doc/api/README And I see correct file rendered And I click on raketasks in doc/api/README Then I should see correct directory rendered + @javascript Scenario: I navigate to doc directory to view user doc in markdown And I navigate to the doc/api/README And Header "GitLab API" should have correct id and link # Markdown branch + @javascript Scenario: I browse files from markdown branch When I visit markdown branch Then I should see files from repository in markdown branch @@ -73,6 +81,7 @@ Feature: Project Source Markdown Render And I click on Rake tasks in README Then I should see correct directory rendered for markdown branch + @javascript Scenario: I navigate to doc directory to view documentation in markdown branch When I visit markdown branch And I navigate to the doc/api/README @@ -80,6 +89,7 @@ Feature: Project Source Markdown Render And I click on users in doc/api/README Then I should see the users document file in markdown branch + @javascript Scenario: I navigate to doc directory to view user doc in markdown branch When I visit markdown branch And I navigate to the doc/api/README @@ -87,6 +97,7 @@ Feature: Project Source Markdown Render And I click on raketasks in doc/api/README Then I should see correct directory rendered for markdown branch + @javascript Scenario: Tree markdown links view empty urls should have correct urls When I visit markdown branch Then The link with text "empty" should have url "tree/markdown" @@ -99,6 +110,7 @@ Feature: Project Source Markdown Render # "ID" means "#id" on the tests below, because we are unable to escape the hash sign. # which Spinach interprets as the start of a comment. + @javascript Scenario: All markdown links with ids should have correct urls When I visit markdown branch Then The link with text "ID" should have url "tree/markdownID" diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index b4741f06d1bf695999ab1d51074c1392748e17b4..36fe21a047c5c5d0b89798bc1d7e06c69ce98b9d 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -4,6 +4,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps include SharedProject include SharedPaths include RepoHelpers + include WaitForAjax step "I don't have write access" do @project = create(:project, :repository, name: "Other Project", path: "other-project") @@ -36,10 +37,12 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end step 'I should see its content' do + wait_for_ajax expect(page).to have_content old_gitignore_content end step 'I should see its new content' do + wait_for_ajax expect(page).to have_content new_gitignore_content end diff --git a/features/steps/project/source/markdown_render.rb b/features/steps/project/source/markdown_render.rb index 0f0827f04772d1f446b5a72ff278b3a10757137c..abdbd795cd54c0e08ac49890310ddcda25ba4c39 100644 --- a/features/steps/project/source/markdown_render.rb +++ b/features/steps/project/source/markdown_render.rb @@ -5,6 +5,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps include SharedAuthentication include SharedPaths include SharedMarkdown + include WaitForAjax step 'I own project "Delta"' do @project = ::Project.find_by(name: "Delta") @@ -34,6 +35,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I should see correct document rendered' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md") + wait_for_ajax expect(page).to have_content "All API requests require authentication" end @@ -63,6 +65,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I should see correct maintenance file rendered' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/raketasks/maintenance.md") + wait_for_ajax expect(page).to have_content "bundle exec rake gitlab:env:info RAILS_ENV=production" end @@ -94,6 +97,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I see correct file rendered' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md") + wait_for_ajax expect(page).to have_content "Contents" expect(page).to have_link "Users" expect(page).to have_link "Rake tasks" @@ -138,6 +142,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I see correct file rendered in markdown branch' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md") + wait_for_ajax expect(page).to have_content "Contents" expect(page).to have_link "Users" expect(page).to have_link "Rake tasks" @@ -145,6 +150,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I should see correct document rendered for markdown branch' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/README.md") + wait_for_ajax expect(page).to have_content "All API requests require authentication" end @@ -162,6 +168,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps # Expected link contents step 'The link with text "empty" should have url "tree/markdown"' do + wait_for_ajax find('a', text: /^empty$/)['href'] == current_host + namespace_project_tree_path(@project.namespace, @project, "markdown") end @@ -197,6 +204,7 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps end step 'The link with text "ID" should have url "blob/markdown/README.mdID"' do + wait_for_ajax find('a', text: /^#id$/)['href'] == current_host + namespace_project_blob_path(@project.namespace, @project, "markdown/README.md") + '#id' end @@ -291,10 +299,12 @@ class Spinach::Features::ProjectSourceMarkdownRender < Spinach::FeatureSteps step 'I should see the correct markdown' do expect(current_path).to eq namespace_project_blob_path(@project.namespace, @project, "markdown/doc/api/users.md") + wait_for_ajax expect(page).to have_content "List users" end step 'Header "Application details" should have correct id and link' do + wait_for_ajax header_should_have_correct_id_and_link(2, 'Application details', 'application-details') end diff --git a/features/steps/shared/markdown.rb b/features/steps/shared/markdown.rb index 875d27d9383c479d7a4161a2566c821846c8125a..6610b97ecb2492f235506d5e6413319fb7925df2 100644 --- a/features/steps/shared/markdown.rb +++ b/features/steps/shared/markdown.rb @@ -3,7 +3,7 @@ module SharedMarkdown def header_should_have_correct_id_and_link(level, text, id, parent = ".wiki") node = find("#{parent} h#{level} a#user-content-#{id}") - expect(node[:href]).to eq "##{id}" + expect(node[:href]).to end_with "##{id}" # Work around a weird Capybara behavior where calling `parent` on a node # returns the whole document, not the node's actual parent element diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index 98fd4e78126b744feb06dc79aeea28e498876885..e8bb9e1f805bb513df0c3887b296d1a6a5b45909 100644 --- a/lib/gitlab/git/blob.rb +++ b/lib/gitlab/git/blob.rb @@ -109,10 +109,6 @@ def binary? @binary.nil? ? super : @binary == true end - def empty? - !data || data == '' - end - def data encode! @data end diff --git a/spec/features/copy_as_gfm_spec.rb b/spec/features/copy_as_gfm_spec.rb index 344e31e5ef5c1e1eb1f3d23e4d980cc53dd3724a..f197fb446081c356abe57530d075f1dac207f85e 100644 --- a/spec/features/copy_as_gfm_spec.rb +++ b/spec/features/copy_as_gfm_spec.rb @@ -479,6 +479,7 @@ def current_user context 'from a blob' do before do visit namespace_project_blob_path(project.namespace, project, File.join('master', 'files/ruby/popen.rb')) + wait_for_ajax end context 'selecting one word of text' do @@ -520,6 +521,7 @@ def current_user context 'from a GFM code block' do before do visit namespace_project_blob_path(project.namespace, project, File.join('markdown', 'doc/api/users.md')) + wait_for_ajax end context 'selecting one word of text' do diff --git a/spec/features/projects/blobs/blob_show_spec.rb b/spec/features/projects/blobs/blob_show_spec.rb index 7cfa5b9716f90b4459812d9a56957614c53f5401..cc11cb7a55f6665a8be7080e572596abae779c60 100644 --- a/spec/features/projects/blobs/blob_show_spec.rb +++ b/spec/features/projects/blobs/blob_show_spec.rb @@ -1,21 +1,313 @@ require 'spec_helper' -feature 'File blob', feature: true do +feature 'File blob', :js, feature: true do include TreeHelper + include WaitForAjax - let(:project) { create(:project, :public, :test_repo) } - let(:merge_request) { create(:merge_request, source_project: project, source_branch: 'feature', target_branch: 'master') } - let(:branch) { 'master' } - let(:file_path) { project.repository.ls_files(project.repository.root_ref)[1] } + let(:project) { create(:project, :public) } - context 'anonymous' do - context 'from blob file path' do + def visit_blob(path, fragment = nil) + visit namespace_project_blob_path(project.namespace, project, tree_join('master', path), anchor: fragment) + end + + context 'Ruby file' do + before do + visit_blob('files/ruby/popen.rb') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows highlighted Ruby code + expect(page).to have_content("require 'fileutils'") + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # shows an enabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + end + end + end + + context 'Markdown file' do + context 'visiting directly' do before do - visit namespace_project_blob_path(project.namespace, project, tree_join(branch, file_path)) + visit_blob('files/markdown/ruby-style-guide.md') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # hides the simple viewer + expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false) + expect(page).to have_selector('.blob-viewer[data-type="rich"]') + + # shows rendered Markdown + expect(page).to have_link("PEP-8") + + # shows a viewer switcher + expect(page).to have_selector('.js-blob-viewer-switcher') + + # shows a disabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn.disabled') + end + end + + context 'switching to the simple viewer' do + before do + find('.js-blob-viewer-switch-btn[data-viewer=simple]').click + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # hides the rich viewer + expect(page).to have_selector('.blob-viewer[data-type="simple"]') + expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false) + + # shows highlighted Markdown code + expect(page).to have_content("[PEP-8](http://www.python.org/dev/peps/pep-0008/)") + + # shows an enabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + end + end + + context 'switching to the rich viewer again' do + before do + find('.js-blob-viewer-switch-btn[data-viewer=rich]').click + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # hides the simple viewer + expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false) + expect(page).to have_selector('.blob-viewer[data-type="rich"]') + + # shows an enabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + end + end + end end + end + + context 'visiting with a line number anchor' do + before do + visit_blob('files/markdown/ruby-style-guide.md', 'L1') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # hides the rich viewer + expect(page).to have_selector('.blob-viewer[data-type="simple"]') + expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false) + + # highlights the line in question + expect(page).to have_selector('#LC1.hll') + + # shows highlighted Markdown code + expect(page).to have_content("[PEP-8](http://www.python.org/dev/peps/pep-0008/)") + + # shows an enabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + end + end + end + end + + context 'Markdown file (stored in LFS)' do + before do + project.add_master(project.creator) + + Files::CreateService.new( + project, + project.creator, + start_branch: 'master', + branch_name: 'master', + commit_message: "Add Markdown in LFS", + file_path: 'files/lfs/file.md', + file_content: project.repository.blob_at('master', 'files/lfs/lfs_object.iso').data + ).execute + end + + context 'when LFS is enabled on the project' do + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + project.update_attribute(:lfs_enabled, true) + + visit_blob('files/lfs/file.md') + + wait_for_ajax + end + + it 'displays an error' do + aggregate_failures do + # hides the simple viewer + expect(page).to have_selector('.blob-viewer[data-type="simple"]', visible: false) + expect(page).to have_selector('.blob-viewer[data-type="rich"]') + + # shows an error message + expect(page).to have_content('The rendered file could not be displayed because it is stored in LFS. You can view the source or download it instead.') + + # shows a viewer switcher + expect(page).to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + end + end + + context 'switching to the simple viewer' do + before do + find('.js-blob-viewer-switcher .js-blob-viewer-switch-btn[data-viewer=simple]').click + + wait_for_ajax + end + + it 'displays an error' do + aggregate_failures do + # hides the rich viewer + expect(page).to have_selector('.blob-viewer[data-type="simple"]') + expect(page).to have_selector('.blob-viewer[data-type="rich"]', visible: false) + + # shows an error message + expect(page).to have_content('The source could not be displayed because it is stored in LFS. You can download it instead.') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + end + end + end + end + + context 'when LFS is disabled on the project' do + before do + visit_blob('files/lfs/file.md') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows text + expect(page).to have_content('size 1575078') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # shows an enabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + end + end + end + end + + context 'PDF file' do + before do + project.add_master(project.creator) + + Files::CreateService.new( + project, + project.creator, + start_branch: 'master', + branch_name: 'master', + commit_message: "Add PDF", + file_path: 'files/test.pdf', + file_content: File.read(Rails.root.join('spec/javascripts/blob/pdf/test.pdf')) + ).execute + + visit_blob('files/test.pdf') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows rendered PDF + expect(page).to have_selector('.js-pdf-viewer') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + end + end + end + + context 'ISO file (stored in LFS)' do + context 'when LFS is enabled on the project' do + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + project.update_attribute(:lfs_enabled, true) + + visit_blob('files/lfs/lfs_object.iso') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows a download link + expect(page).to have_link('Download (1.5 MB)') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') + end + end + end + + context 'when LFS is disabled on the project' do + before do + visit_blob('files/lfs/lfs_object.iso') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows text + expect(page).to have_content('size 1575078') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') + + # shows an enabled copy button + expect(page).to have_selector('.js-copy-blob-source-btn:not(.disabled)') + end + end + end + end + + context 'ZIP file' do + before do + visit_blob('Gemfile.zip') + + wait_for_ajax + end + + it 'displays the blob' do + aggregate_failures do + # shows a download link + expect(page).to have_link('Download (2.11 KB)') + + # does not show a viewer switcher + expect(page).not_to have_selector('.js-blob-viewer-switcher') - it 'updates content' do - expect(page).to have_link 'Edit' + # does not show a copy button + expect(page).not_to have_selector('.js-copy-blob-source-btn') end end end diff --git a/spec/features/projects/files/browse_files_spec.rb b/spec/features/projects/files/browse_files_spec.rb index d281043caa3c9e9c0cf369c295840d21ce0a6e97..70e96efd55704f9c4fcecb02acddd7ea5af61432 100644 --- a/spec/features/projects/files/browse_files_spec.rb +++ b/spec/features/projects/files/browse_files_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'user browses project', feature: true do +feature 'user browses project', feature: true, js: true do let(:project) { create(:project) } let(:user) { create(:user) } @@ -13,7 +13,7 @@ scenario "can see blame of '.gitignore'" do click_link ".gitignore" click_link 'Blame' - + expect(page).to have_content "*.rb" expect(page).to have_content "Dmitriy Zaporozhets" expect(page).to have_content "Initial commit" @@ -24,6 +24,7 @@ click_link 'files' click_link 'lfs' click_link 'lfs_object.iso' + wait_for_ajax expect(page).not_to have_content 'Download (1.5 MB)' expect(page).to have_content 'version https://git-lfs.github.com/spec/v1' diff --git a/spec/helpers/blob_helper_spec.rb b/spec/helpers/blob_helper_spec.rb index 508aeb7cf67ec409aa8f2d1fac2ed9fe48b181cc..379f62f73e1f909f146d175a93bdfe42936d42f0 100644 --- a/spec/helpers/blob_helper_spec.rb +++ b/spec/helpers/blob_helper_spec.rb @@ -56,15 +56,14 @@ def test(input): end end - describe "#sanitize_svg" do + describe "#sanitize_svg_data" do let(:input_svg_path) { File.join(Rails.root, 'spec', 'fixtures', 'unsanitized.svg') } let(:data) { open(input_svg_path).read } let(:expected_svg_path) { File.join(Rails.root, 'spec', 'fixtures', 'sanitized.svg') } let(:expected) { open(expected_svg_path).read } it 'retains essential elements' do - blob = OpenStruct.new(data: data) - expect(sanitize_svg(blob).data).to eq(expected) + expect(sanitize_svg_data(data)).to eq(expected) end end @@ -105,4 +104,119 @@ def test(input): expect(Capybara.string(link).find_link('Edit')[:href]).to eq('/gitlab/gitlabhq/edit/master/README.md?mr_id=10') end end + + context 'viewer related' do + include FakeBlobHelpers + + let(:project) { build(:empty_project, lfs_enabled: true) } + + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + end + + let(:viewer_class) do + Class.new(BlobViewer::Base) do + self.max_size = 1.megabyte + self.absolute_max_size = 5.megabytes + self.type = :rich + self.client_side = false + end + end + + let(:viewer) { viewer_class.new(blob) } + let(:blob) { fake_blob } + + describe '#blob_render_error_reason' do + context 'for error :too_large' do + context 'when the blob size is larger than the absolute max size' do + let(:blob) { fake_blob(size: 10.megabytes) } + + it 'returns an error message' do + expect(helper.blob_render_error_reason(viewer)).to eq('it is larger than 5 MB') + end + end + + context 'when the blob size is larger than the max size' do + let(:blob) { fake_blob(size: 2.megabytes) } + + it 'returns an error message' do + expect(helper.blob_render_error_reason(viewer)).to eq('it is larger than 1 MB') + end + end + end + + context 'for error :server_side_but_stored_in_lfs' do + let(:blob) { fake_blob(lfs: true) } + + it 'returns an error message' do + expect(helper.blob_render_error_reason(viewer)).to eq('it is stored in LFS') + end + end + end + + describe '#blob_render_error_options' do + before do + assign(:project, project) + assign(:id, File.join('master', blob.path)) + + controller.params[:controller] = 'projects/blob' + controller.params[:action] = 'show' + controller.params[:namespace_id] = project.namespace.to_param + controller.params[:project_id] = project.to_param + controller.params[:id] = File.join('master', blob.path) + end + + context 'for error :too_large' do + context 'when the max size can be overridden' do + let(:blob) { fake_blob(size: 2.megabytes) } + + it 'includes a "load it anyway" link' do + expect(helper.blob_render_error_options(viewer)).to include(/load it anyway/) + end + end + + context 'when the max size cannot be overridden' do + let(:blob) { fake_blob(size: 10.megabytes) } + + it 'does not include a "load it anyway" link' do + expect(helper.blob_render_error_options(viewer)).not_to include(/load it anyway/) + end + end + end + + context 'when the viewer is rich' do + context 'the blob is rendered as text' do + let(:blob) { fake_blob(path: 'file.md', lfs: true) } + + it 'includes a "view the source" link' do + expect(helper.blob_render_error_options(viewer)).to include(/view the source/) + end + end + + context 'the blob is not rendered as text' do + let(:blob) { fake_blob(path: 'file.pdf', binary: true, lfs: true) } + + it 'does not include a "view the source" link' do + expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/) + end + end + end + + context 'when the viewer is not rich' do + before do + viewer_class.type = :simple + end + + let(:blob) { fake_blob(path: 'file.md', lfs: true) } + + it 'does not include a "view the source" link' do + expect(helper.blob_render_error_options(viewer)).not_to include(/view the source/) + end + end + + it 'includes a "download it" link' do + expect(helper.blob_render_error_options(viewer)).to include(/download it/) + end + end + end end diff --git a/spec/javascripts/blob/viewer/index_spec.js b/spec/javascripts/blob/viewer/index_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..13f122b68b2923306a27d2398423401f0e6f521b --- /dev/null +++ b/spec/javascripts/blob/viewer/index_spec.js @@ -0,0 +1,161 @@ +/* eslint-disable no-new */ +import BlobViewer from '~/blob/viewer/index'; + +describe('Blob viewer', () => { + let blob; + preloadFixtures('blob/show.html.raw'); + + beforeEach(() => { + loadFixtures('blob/show.html.raw'); + $('#modal-upload-blob').remove(); + + blob = new BlobViewer(); + + spyOn($, 'ajax').and.callFake(() => { + const d = $.Deferred(); + + d.resolve({ + html: '<div>testing</div>', + }); + + return d.promise(); + }); + }); + + afterEach(() => { + location.hash = ''; + }); + + it('loads source file after switching views', (done) => { + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); + + setTimeout(() => { + expect($.ajax).toHaveBeenCalled(); + expect( + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]') + .classList.contains('hidden'), + ).toBeFalsy(); + + done(); + }); + }); + + it('loads source file when line number is in hash', (done) => { + location.hash = '#L1'; + + new BlobViewer(); + + setTimeout(() => { + expect($.ajax).toHaveBeenCalled(); + expect( + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]') + .classList.contains('hidden'), + ).toBeFalsy(); + + done(); + }); + }); + + 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(() => { + expect($.ajax).toHaveBeenCalled(); + return asyncClick(); + }) + .then(() => { + expect($.ajax.calls.count()).toBe(1); + expect( + document.querySelector('.blob-viewer[data-type="simple"]').getAttribute('data-loaded'), + ).toBe('true'); + + done(); + }) + .catch(() => { + fail(); + done(); + }); + }); + + describe('copy blob button', () => { + it('disabled on load', () => { + expect( + document.querySelector('.js-copy-blob-source-btn').classList.contains('disabled'), + ).toBeTruthy(); + }); + + it('has tooltip when disabled', () => { + expect( + document.querySelector('.js-copy-blob-source-btn').getAttribute('data-original-title'), + ).toBe('Switch to the source to copy it to the clipboard'); + }); + + it('enables after switching to simple view', (done) => { + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); + + setTimeout(() => { + expect($.ajax).toHaveBeenCalled(); + expect( + document.querySelector('.js-copy-blob-source-btn').classList.contains('disabled'), + ).toBeFalsy(); + + done(); + }); + }); + + it('updates tooltip after switching to simple view', (done) => { + document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]').click(); + + setTimeout(() => { + expect($.ajax).toHaveBeenCalled(); + + expect( + document.querySelector('.js-copy-blob-source-btn').getAttribute('data-original-title'), + ).toBe('Copy source to clipboard'); + + done(); + }); + }); + }); + + describe('switchToViewer', () => { + it('removes active class from old viewer button', () => { + blob.switchToViewer('simple'); + + expect( + document.querySelector('.js-blob-viewer-switch-btn.active[data-viewer="rich"]'), + ).toBeNull(); + }); + + it('adds active class to new viewer button', () => { + const simpleBtn = document.querySelector('.js-blob-viewer-switch-btn[data-viewer="simple"]'); + + spyOn(simpleBtn, 'blur'); + + blob.switchToViewer('simple'); + + expect( + simpleBtn.classList.contains('active'), + ).toBeTruthy(); + expect(simpleBtn.blur).toHaveBeenCalled(); + }); + + it('sends AJAX request when switching to simple view', () => { + blob.switchToViewer('simple'); + + expect($.ajax).toHaveBeenCalled(); + }); + + it('does not send AJAX request when switching to rich view', () => { + blob.switchToViewer('simple'); + blob.switchToViewer('rich'); + + expect($.ajax.calls.count()).toBe(1); + }); + }); +}); diff --git a/spec/javascripts/fixtures/blob.rb b/spec/javascripts/fixtures/blob.rb new file mode 100644 index 0000000000000000000000000000000000000000..16490ad503984eff3c80ab75f9140be971c89b7e --- /dev/null +++ b/spec/javascripts/fixtures/blob.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +describe Projects::BlobController, '(JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin) } + let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} + let(:project) { create(:project, :repository, namespace: namespace, path: 'branches-project') } + + render_views + + before(:all) do + clean_frontend_fixtures('blob/') + end + + before(:each) do + sign_in(admin) + end + + it 'blob/show.html.raw' do |example| + get(:show, + namespace_id: project.namespace, + project_id: project, + id: 'add-ipython-files/files/ipython/basic.ipynb') + + expect(response).to be_success + store_frontend_fixture(response, example.description) + end +end diff --git a/spec/models/blob_spec.rb b/spec/models/blob_spec.rb index e5dd57fc4bb8e1eacfbbe7cf6ce9fcd08ed0ea32..7e8a1c8add79c81a514b9156d340b5eed0d0a5e4 100644 --- a/spec/models/blob_spec.rb +++ b/spec/models/blob_spec.rb @@ -2,6 +2,14 @@ require 'rails_helper' describe Blob do + include FakeBlobHelpers + + let(:project) { build(:empty_project, lfs_enabled: true) } + + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + end + describe '.decorate' do it 'returns NilClass when given nil' do expect(described_class.decorate(nil)).to be_nil @@ -12,7 +20,7 @@ context 'using a binary blob' do it 'returns the data as-is' do data = "\n\xFF\xB9\xC3" - blob = described_class.new(double(binary?: true, data: data)) + blob = fake_blob(binary: true, data: data) expect(blob.data).to eq(data) end @@ -20,202 +28,176 @@ context 'using a text blob' do it 'converts the data to UTF-8' do - blob = described_class.new(double(binary?: false, data: "\n\xFF\xB9\xC3")) + blob = fake_blob(binary: false, data: "\n\xFF\xB9\xC3") expect(blob.data).to eq("\n���") end end end - describe '#svg?' do - it 'is falsey when not text' do - git_blob = double(text?: false) + describe '#raw_binary?' do + context 'if the blob is a valid LFS pointer' do + context 'if the extension has a rich viewer' do + context 'if the viewer is binary' do + it 'returns true' do + blob = fake_blob(path: 'file.pdf', lfs: true) - expect(described_class.decorate(git_blob)).not_to be_svg - end - - it 'is falsey when no language is detected' do - git_blob = double(text?: true, language: nil) + expect(blob.raw_binary?).to be_truthy + end + end - expect(described_class.decorate(git_blob)).not_to be_svg - end + context 'if the viewer is text-based' do + it 'return false' do + blob = fake_blob(path: 'file.md', lfs: true) - it' is falsey when language is not SVG' do - git_blob = double(text?: true, language: double(name: 'XML')) - - expect(described_class.decorate(git_blob)).not_to be_svg - end - - it 'is truthy when language is SVG' do - git_blob = double(text?: true, language: double(name: 'SVG')) - - expect(described_class.decorate(git_blob)).to be_svg - end - end - - describe '#pdf?' do - it 'is falsey when file extension is not .pdf' do - git_blob = Gitlab::Git::Blob.new(name: 'git_blob.txt') - - expect(described_class.decorate(git_blob)).not_to be_pdf - end + expect(blob.raw_binary?).to be_falsey + end + end + end - it 'is truthy when file extension is .pdf' do - git_blob = Gitlab::Git::Blob.new(name: 'git_blob.pdf') + context "if the extension doesn't have a rich viewer" do + it 'returns true' do + blob = fake_blob(path: 'file.exe', lfs: true) - expect(described_class.decorate(git_blob)).to be_pdf + expect(blob.raw_binary?).to be_truthy + end + end end - end - describe '#ipython_notebook?' do - it 'is falsey when language is not Jupyter Notebook' do - git_blob = double(text?: true, language: double(name: 'JSON')) + context 'if the blob is not an LFS pointer' do + context 'if the blob is binary' do + it 'returns true' do + blob = fake_blob(path: 'file.pdf', binary: true) - expect(described_class.decorate(git_blob)).not_to be_ipython_notebook - end + expect(blob.raw_binary?).to be_truthy + end + end - it 'is truthy when language is Jupyter Notebook' do - git_blob = double(text?: true, language: double(name: 'Jupyter Notebook')) + context 'if the blob is text-based' do + it 'return false' do + blob = fake_blob(path: 'file.md') - expect(described_class.decorate(git_blob)).to be_ipython_notebook + expect(blob.raw_binary?).to be_falsey + end + end end end - describe '#sketch?' do - it 'is falsey with image extension' do - git_blob = Gitlab::Git::Blob.new(name: "design.png") - - expect(described_class.decorate(git_blob)).not_to be_sketch - end - - it 'is truthy with sketch extension' do - git_blob = Gitlab::Git::Blob.new(name: "design.sketch") + describe '#extension' do + it 'returns the extension' do + blob = fake_blob(path: 'file.md') - expect(described_class.decorate(git_blob)).to be_sketch + expect(blob.extension).to eq('md') end end - describe '#video?' do - it 'is falsey with image extension' do - git_blob = Gitlab::Git::Blob.new(name: 'image.png') + describe '#simple_viewer' do + context 'when the blob is empty' do + it 'returns an empty viewer' do + blob = fake_blob(data: '') - expect(described_class.decorate(git_blob)).not_to be_video - end - - UploaderHelper::VIDEO_EXT.each do |ext| - it "is truthy when extension is .#{ext}" do - git_blob = Gitlab::Git::Blob.new(name: "video.#{ext}") - - expect(described_class.decorate(git_blob)).to be_video + expect(blob.simple_viewer).to be_a(BlobViewer::Empty) end end - end - describe '#stl?' do - it 'is falsey with image extension' do - git_blob = Gitlab::Git::Blob.new(name: 'file.png') + context 'when the file represented by the blob is binary' do + it 'returns a download viewer' do + blob = fake_blob(binary: true) - expect(described_class.decorate(git_blob)).not_to be_stl + expect(blob.simple_viewer).to be_a(BlobViewer::Download) + end end - it 'is truthy with STL extension' do - git_blob = Gitlab::Git::Blob.new(name: 'file.stl') + context 'when the file represented by the blob is text-based' do + it 'returns a text viewer' do + blob = fake_blob - expect(described_class.decorate(git_blob)).to be_stl + expect(blob.simple_viewer).to be_a(BlobViewer::Text) + end end end - describe '#to_partial_path' do - let(:project) { double(lfs_enabled?: true) } + describe '#rich_viewer' do + context 'when the blob is an invalid LFS pointer' do + before do + project.lfs_enabled = false + end - def stubbed_blob(overrides = {}) - overrides.reverse_merge!( - name: nil, - image?: false, - language: nil, - lfs_pointer?: false, - svg?: false, - text?: false, - binary?: false, - stl?: false - ) + it 'returns nil' do + blob = fake_blob(path: 'file.pdf', lfs: true) - described_class.decorate(Gitlab::Git::Blob.new({})).tap do |blob| - allow(blob).to receive_messages(overrides) + expect(blob.rich_viewer).to be_nil end end - it 'handles LFS pointers with LFS enabled' do - blob = stubbed_blob(lfs_pointer?: true, text?: true) - expect(blob.to_partial_path(project)).to eq 'download' - end - - it 'handles LFS pointers with LFS disabled' do - blob = stubbed_blob(lfs_pointer?: true, text?: true) - project = double(lfs_enabled?: false) - expect(blob.to_partial_path(project)).to eq 'text' - end + context 'when the blob is empty' do + it 'returns nil' do + blob = fake_blob(data: '') - it 'handles SVGs' do - blob = stubbed_blob(text?: true, svg?: true) - expect(blob.to_partial_path(project)).to eq 'svg' + expect(blob.rich_viewer).to be_nil + end end - it 'handles images' do - blob = stubbed_blob(image?: true) - expect(blob.to_partial_path(project)).to eq 'image' - end + context 'when the blob is a valid LFS pointer' do + it 'returns a matching viewer' do + blob = fake_blob(path: 'file.pdf', lfs: true) - it 'handles text' do - blob = stubbed_blob(text?: true, name: 'test.txt') - expect(blob.to_partial_path(project)).to eq 'text' - end - - it 'defaults to download' do - blob = stubbed_blob - expect(blob.to_partial_path(project)).to eq 'download' + expect(blob.rich_viewer).to be_a(BlobViewer::PDF) + end end - it 'handles PDFs' do - blob = stubbed_blob(name: 'blob.pdf', pdf?: true) - expect(blob.to_partial_path(project)).to eq 'pdf' - end + context 'when the blob is binary' do + it 'returns a matching binary viewer' do + blob = fake_blob(path: 'file.pdf', binary: true) - it 'handles iPython notebooks' do - blob = stubbed_blob(text?: true, ipython_notebook?: true) - expect(blob.to_partial_path(project)).to eq 'notebook' + expect(blob.rich_viewer).to be_a(BlobViewer::PDF) + end end - it 'handles Sketch files' do - blob = stubbed_blob(text?: true, sketch?: true, binary?: true) - expect(blob.to_partial_path(project)).to eq 'sketch' - end + context 'when the blob is text-based' do + it 'returns a matching text-based viewer' do + blob = fake_blob(path: 'file.md') - it 'handles STLs' do - blob = stubbed_blob(text?: true, stl?: true) - expect(blob.to_partial_path(project)).to eq 'stl' + expect(blob.rich_viewer).to be_a(BlobViewer::Markup) + end end end - describe '#size_within_svg_limits?' do - let(:blob) { described_class.decorate(double(:blob)) } + describe '#rendered_as_text?' do + context 'when ignoring errors' do + context 'when the simple viewer is text-based' do + it 'returns true' do + blob = fake_blob(path: 'file.md', size: 100.megabytes) - it 'returns true when the blob size is smaller than the SVG limit' do - expect(blob).to receive(:size).and_return(42) + expect(blob.rendered_as_text?).to be_truthy + end + end + + context 'when the simple viewer is binary' do + it 'returns false' do + blob = fake_blob(path: 'file.pdf', binary: true, size: 100.megabytes) - expect(blob.size_within_svg_limits?).to eq(true) + expect(blob.rendered_as_text?).to be_falsey + end + end end - it 'returns true when the blob size is equal to the SVG limit' do - expect(blob).to receive(:size).and_return(Blob::MAXIMUM_SVG_SIZE) + context 'when not ignoring errors' do + context 'when the viewer has render errors' do + it 'returns false' do + blob = fake_blob(path: 'file.md', size: 100.megabytes) - expect(blob.size_within_svg_limits?).to eq(true) - end + expect(blob.rendered_as_text?(ignore_errors: false)).to be_falsey + end + end - it 'returns false when the blob size is larger than the SVG limit' do - expect(blob).to receive(:size).and_return(1.terabyte) + context "when the viewer doesn't have render errors" do + it 'returns true' do + blob = fake_blob(path: 'file.md') - expect(blob.size_within_svg_limits?).to eq(false) + expect(blob.rendered_as_text?(ignore_errors: false)).to be_truthy + end + end end end end diff --git a/spec/models/blob_viewer/base_spec.rb b/spec/models/blob_viewer/base_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a3e598de56d9819fe6a0a6f4b70f524d56af27fb --- /dev/null +++ b/spec/models/blob_viewer/base_spec.rb @@ -0,0 +1,186 @@ +require 'spec_helper' + +describe BlobViewer::Base, model: true do + include FakeBlobHelpers + + let(:project) { build(:empty_project) } + + let(:viewer_class) do + Class.new(described_class) do + self.extensions = %w(pdf) + self.max_size = 1.megabyte + self.absolute_max_size = 5.megabytes + self.client_side = false + end + end + + let(:viewer) { viewer_class.new(blob) } + + describe '.can_render?' do + context 'when the extension is supported' do + let(:blob) { fake_blob(path: 'file.pdf') } + + it 'returns true' do + expect(viewer_class.can_render?(blob)).to be_truthy + end + end + + context 'when the extension is not supported' do + let(:blob) { fake_blob(path: 'file.txt') } + + it 'returns false' do + expect(viewer_class.can_render?(blob)).to be_falsey + end + end + end + + describe '#too_large?' do + context 'when the blob size is larger than the max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } + + it 'returns true' do + expect(viewer.too_large?).to be_truthy + end + end + + context 'when the blob size is smaller than the max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } + + it 'returns false' do + expect(viewer.too_large?).to be_falsey + end + end + end + + describe '#absolutely_too_large?' do + context 'when the blob size is larger than the absolute max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } + + it 'returns true' do + expect(viewer.absolutely_too_large?).to be_truthy + end + end + + context 'when the blob size is smaller than the absolute max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } + + it 'returns false' do + expect(viewer.absolutely_too_large?).to be_falsey + end + end + end + + describe '#can_override_max_size?' do + context 'when the blob size is larger than the max size' do + context 'when the blob size is larger than the absolute max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } + + it 'returns false' do + expect(viewer.can_override_max_size?).to be_falsey + end + end + + context 'when the blob size is smaller than the absolute max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } + + it 'returns true' do + expect(viewer.can_override_max_size?).to be_truthy + end + end + end + + context 'when the blob size is smaller than the max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } + + it 'returns false' do + expect(viewer.can_override_max_size?).to be_falsey + end + end + end + + describe '#render_error' do + context 'when the max size is overridden' do + before do + viewer.override_max_size = true + end + + context 'when the blob size is larger than the absolute max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 10.megabytes) } + + it 'returns :too_large' do + expect(viewer.render_error).to eq(:too_large) + end + end + + context 'when the blob size is smaller than the absolute max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } + + it 'returns nil' do + expect(viewer.render_error).to be_nil + end + end + end + + context 'when the max size is not overridden' do + context 'when the blob size is larger than the max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 2.megabytes) } + + it 'returns :too_large' do + expect(viewer.render_error).to eq(:too_large) + end + end + + context 'when the blob size is smaller than the max size' do + let(:blob) { fake_blob(path: 'file.pdf', size: 10.kilobytes) } + + it 'returns nil' do + expect(viewer.render_error).to be_nil + end + end + end + + context 'when the viewer is server side but the blob is stored in LFS' do + let(:project) { build(:empty_project, lfs_enabled: true) } + + let(:blob) { fake_blob(path: 'file.pdf', lfs: true) } + + before do + allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) + end + + it 'return :server_side_but_stored_in_lfs' do + expect(viewer.render_error).to eq(:server_side_but_stored_in_lfs) + end + end + end + + describe '#prepare!' do + context 'when the viewer is server side' do + let(:blob) { fake_blob(path: 'file.md') } + + before do + viewer_class.client_side = false + end + + it 'loads all blob data' do + expect(blob).to receive(:load_all_data!) + + viewer.prepare! + end + end + + context 'when the viewer is client side' do + let(:blob) { fake_blob(path: 'file.md') } + + before do + viewer_class.client_side = true + end + + it "doesn't load all blob data" do + expect(blob).not_to receive(:load_all_data!) + + viewer.prepare! + end + end + end +end diff --git a/spec/support/helpers/fake_blob_helpers.rb b/spec/support/helpers/fake_blob_helpers.rb new file mode 100644 index 0000000000000000000000000000000000000000..b29af732ad320440c6b3b70f7ba418c05338ee5f --- /dev/null +++ b/spec/support/helpers/fake_blob_helpers.rb @@ -0,0 +1,50 @@ +module FakeBlobHelpers + class FakeBlob + include Linguist::BlobHelper + + attr_reader :path, :size, :data, :lfs_oid, :lfs_size + + def initialize(path: 'file.txt', size: 1.kilobyte, data: 'foo', binary: false, lfs: nil) + @path = path + @size = size + @data = data + @binary = binary + + @lfs_pointer = lfs.present? + if @lfs_pointer + @lfs_oid = SecureRandom.hex(20) + @lfs_size = 1.megabyte + end + end + + alias_method :name, :path + + def mode + nil + end + + def id + 0 + end + + def binary? + @binary + end + + def load_all_data!(repository) + # No-op + end + + def lfs_pointer? + @lfs_pointer + end + + def truncated? + false + end + end + + def fake_blob(**kwargs) + Blob.decorate(FakeBlob.new(**kwargs), project) + end +end diff --git a/spec/views/projects/blob/_viewer.html.haml_spec.rb b/spec/views/projects/blob/_viewer.html.haml_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a4915264abe19048d0573dba42e97393a137d915 --- /dev/null +++ b/spec/views/projects/blob/_viewer.html.haml_spec.rb @@ -0,0 +1,96 @@ +require 'spec_helper' + +describe 'projects/blob/_viewer.html.haml', :view do + include FakeBlobHelpers + + let(:project) { build(:empty_project) } + + let(:viewer_class) do + Class.new(BlobViewer::Base) do + include BlobViewer::Rich + + self.partial_name = 'text' + self.max_size = 1.megabyte + self.absolute_max_size = 5.megabytes + self.client_side = false + end + end + + let(:viewer) { viewer_class.new(blob) } + let(:blob) { fake_blob } + + before do + assign(:project, project) + assign(:id, File.join('master', blob.path)) + + controller.params[:controller] = 'projects/blob' + controller.params[:action] = 'show' + controller.params[:namespace_id] = project.namespace.to_param + controller.params[:project_id] = project.to_param + controller.params[:id] = File.join('master', blob.path) + end + + def render_view + render partial: 'projects/blob/viewer', locals: { viewer: viewer } + end + + context 'when the viewer is server side' do + before do + viewer_class.client_side = false + end + + context 'when there is no render error' do + it 'adds a URL to the blob viewer element' do + render_view + + expect(rendered).to have_css('.blob-viewer[data-url]') + end + + it 'displays a spinner' do + render_view + + expect(rendered).to have_css('i[aria-label="Loading content"]') + end + end + + context 'when there is a render error' do + let(:blob) { fake_blob(size: 10.megabytes) } + + it 'renders the error' do + render_view + + expect(view).to render_template('projects/blob/_render_error') + end + end + end + + context 'when the viewer is client side' do + before do + viewer_class.client_side = true + end + + context 'when there is no render error' do + it 'prepares the viewer' do + expect(viewer).to receive(:prepare!) + + render_view + end + + it 'renders the viewer' do + render_view + + expect(view).to render_template('projects/blob/viewers/_text') + end + end + + context 'when there is a render error' do + let(:blob) { fake_blob(size: 10.megabytes) } + + it 'renders the error' do + render_view + + expect(view).to render_template('projects/blob/_render_error') + end + end + end +end