Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4d0b4d5
add left-pane file tree view and related templates
aayushkdev Jun 27, 2025
8e5dd0c
temporarily include parent_path field from previous pr for tests
aayushkdev Jun 27, 2025
606c2b6
Some formatting changes
aayushkdev Jul 3, 2025
8a5b576
bump migration up to resolve failing tests
aayushkdev Jul 4, 2025
69ad5ac
remove conflicting migration
aayushkdev Jul 4, 2025
2c1dd0f
implement suggested changes
aayushkdev Jul 7, 2025
28f7e05
fix code format
aayushkdev Jul 7, 2025
79e2743
use instead of None to follow code format
aayushkdev Jul 12, 2025
054d87f
update save function so tests pass
aayushkdev Jul 12, 2025
adbe5b4
add suggested improvements
aayushkdev Jul 15, 2025
3fec6ab
refactor tests
aayushkdev Jul 21, 2025
da1596e
fix code format
aayushkdev Jul 21, 2025
8640fef
add the right resource table panel
aayushkdev Jul 26, 2025
7c133bd
add tests
aayushkdev Jul 28, 2025
633e5df
small ui change in table
aayushkdev Jul 28, 2025
fe0f33b
add resizable panel
aayushkdev Jul 28, 2025
d7dfce4
Merge remote-tracking branch 'origin/main' into 1682-left-pane-file-tree
aayushkdev Aug 5, 2025
e665feb
Revert filtering support and add reviewed changes
aayushkdev Aug 19, 2025
08727c0
fix icon alignment
aayushkdev Aug 24, 2025
23b0c0a
update icon+subpath to open caret path on left side
aayushkdev Sep 2, 2025
da5be03
update 'path' link to open the corresponding directory in the right pane
aayushkdev Sep 2, 2025
1e39667
Set a minimum width of about 200px for the left panel
aayushkdev Sep 3, 2025
3fa79ec
add a css style for currently selected item
aayushkdev Sep 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions scanpipe/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
from django.db import transaction
from django.db.models import Case
from django.db.models import Count
from django.db.models import Exists
from django.db.models import IntegerField
from django.db.models import OuterRef
from django.db.models import Prefetch
Expand Down Expand Up @@ -2425,6 +2426,17 @@ def macho_binaries(self):
def executable_binaries(self):
return self.union(self.win_exes(), self.macho_binaries(), self.elfs())

def with_has_children(self):
"""
Annotate the QuerySet with has_children field based on whether
each resource has any children (subdirectories/files).
"""
children_qs = CodebaseResource.objects.filter(
parent_path=OuterRef("path"),
)

return self.annotate(has_children=Exists(children_qs))


class ScanFieldsModelMixin(models.Model):
"""Fields returned by the ScanCode-toolkit scans."""
Expand Down
29 changes: 29 additions & 0 deletions scanpipe/templates/scanpipe/panels/codebase_tree_panel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<ul>
{% for node in children %}
<li class="mb-1">
{% if node.is_dir %}
<div class="tree-node is-flex is-align-items-center has-text-weight-semibold px-1" data-folder data-path="{{ node.path }}"{% if node.has_children %} data-target="{{ node.path|slugify }}" data-url="{% url 'codebase_resource_tree' slug=project.slug %}?path={{ node.path }}"{% endif %}>
<span class="icon is-small chevron mr-1{% if not node.has_children %} is-invisible{% endif %} is-clickable is-flex is-align-items-center" data-chevron>
<i class="fas fa-chevron-right"></i>
</span>
<span class="is-flex is-align-items-center folder-meta is-clickable" data-folder-click hx-get="{% url 'codebase_resource_table' project.slug %}?path={{ node.path }}" hx-target="#right-pane">
<span class="icon is-small mr-1">
<i class="fas fa-folder"></i>
</span>
<span>{{ node.name }}</span>
</span>
</div>
{% if node.has_children %}
<div id="dir-{{ node.path|slugify }}" class="ml-4 is-hidden" data-loaded="false"></div>
{% endif %}
{% else %}
<div class="is-flex is-align-items-center ml-5 is-clickable is-file" data-file data-path="{{ node.path }}" hx-get="{% url 'codebase_resource_table' project.slug %}?path={{ node.path }}" hx-target="#right-pane">
<span class="icon is-small mr-1">
<i class="far fa-file"></i>
</span>
<span>{{ node.name }}</span>
</div>
{% endif %}
</li>
{% endfor %}
</ul>
7 changes: 5 additions & 2 deletions scanpipe/templates/scanpipe/panels/project_codebase.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
<nav id="codebase-navigation" class="panel is-dark">
<p class="panel-heading">
Codebase
<p class="panel-heading is-flex is-justify-content-space-between is-align-items-center">
<span>Codebase</span>
<a href="{% url 'codebase_resource_tree' project.slug %}" class="ml-2 has-text-white has-text-decoration-none">
<i class="fa-solid fa-folder-tree mr-1"></i>Tree view
</a>
{% if current_dir and current_dir != "." %}
<span class="tag ml-2">
{% for dir_name, full_path in codebase_breadcrumbs.items %}
Expand Down
104 changes: 104 additions & 0 deletions scanpipe/templates/scanpipe/panels/resource_table_panel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
{% load humanize %}

{% if resources %}
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
<thead class="is-sticky">
<tr>
<th>Path</th>
<th>Status</th>
<th>Type</th>
<th>Size</th>
<th>Name</th>
<th>Extension</th>
<th>Language</th>
<th>MIME Type</th>
<th>Tag</th>
<th>License</th>
<th>Alert</th>
<th>Packages</th>
</tr>
</thead>
<tbody>
{% for resource in resources %}
<tr>
<td class="break-all" style="min-width: 200px;">
{% if resource.is_dir %}
<a href="#" class="expand-in-tree" data-path="{{ resource.path }}" hx-get="{% url 'codebase_resource_table' project.slug %}?path={{ resource.path }}" hx-target="#right-pane">{{ resource.path }}</a>
{% else %}
<a href="{% url 'resource_detail' project.slug resource.path %}">{{ resource.path }}</a>
{% endif %}
</td>
<td>
{{ resource.status }}
</td>
<td>
{{ resource.type }}
</td>
<td>
{% if resource.is_file %}
{{ resource.size|filesizeformat|default_if_none:"" }}
{% endif %}
</td>
<td class="break-all" style="min-width: 100px;">
{{ resource.name }}
</td>
<td>
{{ resource.extension }}
</td>
<td class="break-all">
{{ resource.programming_language }}
</td>
<td class="break-all">
{{ resource.mime_type }}
</td>
<td>
{{ resource.tag }}
</td>
<td>
{{ resource.detected_license_expression }}
</td>
<td>
{{ resource.compliance_alert }}
</td>
<td>
{% if resource.discovered_packages.all %}
{% for package in resource.discovered_packages.all|slice:":3" %}
<a href="{% url 'project_packages' project.slug %}?purl={{ package.package_url }}">{{ package }}</a>{% if not forloop.last %}, {% endif %}
{% endfor %}
{% if resource.discovered_packages.all|length > 3 %}
+{{ resource.discovered_packages.all|length|add:"-3" }} more
{% endif %}
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>

{% if is_paginated %}
<nav class="pagination is-centered mt-4" role="navigation">
{% if page_obj.has_previous %}
<a class="pagination-previous" hx-get="{% url 'codebase_resource_table' project.slug %}?path={{ path }}&page={{ page_obj.previous_page_number }}" hx-target="#right-pane">Previous</a>
{% endif %}
{% if page_obj.has_next %}
<a class="pagination-next" hx-get="{% url 'codebase_resource_table' project.slug %}?path={{ path }}&page={{ page_obj.next_page_number }}" hx-target="#right-pane">Next page</a>
{% endif %}
<ul class="pagination-list">
<li><span class="pagination-ellipsis">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="has-text-centered p-6">
<div class="icon is-large has-text-grey-light mb-3">
<i class="fas fa-folder-open fa-3x"></i>
</div>
<p class="has-text-grey">
{% if path %}
No resources found in this directory.
{% else %}
Select a file or folder from the tree to view its contents.
{% endif %}
</p>
</div>
{% endif %}
216 changes: 216 additions & 0 deletions scanpipe/templates/scanpipe/resource_tree.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
{% extends "scanpipe/base.html" %}
{% load static humanize %}
{% block title %}ScanCode.io: {{ project.name }} - Resource Tree{% endblock %}

{% block extrahead %}
<style>
.is-current {
background: rgba(128,128,128,0.10);
border-radius: 6px;
}
.chevron {
transition: transform 0.2s ease;
display: inline-block;
}
.chevron.rotated {
transform: rotate(90deg);
}

.resizable-container {
display: flex;
height: calc(100vh - 140px);
margin: 0;
}

.left-pane {
min-width: 0;
max-width: 100%;
border-right: 1px solid #ccc;
overflow-y: auto;
overflow-x: hidden;
flex-basis: 25%;
transition: opacity 0.2s ease;
}

.left-pane.collapsed {
opacity: 0;
pointer-events: none;
}

.resizer {
width: 5px;
background: #ddd;
cursor: col-resize;
position: relative;
flex-shrink: 0;
}

.resizer:hover {
background: #bbb;
}

.resizer::before {
content: '';
position: absolute;
top: 50%;
left: 1px;
transform: translateY(-50%);
width: 3px;
height: 30px;
background: #999;
border-radius: 2px;
}

.right-pane {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
min-width: 0;
transition: opacity 0.2s ease;
}

.right-pane.collapsed {
opacity: 0;
pointer-events: none;
}
</style>
{% endblock %}

{% block content %}
<div id="content-header" class="container is-max-widescreen mb-3">
{% include 'scanpipe/includes/navbar_header.html' %}
<section class="mx-5">
<div class="is-flex is-justify-content-space-between">
{% include 'scanpipe/includes/breadcrumb.html' with linked_project=True current="Resource Tree" %}
</div>
</section>
</div>

<div class="resizable-container">
<div class="left-pane" id="left-pane">
<div id="resource-tree" class="p-4">
{% include "scanpipe/panels/codebase_tree_panel.html" with children=children path=path %}
</div>
</div>
<div class="resizer" id="resizer"></div>
<div class="right-pane" id="right-pane">
<div class="p-4">
{% include "scanpipe/panels/resource_table_panel.html" %}
</div>
</div>
</div>
{% endblock %}

{% block scripts %}
<script>
// Tree functionality
document.addEventListener("click", async function (e) {
const chevron = e.target.closest("[data-chevron]");
if (chevron) {
const folderNode = chevron.closest("[data-folder]");
const targetId = folderNode.dataset.target;
const url = folderNode.dataset.url;
const target = document.getElementById("dir-" + targetId);

if (target.dataset.loaded === "true") {
target.classList.toggle("is-hidden");
} else {
target.classList.remove("is-hidden");
const response = await fetch(url + "&tree_panel=true");
target.innerHTML = await response.text();
target.dataset.loaded = "true";
htmx.process(target);
}

chevron.classList.toggle("rotated");
e.stopPropagation();
return;
}

const folderMeta = e.target.closest(".folder-meta");
if (folderMeta) {
const folderNode = folderMeta.closest("[data-folder]");
if (folderNode && folderNode.dataset.target) {
document.querySelectorAll('.tree-node.is-current, .is-file.is-current').forEach(el => el.classList.remove('is-current'));
folderNode.classList.add('is-current');
const chevron = folderNode.querySelector("[data-chevron]");
const target = document.getElementById("dir-" + folderNode.dataset.target);

if (target.classList.contains("is-hidden")) {
target.classList.remove("is-hidden");
chevron.classList.add("rotated");
if (target.dataset.loaded !== "true") {
const response = await fetch(folderNode.dataset.url + "&tree_panel=true");
target.innerHTML = await response.text();
target.dataset.loaded = "true";
htmx.process(target);
}
}
}
}

const fileNode = e.target.closest(".is-file[data-file]");
if (fileNode) {
document.querySelectorAll('.tree-node.is-current, .is-file.is-current').forEach(el => el.classList.remove('is-current'));
fileNode.classList.add('is-current');
}

const expandLink = e.target.closest(".expand-in-tree");
if (expandLink) {
e.preventDefault();
const path = expandLink.getAttribute("data-path");
const leftPane = document.getElementById("left-pane");
if (!leftPane) return;
let node = leftPane.querySelector(`[data-folder][data-path="${path}"], .is-file[data-file][data-path="${path}"]`);
if (node) {
document.querySelectorAll('.tree-node.is-current, .is-file.is-current').forEach(el => el.classList.remove('is-current'));
node.classList.add('is-current');
const chevron = node.querySelector("[data-chevron]");
if (chevron && !chevron.classList.contains("rotated")) chevron.click();
node.scrollIntoView({behavior: "smooth", block: "center"});
}
}
});

document.addEventListener("DOMContentLoaded", function() {
const resizer = document.getElementById('resizer');
const leftPane = document.getElementById('left-pane');
const rightPane = document.getElementById('right-pane');
let isResizing = false;

resizer.addEventListener('mousedown', function(e) {
isResizing = true;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
e.preventDefault();
});

document.addEventListener('mousemove', function(e) {
if (!isResizing) return;

const container = document.querySelector('.resizable-container');
const containerRect = container.getBoundingClientRect();
let newLeftWidth = e.clientX - containerRect.left;
const containerWidth = containerRect.width;
const resizerWidth = 5;
const minLeftWidth = 200;
if (newLeftWidth < minLeftWidth) newLeftWidth = minLeftWidth;
if (newLeftWidth > containerWidth - resizerWidth) newLeftWidth = containerWidth - resizerWidth;

const leftPercent = (newLeftWidth / containerWidth) * 100;
const rightPercent = ((containerWidth - newLeftWidth - resizerWidth) / containerWidth) * 100;

leftPane.style.flexBasis = leftPercent + '%';
rightPane.style.flexBasis = rightPercent + '%';
});

document.addEventListener('mouseup', function() {
if (isResizing) {
isResizing = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
}
});
});
</script>
{% endblock %}
Loading