From 4b329024c78a97b5c613bf84f03fbb9c2f40debb Mon Sep 17 00:00:00 2001
From: Antoine Lambert <anlambert@softwareheritage.org>
Date: Thu, 20 Mar 2025 15:12:42 +0100
Subject: [PATCH 1/3] browse: Ensure only source code is highlighted in content
 views

---
 swh/web/browse/templates/includes/content-display.html | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/swh/web/browse/templates/includes/content-display.html b/swh/web/browse/templates/includes/content-display.html
index 90052b810..e7b65dc8b 100644
--- a/swh/web/browse/templates/includes/content-display.html
+++ b/swh/web/browse/templates/includes/content-display.html
@@ -58,7 +58,7 @@ See top-level LICENSE file for more information
         let codeContainer = $('code');
         let content = codeContainer.text();
 
-        swh.webapp.highlightCode(true, 'code', !{{ iframe_mode|jsonify }});
+        swh.webapp.highlightCode(true, '.swh-content code', !{{ iframe_mode|jsonify }});
 
         function updateLanguage(language) {
           codeContainer.text(content);
@@ -71,7 +71,7 @@ See top-level LICENSE file for more information
           const newUrl = window.location.pathname + '?' + urlParams.toString() + window.location.hash;
           window.history.replaceState('', document.title, newUrl);
 
-          swh.webapp.highlightCode(true, 'code', !{{ iframe_mode|jsonify }});
+          swh.webapp.highlightCode(true, '.swh-content code', !{{ iframe_mode|jsonify }});
         }
 
       {% endif %}
-- 
GitLab


From 49c73e7faa5d22cffa9cb3c9304e99c468c0725d Mon Sep 17 00:00:00 2001
From: Antoine Lambert <anlambert@softwareheritage.org>
Date: Thu, 20 Mar 2025 15:13:36 +0100
Subject: [PATCH 2/3] browse/revision: Fix a couple of issues in the UI

Do not display the "Download" button for vault cooking when
browsing a content.

Fix breadcrumb link for root directory.
---
 swh/web/browse/views/revision.py | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/swh/web/browse/views/revision.py b/swh/web/browse/views/revision.py
index 179435272..072e079ec 100644
--- a/swh/web/browse/views/revision.py
+++ b/swh/web/browse/views/revision.py
@@ -392,7 +392,7 @@ def revision_browse(request: HttpRequest, sha1_git: str) -> HttpResponse:
         dir_id = revision["directory"]
 
     if dir_id:
-        path = "" if path is None else (path + "/")
+        path = "" if not path else (path + "/")
         dirs, files = get_directory_entries(dir_id)
 
     revision_metadata = RevisionMetadata(
@@ -445,7 +445,7 @@ def revision_browse(request: HttpRequest, sha1_git: str) -> HttpResponse:
             "url": reverse(
                 "browse-revision",
                 url_args={"sha1_git": sha1_git},
-                query_params=query_params,
+                query_params={**query_params, **{"path": ""}},
             ),
         }
     )
@@ -463,9 +463,9 @@ def revision_browse(request: HttpRequest, sha1_git: str) -> HttpResponse:
         )
 
     vault_cooking = {
-        "directory_context": False,
-        "directory_swhid": None,
-        "revision_context": True,
+        "directory_context": not bool(content_data),
+        "directory_swhid": f"swh:1:dir:{revision['directory']}",
+        "revision_context": not bool(content_data),
         "revision_swhid": f"swh:1:rev:{sha1_git}",
     }
 
-- 
GitLab


From 5ec5cab34d770052104859d39acb494530e2248a Mon Sep 17 00:00:00 2001
From: Antoine Lambert <anlambert@softwareheritage.org>
Date: Thu, 20 Mar 2025 18:08:50 +0100
Subject: [PATCH 3/3] browse: Add Download button to content views

This reworked button offers the same feature as the "Raw File" one
except the content will be downloaded and saved to disk instead of
rendering it in the browser.

Fixes #4834.
---
 .../templates/includes/top-navigation.html    | 18 ++++-----
 swh/web/browse/tests/views/test_content.py    |  7 ++++
 swh/web/browse/tests/views/test_revision.py   | 39 ++++++++++++++++---
 swh/web/browse/views/content.py               | 17 +++++++-
 swh/web/browse/views/directory.py             |  3 +-
 swh/web/browse/views/release.py               |  4 +-
 swh/web/browse/views/revision.py              | 18 ++++++++-
 swh/web/conftest.py                           | 36 +++++++++++++----
 swh/web/settings/common.py                    | 21 +++++-----
 .../includes/vault-create-tasks.html          | 13 ++++++-
 10 files changed, 137 insertions(+), 39 deletions(-)

diff --git a/swh/web/browse/templates/includes/top-navigation.html b/swh/web/browse/templates/includes/top-navigation.html
index 71ef5d313..5c15021af 100644
--- a/swh/web/browse/templates/includes/top-navigation.html
+++ b/swh/web/browse/templates/includes/top-navigation.html
@@ -1,5 +1,5 @@
 {% comment %}
-Copyright (C) 2017-2024  The Software Heritage developers
+Copyright (C) 2017-2025  The Software Heritage developers
 See the AUTHORS file at the top-level directory of this distribution
 License: GNU Affero General Public License version 3, or any later version
 See top-level LICENSE file for more information
@@ -127,19 +127,19 @@ See top-level LICENSE file for more information
         {{ top_right_link.text }}
       </a>
     {% endif %}
-    {% if available_languages %}
-      <select data-placeholder="Select Language"
-              class="language-select chosen-select">
-        <option value=""></option>
-        {% for lang in available_languages %}<option value="{{ lang }}">{{ lang }}</option>{% endfor %}
-      </select>
-    {% endif %}
     {% if show_actions %}
-      {% if "swh.web.vault" in SWH_DJANGO_APPS and vault_cooking %}
+      {% if "swh.web.vault" in SWH_DJANGO_APPS and vault_cooking or vault_cooking and vault_cooking.content_context %}
         {% if not snapshot_context or not snapshot_context.is_empty %}
           {% include "includes/vault-create-tasks.html" %}
         {% endif %}
       {% endif %}
+      {% if available_languages %}
+        <select data-placeholder="Select Language"
+                class="language-select chosen-select">
+          <option value=""></option>
+          {% for lang in available_languages %}<option value="{{ lang }}">{{ lang }}</option>{% endfor %}
+        </select>
+      {% endif %}
       {% if "swh.web.save_code_now" in SWH_DJANGO_APPS or SWH_MIRROR_CONFIG %}
         {% include "includes/take-new-snapshot.html" %}
       {% endif %}
diff --git a/swh/web/browse/tests/views/test_content.py b/swh/web/browse/tests/views/test_content.py
index 344b4a2ea..47cee1a70 100644
--- a/swh/web/browse/tests/views/test_content.py
+++ b/swh/web/browse/tests/views/test_content.py
@@ -1125,6 +1125,13 @@ def _origin_content_view_test_helper(
 
     assert_not_contains(resp, "swh-metadata-popover")
 
+    content_download_url = reverse(
+        "api-1-content-raw",
+        url_args={"q": f"sha1_git:{content['sha1_git']}"},
+        query_params={"filename": filename},
+    )
+    assert_contains(resp, content_download_url)
+
 
 def _check_origin_link(resp, origin_url):
     browse_origin_url = reverse(
diff --git a/swh/web/browse/tests/views/test_revision.py b/swh/web/browse/tests/views/test_revision.py
index 0107b541d..0f01fa037 100644
--- a/swh/web/browse/tests/views/test_revision.py
+++ b/swh/web/browse/tests/views/test_revision.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017-2022  The Software Heritage developers
+# Copyright (C) 2017-2025  The Software Heritage developers
 # See the AUTHORS file at the top-level directory of this distribution
 # License: GNU Affero General Public License version 3, or any later version
 # See top-level LICENSE file for more information
@@ -42,6 +42,15 @@ def test_revision_origin_snapshot_browse(client, archive_data, swh_scheduler, or
     _revision_browse_checks(client, archive_data, revision, origin_url=origin["url"])
 
 
+def test_revision_browse_content(
+    client, archive_data, revision_with_files_in_target_directory
+):
+    revision = archive_data.revision_get(revision_with_files_in_target_directory)
+    dir_entries = archive_data.directory_ls(revision["directory"])
+    content = random.choice([entry for entry in dir_entries if entry["type"] == "file"])
+    _revision_browse_checks(client, archive_data, revision["id"], content=content)
+
+
 def test_revision_log_browse(client, archive_data, revision):
     per_page = 10
 
@@ -296,18 +305,28 @@ def test_revision_uppercase(client, revision):
 
 
 def _revision_browse_checks(
-    client, archive_data, revision, origin_url=None, snapshot=None
+    client,
+    archive_data,
+    revision,
+    origin_url=None,
+    snapshot=None,
+    content=None,
 ):
     query_params = {}
     if origin_url:
         query_params["origin_url"] = origin_url
     if snapshot:
         query_params["snapshot"] = snapshot["id"]
+    if content:
+        query_params["path"] = content["name"]
 
     url = reverse(
         "browse-revision", url_args={"sha1_git": revision}, query_params=query_params
     )
 
+    if content:
+        del query_params["path"]
+
     revision_data = archive_data.revision_get(revision)
 
     author_name = revision_data["author"]["name"]
@@ -334,7 +353,8 @@ def _revision_browse_checks(
     )
     assert_contains(resp, author_name)
     assert_contains(resp, committer_name)
-    assert_contains(resp, history_url)
+    if content is None:
+        assert_contains(resp, history_url)
 
     for parent in revision_data["parents"]:
         parent_url = reverse(
@@ -352,8 +372,9 @@ def _revision_browse_checks(
     assert_contains(resp, escape(message_lines[0]))
     assert_contains(resp, escape("\n".join(message_lines[1:])))
 
-    assert_contains(resp, "vault-cook-directory")
-    assert_contains(resp, "vault-cook-revision")
+    if content is None:
+        assert_contains(resp, "vault-cook-directory")
+        assert_contains(resp, "vault-cook-revision")
 
     swh_rev_id = gen_swhid(ObjectType.REVISION, revision)
     swh_rev_id_url = reverse("browse-swhid", url_args={"swhid": swh_rev_id})
@@ -399,6 +420,14 @@ def _revision_browse_checks(
     assert_contains(resp, swh_dir_id)
     assert_contains(resp, swh_dir_id_url)
 
+    if content:
+        content_download_url = reverse(
+            "api-1-content-raw",
+            url_args={"q": f"sha1_git:{content['checksums']['sha1_git']}"},
+            query_params={"filename": content["name"]},
+        )
+        assert_contains(resp, content_download_url)
+
 
 def test_revision_invalid_path(client, archive_data, revision):
     path = "foo/bar"
diff --git a/swh/web/browse/views/content.py b/swh/web/browse/views/content.py
index 18348c956..2b431a691 100644
--- a/swh/web/browse/views/content.py
+++ b/swh/web/browse/views/content.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017-2024  The Software Heritage developers
+# Copyright (C) 2017-2025  The Software Heritage developers
 # See the AUTHORS file at the top-level directory of this distribution
 # License: GNU Affero General Public License version 3, or any later version
 # See top-level LICENSE file for more information
@@ -484,6 +484,19 @@ def content_display(
         content_path = "/".join(bc["name"] for bc in breadcrumbs)
         heading += " - %s" % content_path
 
+    vault_cooking = None
+    if content_checksums and "sha1_git" in content_checksums:
+        vault_cooking = {
+            "directory_context": False,
+            "revision_context": False,
+            "content_context": True,
+            "content_download_url": reverse(
+                "api-1-content-raw",
+                url_args={"q": f"sha1_git:{content_checksums['sha1_git']}"},
+                query_params={"filename": filename},
+            ),
+        }
+
     return render(
         request,
         "browse-content.html",
@@ -507,7 +520,7 @@ def content_display(
                 "text": "Raw File",
             },
             "snapshot_context": snapshot_context,
-            "vault_cooking": None,
+            "vault_cooking": vault_cooking,
             "show_actions": True,
             "swhids_info": swhids_info,
             "error_code": error_info["status_code"],
diff --git a/swh/web/browse/views/directory.py b/swh/web/browse/views/directory.py
index ecadd027e..67c2777c5 100644
--- a/swh/web/browse/views/directory.py
+++ b/swh/web/browse/views/directory.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017-2022  The Software Heritage developers
+# Copyright (C) 2017-2025  The Software Heritage developers
 # See the AUTHORS file at the top-level directory of this distribution
 # License: GNU Affero General Public License version 3, or any later version
 # See top-level LICENSE file for more information
@@ -181,6 +181,7 @@ def _directory_browse(
     )
 
     vault_cooking = {
+        "content_context": False,
         "directory_context": True,
         "directory_swhid": f"swh:1:dir:{dir_sha1_git}",
         "revision_context": False,
diff --git a/swh/web/browse/views/release.py b/swh/web/browse/views/release.py
index 653d56f80..06eb4b1a7 100644
--- a/swh/web/browse/views/release.py
+++ b/swh/web/browse/views/release.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017-2022  The Software Heritage developers
+# Copyright (C) 2017-2025  The Software Heritage developers
 # See the AUTHORS file at the top-level directory of this distribution
 # License: GNU Affero General Public License version 3, or any later version
 # See top-level LICENSE file for more information
@@ -124,6 +124,7 @@ def release_browse(request: HttpRequest, sha1_git: str) -> HttpResponse:
             revision = archive.lookup_revision(release["target"])
             rev_directory = revision["directory"]
             vault_cooking = {
+                "content_context": False,
                 "directory_context": True,
                 "directory_swhid": f"swh:1:dir:{rev_directory}",
                 "revision_context": True,
@@ -150,6 +151,7 @@ def release_browse(request: HttpRequest, sha1_git: str) -> HttpResponse:
             # check directory exists
             archive.lookup_directory(release["target"])
             vault_cooking = {
+                "content_context": False,
                 "directory_context": True,
                 "directory_swhid": f"swh:1:dir:{release['target']}",
                 "revision_context": False,
diff --git a/swh/web/browse/views/revision.py b/swh/web/browse/views/revision.py
index 072e079ec..f96a8757f 100644
--- a/swh/web/browse/views/revision.py
+++ b/swh/web/browse/views/revision.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017-2022  The Software Heritage developers
+# Copyright (C) 2017-2025  The Software Heritage developers
 # See the AUTHORS file at the top-level directory of this distribution
 # License: GNU Affero General Public License version 3, or any later version
 # See top-level LICENSE file for more information
@@ -463,6 +463,7 @@ def revision_browse(request: HttpRequest, sha1_git: str) -> HttpResponse:
         )
 
     vault_cooking = {
+        "content_context": bool(content_data),
         "directory_context": not bool(content_data),
         "directory_swhid": f"swh:1:dir:{revision['directory']}",
         "revision_context": not bool(content_data),
@@ -514,6 +515,16 @@ def revision_browse(request: HttpRequest, sha1_git: str) -> HttpResponse:
                 # disable language select dropdown when a notebook is rendered
                 available_languages = None
 
+            if filepath:
+                dir_id = archive.lookup_directory_with_path(
+                    revision["directory"], filepath
+                )["target"]
+            else:
+                dir_id = revision["directory"]
+            swh_objects.append(
+                SWHObjectInfo(object_type=ObjectType.DIRECTORY, object_id=dir_id)
+            )
+
         top_right_link = {
             "url": reverse(
                 "browse-content-raw",
@@ -527,6 +538,11 @@ def revision_browse(request: HttpRequest, sha1_git: str) -> HttpResponse:
         swh_objects.append(
             SWHObjectInfo(object_type=ObjectType.CONTENT, object_id=file_info["target"])
         )
+        vault_cooking["content_download_url"] = reverse(
+            "api-1-content-raw",
+            url_args={"q": f"sha1_git:{file_info['target']}"},
+            query_params={"filename": filename},
+        )
     else:
         for d in dirs:
             if d["type"] == "rev":
diff --git a/swh/web/conftest.py b/swh/web/conftest.py
index 4c9fc2b6a..93b884268 100644
--- a/swh/web/conftest.py
+++ b/swh/web/conftest.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2018-2024  The Software Heritage developers
+# Copyright (C) 2018-2025  The Software Heritage developers
 # See the AUTHORS file at the top-level directory of this distribution
 # License: GNU Affero General Public License version 3, or any later version
 # See top-level LICENSE file for more information
@@ -476,17 +476,21 @@ def directory(tests_data):
     return random.choice(_known_swh_objects(tests_data, "directories"))
 
 
+def _directory_contains_entry_type(tests_data, dir_id, entry_type):
+    return any(
+        [
+            e["type"] == entry_type
+            for e in list(tests_data["storage"].directory_ls(hash_to_bytes(dir_id)))
+        ]
+    )
+
+
 @functools.lru_cache(maxsize=None)
 def _directory_with_entry_type(type_):
     tests_data = get_tests_data()
     return list(
         filter(
-            lambda d: any(
-                [
-                    e["type"] == type_
-                    for e in list(tests_data["storage"].directory_ls(hash_to_bytes(d)))
-                ]
-            ),
+            lambda d: _directory_contains_entry_type(tests_data, d, type_),
             _known_swh_objects(tests_data, "directories"),
         )
     )
@@ -506,6 +510,24 @@ def directory_with_files():
     return random.choice(_directory_with_entry_type("file"))
 
 
+@pytest.fixture(scope="function")
+def revision_with_files_in_target_directory(tests_data):
+    """Fixture returning a random directory containing at least one regular file."""
+    storage = tests_data["storage"]
+    return random.choice(
+        list(
+            filter(
+                lambda r: _directory_contains_entry_type(
+                    tests_data,
+                    storage.revision_get([hash_to_bytes(r)])[0].directory.hex(),
+                    "file",
+                ),
+                _known_swh_objects(tests_data, "revisions"),
+            )
+        )
+    )
+
+
 @pytest.fixture(scope="function")
 def unknown_directory(tests_data):
     """Fixture returning a random directory not ingested into the test archive."""
diff --git a/swh/web/settings/common.py b/swh/web/settings/common.py
index c2b91a692..c264d74f3 100644
--- a/swh/web/settings/common.py
+++ b/swh/web/settings/common.py
@@ -1,4 +1,4 @@
-# Copyright (C) 2017-2024  The Software Heritage developers
+# Copyright (C) 2017-2025  The Software Heritage developers
 # See the AUTHORS file at the top-level directory of this distribution
 # License: GNU Affero General Public License version 3, or any later version
 # See top-level LICENSE file for more information
@@ -107,17 +107,14 @@ if swh_web_config["serve_assets"]:
 ROOT_URLCONF = "swh.web.urls"
 
 SWH_APP_TEMPLATES = [os.path.join(PROJECT_DIR, "../templates")]
-# Add templates directory from each SWH Django application
-for app in SWH_BASE_DJANGO_APPS + swh_web_config["swh_extra_django_apps"]:
-    try:
-        app_spec = find_spec(app)
-        assert app_spec is not None, f"Django application {app} not found !"
-        assert app_spec.origin is not None
-        SWH_APP_TEMPLATES.append(
-            os.path.join(os.path.dirname(app_spec.origin), "templates")
-        )
-    except ModuleNotFoundError:
-        assert False, f"Django application {app} not found !"
+# Add templates directory from each SWH Django application as even
+# if an app is not enabled, others might need some of its templates
+apps_dir = os.path.join(os.path.dirname(__file__), "../")
+_, apps, _ = next(os.walk(apps_dir))  # type: ignore[assignment]
+for app in apps:
+    app_templates_dir = os.path.join(apps_dir, app, "templates")
+    if os.path.exists(app_templates_dir):
+        SWH_APP_TEMPLATES.append(app_templates_dir)
 
 # for mirror version of swh-web, we need access to the Save Code Now templates
 # even if the django application is not enabled
diff --git a/swh/web/vault/templates/includes/vault-create-tasks.html b/swh/web/vault/templates/includes/vault-create-tasks.html
index 5fc56163b..25f8bb505 100644
--- a/swh/web/vault/templates/includes/vault-create-tasks.html
+++ b/swh/web/vault/templates/includes/vault-create-tasks.html
@@ -1,5 +1,5 @@
 {% comment %}
-Copyright (C) 2017-2024  The Software Heritage developers
+Copyright (C) 2017-2025  The Software Heritage developers
 See the AUTHORS file at the top-level directory of this distribution
 License: GNU Affero General Public License version 3, or any later version
 See top-level LICENSE file for more information
@@ -238,4 +238,15 @@ See top-level LICENSE file for more information
     </div>
   </div>
   {% include "./vault-common.html" %}
+{% elif vault_cooking.content_context %}
+  {% comment %}
+    Vault cannot cook a content object, in that case offer a direct file download link instead.
+  {% endcomment %}
+
+  <a href="{{ vault_cooking.content_download_url | safe }}"
+     class="btn btn-secondary btn-sm"
+     role="button">
+    <i class="mdi mdi-download mdi-fw" aria-hidden="true"></i>
+    Download
+  </a>
 {% endif %}
-- 
GitLab