-
Notifications
You must be signed in to change notification settings - Fork 109
add left-pane file tree view and related templates #1704
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
17d0fc3
a9c5118
3ebb81e
44015e7
e63c0f9
3d1c4b7
48fcce6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,24 @@ | ||
# Generated by Django 5.1.9 on 2025-06-19 21:19 | ||
|
||
from django.db import migrations, models | ||
|
||
|
||
class Migration(migrations.Migration): | ||
|
||
dependencies = [ | ||
('scanpipe', '0073_add_sha1_git_checksum'), | ||
] | ||
|
||
operations = [ | ||
migrations.AddField( | ||
model_name='codebaseresource', | ||
name='parent_path', | ||
field=models.CharField(blank=True, help_text="The path of the resource's parent directory. Set to None for top-level (root) resources. Used to efficiently retrieve a directory's contents.", max_length=2000, null=True), | ||
), | ||
migrations.AddIndex( | ||
model_name='codebaseresource', | ||
index=models.Index(fields=['project', 'parent_path'], name='scanpipe_co_project_008448_idx'), | ||
), | ||
] | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 | ||
|
@@ -229,7 +230,7 @@ def delete(self, *args, **kwargs): | |
Note that projects with queued or running pipeline runs cannot be deleted. | ||
See the `_raise_if_run_in_progress` method. | ||
The following if statements should not be triggered unless the `.delete()` | ||
method is directly call from an instance of this class. | ||
method is directly call from a instance of this class. | ||
""" | ||
with suppress(redis.exceptions.ConnectionError, AttributeError): | ||
if self.status == self.Status.RUNNING: | ||
|
@@ -2383,6 +2384,18 @@ def macho_binaries(self): | |
def executable_binaries(self): | ||
return self.union(self.win_exes(), self.macho_binaries(), self.elfs()) | ||
|
||
def with_children(self, project): | ||
""" | ||
Annotate the QuerySet with has_children field based on whether | ||
each resource has any children (subdirectories/files). | ||
""" | ||
subdirs = CodebaseResource.objects.filter( | ||
project=project, | ||
parent_path=OuterRef("path"), | ||
) | ||
|
||
return self.annotate(has_children=Exists(subdirs)) | ||
|
||
|
||
class ScanFieldsModelMixin(models.Model): | ||
"""Fields returned by the ScanCode-toolkit scans.""" | ||
|
@@ -2688,6 +2701,17 @@ class CodebaseResource( | |
'Eg.: "/usr/bin/bash" for a path of "tarball-extract/rootfs/usr/bin/bash"' | ||
), | ||
) | ||
|
||
parent_path = models.CharField( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. After migrating, existing records will have null Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||
max_length=2000, | ||
blank=True, | ||
help_text=_( | ||
"The path of the resource's parent directory. " | ||
"Set to None for top-level (root) resources. " | ||
"Used to efficiently retrieve a directory's contents." | ||
), | ||
) | ||
|
||
status = models.CharField( | ||
blank=True, | ||
max_length=50, | ||
|
@@ -2781,6 +2805,7 @@ class Meta: | |
models.Index(fields=["compliance_alert"]), | ||
models.Index(fields=["is_binary"]), | ||
models.Index(fields=["is_text"]), | ||
models.Index(fields=["project", "parent_path"]), | ||
] | ||
constraints = [ | ||
models.UniqueConstraint( | ||
|
@@ -2793,6 +2818,11 @@ class Meta: | |
def __str__(self): | ||
return self.path | ||
|
||
def save(self, *args, **kwargs): | ||
if self.path and not self.parent_path: | ||
self.parent_path = self.parent_directory() | ||
super().save(*args, **kwargs) | ||
|
||
def get_absolute_url(self): | ||
return reverse("resource_detail", args=[self.project.slug, self.path]) | ||
|
||
|
@@ -2863,7 +2893,8 @@ def get_path_segments_with_subpath(self): | |
|
||
def parent_directory(self): | ||
"""Return the parent path for this CodebaseResource or None.""" | ||
return parent_directory(self.path, with_trail=False) | ||
parent_path = parent_directory(str(self.path), with_trail=False) | ||
return parent_path or None | ||
|
||
def has_parent(self): | ||
""" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
<ul class="pl-2"> | ||
{% 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 is-clickable px-1" data-folder{% 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 %}" data-chevron> | ||
<i class="fas fa-chevron-right"></i> | ||
</span> | ||
<span class="is-flex is-align-items-center folder-meta"> | ||
<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"> | ||
<span class="icon is-small mr-1"> | ||
<i class="far fa-file"></i> | ||
</span> | ||
<span>{{ node.name }}</span> | ||
</div> | ||
{% endif %} | ||
</li> | ||
{% endfor %} | ||
</ul> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,67 @@ | ||
{% extends "scanpipe/base.html" %} | ||
{% load static humanize %} | ||
{% block title %}ScanCode.io: {{ project.name }} - Resource Tree{% endblock %} | ||
|
||
{% block extrahead %} | ||
<style> | ||
.chevron { | ||
transition: transform 0.2s ease; | ||
display: inline-block; | ||
} | ||
.chevron.rotated { | ||
transform: rotate(90deg); | ||
} | ||
</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="columns is-gapless is-mobile" style="height: 80vh; margin: 0;"> | ||
<div class="column is-one-third p-4" style="border-right: 1px solid #ccc; overflow-y: auto;"> | ||
<div id="resource-tree"> | ||
{% include "scanpipe/panels/codebase_tree_panel.html" with children=children path=path %} | ||
</div> | ||
</div> | ||
<div class="column p-4" style="overflow-y: auto;"> | ||
<div id="right-pane"> | ||
</div> | ||
</div> | ||
</div> | ||
{% endblock %} | ||
|
||
{% block scripts %} | ||
<script> | ||
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 icon = chevron.querySelector("i"); | ||
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=true"); | ||
target.innerHTML = await response.text(); | ||
target.dataset.loaded = "true"; | ||
htmx.process(target); | ||
} | ||
|
||
chevron.classList.toggle("rotated"); | ||
e.stopPropagation(); | ||
return; | ||
} | ||
}); | ||
</script> | ||
{% endblock %} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -2567,3 +2567,30 @@ def get_node(self, package): | |||||
if children: | ||||||
node["children"] = children | ||||||
return node | ||||||
|
||||||
|
||||||
class CodebaseResourceTreeView(ConditionalLoginRequired, generic.DetailView): | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [nitpick] This view overrides
Suggested change
Copilot uses AI. Check for mistakes. Positive FeedbackNegative Feedback |
||||||
template_name = "scanpipe/resource_tree.html" | ||||||
|
||||||
def get(self, request, *args, **kwargs): | ||||||
slug = self.kwargs.get("slug") | ||||||
project = get_object_or_404(Project, slug=slug) | ||||||
path = request.GET.get("path", None) | ||||||
|
||||||
base_qs = ( | ||||||
CodebaseResource.objects.filter(project=project, parent_path=path) | ||||||
.only("path", "name", "type") | ||||||
.order_by("path") | ||||||
) | ||||||
|
||||||
children = base_qs.with_children(project) | ||||||
|
||||||
context = { | ||||||
"project": project, | ||||||
"path": path, | ||||||
"children": children, | ||||||
} | ||||||
|
||||||
if request.GET.get("tree") == "true": | ||||||
return render(request, "scanpipe/panels/codebase_tree_panel.html", context) | ||||||
return render(request, self.template_name, context) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Typo in docstring: change 'a instance' to 'an instance'.
Copilot uses AI. Check for mistakes.