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