Skip to content

Commit fd78a78

Browse files
authored
Project resource codebase tree view (#1704)
Signed-off-by: Aayush Kumar <aayush214.kumar@gmail.com>
1 parent ba53d70 commit fd78a78

File tree

9 files changed

+609
-2
lines changed

9 files changed

+609
-2
lines changed

scanpipe/models.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from django.db import transaction
4747
from django.db.models import Case
4848
from django.db.models import Count
49+
from django.db.models import Exists
4950
from django.db.models import IntegerField
5051
from django.db.models import OuterRef
5152
from django.db.models import Prefetch
@@ -2432,6 +2433,17 @@ def macho_binaries(self):
24322433
def executable_binaries(self):
24332434
return self.union(self.win_exes(), self.macho_binaries(), self.elfs())
24342435

2436+
def with_has_children(self):
2437+
"""
2438+
Annotate the QuerySet with has_children field based on whether
2439+
each resource has any children (subdirectories/files).
2440+
"""
2441+
children_qs = CodebaseResource.objects.filter(
2442+
parent_path=OuterRef("path"),
2443+
)
2444+
2445+
return self.annotate(has_children=Exists(children_qs))
2446+
24352447

24362448
class ScanFieldsModelMixin(models.Model):
24372449
"""Fields returned by the ScanCode-toolkit scans."""
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<ul>
2+
{% for node in children %}
3+
<li class="mb-1">
4+
{% if node.is_dir %}
5+
<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 %}>
6+
<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>
7+
<i class="fas fa-chevron-right"></i>
8+
</span>
9+
<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">
10+
<span class="icon is-small mr-1">
11+
<i class="fas fa-folder"></i>
12+
</span>
13+
<span>{{ node.name }}</span>
14+
</span>
15+
</div>
16+
{% if node.has_children %}
17+
<div id="dir-{{ node.path|slugify }}" class="ml-4 is-hidden" data-loaded="false"></div>
18+
{% endif %}
19+
{% else %}
20+
<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">
21+
<span class="icon is-small mr-1">
22+
<i class="far fa-file"></i>
23+
</span>
24+
<span>{{ node.name }}</span>
25+
</div>
26+
{% endif %}
27+
</li>
28+
{% endfor %}
29+
</ul>

scanpipe/templates/scanpipe/panels/project_codebase.html

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
<nav id="codebase-navigation" class="panel is-dark">
2-
<p class="panel-heading">
3-
Codebase
2+
<p class="panel-heading is-flex is-justify-content-space-between is-align-items-center">
3+
<span>Codebase</span>
4+
<a href="{% url 'codebase_resource_tree' project.slug %}" class="ml-2 has-text-white has-text-decoration-none">
5+
<i class="fa-solid fa-folder-tree mr-1"></i>Tree view
6+
</a>
47
{% if current_dir and current_dir != "." %}
58
<span class="tag ml-2">
69
{% for dir_name, full_path in codebase_breadcrumbs.items %}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
{% load humanize %}
2+
3+
{% if resources %}
4+
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth">
5+
<thead class="is-sticky">
6+
<tr>
7+
<th>Path</th>
8+
<th>Status</th>
9+
<th>Type</th>
10+
<th>Size</th>
11+
<th>Name</th>
12+
<th>Extension</th>
13+
<th>Language</th>
14+
<th>MIME Type</th>
15+
<th>Tag</th>
16+
<th>License</th>
17+
<th>Alert</th>
18+
<th>Packages</th>
19+
</tr>
20+
</thead>
21+
<tbody>
22+
{% for resource in resources %}
23+
<tr>
24+
<td class="break-all" style="min-width: 200px;">
25+
{% if resource.is_dir %}
26+
<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>
27+
{% else %}
28+
<a href="{% url 'resource_detail' project.slug resource.path %}">{{ resource.path }}</a>
29+
{% endif %}
30+
</td>
31+
<td>
32+
{{ resource.status }}
33+
</td>
34+
<td>
35+
{{ resource.type }}
36+
</td>
37+
<td>
38+
{% if resource.is_file %}
39+
{{ resource.size|filesizeformat|default_if_none:"" }}
40+
{% endif %}
41+
</td>
42+
<td class="break-all" style="min-width: 100px;">
43+
{{ resource.name }}
44+
</td>
45+
<td>
46+
{{ resource.extension }}
47+
</td>
48+
<td class="break-all">
49+
{{ resource.programming_language }}
50+
</td>
51+
<td class="break-all">
52+
{{ resource.mime_type }}
53+
</td>
54+
<td>
55+
{{ resource.tag }}
56+
</td>
57+
<td>
58+
{{ resource.detected_license_expression }}
59+
</td>
60+
<td>
61+
{{ resource.compliance_alert }}
62+
</td>
63+
<td>
64+
{% if resource.discovered_packages.all %}
65+
{% for package in resource.discovered_packages.all|slice:":3" %}
66+
<a href="{% url 'project_packages' project.slug %}?purl={{ package.package_url }}">{{ package }}</a>{% if not forloop.last %}, {% endif %}
67+
{% endfor %}
68+
{% if resource.discovered_packages.all|length > 3 %}
69+
+{{ resource.discovered_packages.all|length|add:"-3" }} more
70+
{% endif %}
71+
{% endif %}
72+
</td>
73+
</tr>
74+
{% endfor %}
75+
</tbody>
76+
</table>
77+
78+
{% if is_paginated %}
79+
<nav class="pagination is-centered mt-4" role="navigation">
80+
{% if page_obj.has_previous %}
81+
<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>
82+
{% endif %}
83+
{% if page_obj.has_next %}
84+
<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>
85+
{% endif %}
86+
<ul class="pagination-list">
87+
<li><span class="pagination-ellipsis">Page {{ page_obj.number }} of {{ page_obj.paginator.num_pages }}</span></li>
88+
</ul>
89+
</nav>
90+
{% endif %}
91+
{% else %}
92+
<div class="has-text-centered p-6">
93+
<div class="icon is-large has-text-grey-light mb-3">
94+
<i class="fas fa-folder-open fa-3x"></i>
95+
</div>
96+
<p class="has-text-grey">
97+
{% if path %}
98+
No resources found in this directory.
99+
{% else %}
100+
Select a file or folder from the tree to view its contents.
101+
{% endif %}
102+
</p>
103+
</div>
104+
{% endif %}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
{% extends "scanpipe/base.html" %}
2+
{% load static humanize %}
3+
{% block title %}ScanCode.io: {{ project.name }} - Resource Tree{% endblock %}
4+
5+
{% block extrahead %}
6+
<style>
7+
.is-current {
8+
background: rgba(128,128,128,0.10);
9+
border-radius: 6px;
10+
}
11+
.chevron {
12+
transition: transform 0.2s ease;
13+
display: inline-block;
14+
}
15+
.chevron.rotated {
16+
transform: rotate(90deg);
17+
}
18+
19+
.resizable-container {
20+
display: flex;
21+
height: calc(100vh - 140px);
22+
margin: 0;
23+
}
24+
25+
.left-pane {
26+
min-width: 0;
27+
max-width: 100%;
28+
border-right: 1px solid #ccc;
29+
overflow-y: auto;
30+
overflow-x: hidden;
31+
flex-basis: 25%;
32+
transition: opacity 0.2s ease;
33+
}
34+
35+
.left-pane.collapsed {
36+
opacity: 0;
37+
pointer-events: none;
38+
}
39+
40+
.resizer {
41+
width: 5px;
42+
background: #ddd;
43+
cursor: col-resize;
44+
position: relative;
45+
flex-shrink: 0;
46+
}
47+
48+
.resizer:hover {
49+
background: #bbb;
50+
}
51+
52+
.resizer::before {
53+
content: '';
54+
position: absolute;
55+
top: 50%;
56+
left: 1px;
57+
transform: translateY(-50%);
58+
width: 3px;
59+
height: 30px;
60+
background: #999;
61+
border-radius: 2px;
62+
}
63+
64+
.right-pane {
65+
flex: 1;
66+
overflow-y: auto;
67+
overflow-x: hidden;
68+
min-width: 0;
69+
transition: opacity 0.2s ease;
70+
}
71+
72+
.right-pane.collapsed {
73+
opacity: 0;
74+
pointer-events: none;
75+
}
76+
</style>
77+
{% endblock %}
78+
79+
{% block content %}
80+
<div id="content-header" class="container is-max-widescreen mb-3">
81+
{% include 'scanpipe/includes/navbar_header.html' %}
82+
<section class="mx-5">
83+
<div class="is-flex is-justify-content-space-between">
84+
{% include 'scanpipe/includes/breadcrumb.html' with linked_project=True current="Resource Tree" %}
85+
</div>
86+
</section>
87+
</div>
88+
89+
<div class="resizable-container">
90+
<div class="left-pane" id="left-pane">
91+
<div id="resource-tree" class="p-4">
92+
{% include "scanpipe/panels/codebase_tree_panel.html" with children=children path=path %}
93+
</div>
94+
</div>
95+
<div class="resizer" id="resizer"></div>
96+
<div class="right-pane" id="right-pane">
97+
<div class="p-4">
98+
{% include "scanpipe/panels/resource_table_panel.html" %}
99+
</div>
100+
</div>
101+
</div>
102+
{% endblock %}
103+
104+
{% block scripts %}
105+
<script>
106+
// Tree functionality
107+
document.addEventListener("click", async function (e) {
108+
const chevron = e.target.closest("[data-chevron]");
109+
if (chevron) {
110+
const folderNode = chevron.closest("[data-folder]");
111+
const targetId = folderNode.dataset.target;
112+
const url = folderNode.dataset.url;
113+
const target = document.getElementById("dir-" + targetId);
114+
115+
if (target.dataset.loaded === "true") {
116+
target.classList.toggle("is-hidden");
117+
} else {
118+
target.classList.remove("is-hidden");
119+
const response = await fetch(url + "&tree_panel=true");
120+
target.innerHTML = await response.text();
121+
target.dataset.loaded = "true";
122+
htmx.process(target);
123+
}
124+
125+
chevron.classList.toggle("rotated");
126+
e.stopPropagation();
127+
return;
128+
}
129+
130+
const folderMeta = e.target.closest(".folder-meta");
131+
if (folderMeta) {
132+
const folderNode = folderMeta.closest("[data-folder]");
133+
if (folderNode && folderNode.dataset.target) {
134+
document.querySelectorAll('.tree-node.is-current, .is-file.is-current').forEach(el => el.classList.remove('is-current'));
135+
folderNode.classList.add('is-current');
136+
const chevron = folderNode.querySelector("[data-chevron]");
137+
const target = document.getElementById("dir-" + folderNode.dataset.target);
138+
139+
if (target.classList.contains("is-hidden")) {
140+
target.classList.remove("is-hidden");
141+
chevron.classList.add("rotated");
142+
if (target.dataset.loaded !== "true") {
143+
const response = await fetch(folderNode.dataset.url + "&tree_panel=true");
144+
target.innerHTML = await response.text();
145+
target.dataset.loaded = "true";
146+
htmx.process(target);
147+
}
148+
}
149+
}
150+
}
151+
152+
const fileNode = e.target.closest(".is-file[data-file]");
153+
if (fileNode) {
154+
document.querySelectorAll('.tree-node.is-current, .is-file.is-current').forEach(el => el.classList.remove('is-current'));
155+
fileNode.classList.add('is-current');
156+
}
157+
158+
const expandLink = e.target.closest(".expand-in-tree");
159+
if (expandLink) {
160+
e.preventDefault();
161+
const path = expandLink.getAttribute("data-path");
162+
const leftPane = document.getElementById("left-pane");
163+
if (!leftPane) return;
164+
let node = leftPane.querySelector(`[data-folder][data-path="${path}"], .is-file[data-file][data-path="${path}"]`);
165+
if (node) {
166+
document.querySelectorAll('.tree-node.is-current, .is-file.is-current').forEach(el => el.classList.remove('is-current'));
167+
node.classList.add('is-current');
168+
const chevron = node.querySelector("[data-chevron]");
169+
if (chevron && !chevron.classList.contains("rotated")) chevron.click();
170+
node.scrollIntoView({behavior: "smooth", block: "center"});
171+
}
172+
}
173+
});
174+
175+
document.addEventListener("DOMContentLoaded", function() {
176+
const resizer = document.getElementById('resizer');
177+
const leftPane = document.getElementById('left-pane');
178+
const rightPane = document.getElementById('right-pane');
179+
let isResizing = false;
180+
181+
resizer.addEventListener('mousedown', function(e) {
182+
isResizing = true;
183+
document.body.style.cursor = 'col-resize';
184+
document.body.style.userSelect = 'none';
185+
e.preventDefault();
186+
});
187+
188+
document.addEventListener('mousemove', function(e) {
189+
if (!isResizing) return;
190+
191+
const container = document.querySelector('.resizable-container');
192+
const containerRect = container.getBoundingClientRect();
193+
let newLeftWidth = e.clientX - containerRect.left;
194+
const containerWidth = containerRect.width;
195+
const resizerWidth = 5;
196+
const minLeftWidth = 200;
197+
if (newLeftWidth < minLeftWidth) newLeftWidth = minLeftWidth;
198+
if (newLeftWidth > containerWidth - resizerWidth) newLeftWidth = containerWidth - resizerWidth;
199+
200+
const leftPercent = (newLeftWidth / containerWidth) * 100;
201+
const rightPercent = ((containerWidth - newLeftWidth - resizerWidth) / containerWidth) * 100;
202+
203+
leftPane.style.flexBasis = leftPercent + '%';
204+
rightPane.style.flexBasis = rightPercent + '%';
205+
});
206+
207+
document.addEventListener('mouseup', function() {
208+
if (isResizing) {
209+
isResizing = false;
210+
document.body.style.cursor = '';
211+
document.body.style.userSelect = '';
212+
}
213+
});
214+
});
215+
</script>
216+
{% endblock %}

0 commit comments

Comments
 (0)