From f886bda750c3c593594f7a2e47307253fb7fa9ca Mon Sep 17 00:00:00 2001 From: Aayush Kumar Date: Mon, 23 Mar 2026 23:34:34 +0530 Subject: [PATCH 1/3] add resource tree search Signed-off-by: Aayush Kumar --- scancodeio/static/js/resource_tree.js | 193 +++++++++++++++++- scancodeio/static/main.css | 87 +++++++- .../scanpipe/tree/resource_left_pane.html | 36 +++- .../tree/resource_search_results.html | 33 +++ scanpipe/urls.py | 5 + scanpipe/views.py | 29 +++ 6 files changed, 377 insertions(+), 6 deletions(-) create mode 100644 scanpipe/templates/scanpipe/tree/resource_search_results.html diff --git a/scancodeio/static/js/resource_tree.js b/scancodeio/static/js/resource_tree.js index 886fe65117..50b7fb882f 100644 --- a/scancodeio/static/js/resource_tree.js +++ b/scancodeio/static/js/resource_tree.js @@ -67,6 +67,196 @@ } } + function isTypingElement(element) { + if (!element) return false; + const tagName = element.tagName ? element.tagName.toLowerCase() : ""; + return ( + ["input", "textarea", "select"].includes(tagName) || + element.isContentEditable + ); + } + + function initSearchInteractions() { + const searchContainer = document.getElementById('resource-search-container'); + const searchInput = document.getElementById('file-search-input'); + const searchResults = document.getElementById('search-results'); + const clearSearchBtn = document.getElementById('clear-search'); + if (!searchContainer || !searchInput || !searchResults || !clearSearchBtn) return; + + let activeIndex = -1; + + if (searchResults.parentNode !== document.body) { + searchResults.classList.add('search-dropdown-portal'); + document.body.appendChild(searchResults); + } + + function getResultItems() { + return Array.from(searchResults.querySelectorAll('.search-result-item')); + } + + function updateDropdownPosition() { + if (searchResults.classList.contains('is-hidden')) return; + + const rect = searchContainer.getBoundingClientRect(); + const viewportPadding = 8; + const baseWidth = Math.max(rect.width + 300, window.innerWidth * 0.5); + const width = Math.min(baseWidth, window.innerWidth - viewportPadding * 2); + const left = Math.max( + viewportPadding, + Math.min(rect.left, window.innerWidth - width - viewportPadding) + ); + const dropdownTop = rect.bottom + 4; + const availableHeight = window.innerHeight - dropdownTop - viewportPadding; + const maxHeight = Math.max(180, Math.min(window.innerHeight * 0.62, availableHeight)); + + searchResults.style.left = `${left}px`; + searchResults.style.top = `${dropdownTop}px`; + searchResults.style.width = `${width}px`; + searchResults.style.maxHeight = `${maxHeight}px`; + } + + function showDropdown() { + if (searchInput.value.trim()) { + searchResults.classList.remove('is-hidden'); + updateDropdownPosition(); + } + } + + function hideDropdown() { + searchResults.classList.add('is-hidden'); + setActiveItem(-1); + } + + function updateClearButtonVisibility() { + clearSearchBtn.classList.toggle('is-hidden', !searchInput.value.trim()); + } + + function setActiveItem(nextIndex) { + const items = getResultItems(); + if (!items.length) { + activeIndex = -1; + return; + } + + items.forEach(item => item.classList.remove('is-active')); + if (nextIndex < 0) { + activeIndex = -1; + return; + } + + activeIndex = ((nextIndex % items.length) + items.length) % items.length; + const activeItem = items[activeIndex]; + activeItem.classList.add('is-active'); + activeItem.scrollIntoView({ block: 'nearest' }); + } + + function triggerActiveItem() { + const items = getResultItems(); + if (!items.length) return; + + const index = activeIndex >= 0 ? activeIndex : 0; + const activeItem = items[index]; + activeItem.click(); + } + + function clearSearch() { + searchInput.value = ''; + updateClearButtonVisibility(); + hideDropdown(); + searchResults.innerHTML = ''; + searchInput.focus(); + } + + clearSearchBtn.addEventListener('click', clearSearch); + + searchInput.addEventListener('input', function() { + activeIndex = -1; + updateClearButtonVisibility(); + if (!searchInput.value.trim()) { + hideDropdown(); + } else { + updateDropdownPosition(); + } + }); + + searchInput.addEventListener('focus', showDropdown); + + window.addEventListener('resize', updateDropdownPosition); + window.addEventListener('scroll', updateDropdownPosition, true); + + searchInput.addEventListener('keydown', function(event) { + if (event.key === 'Escape') { + hideDropdown(); + searchInput.blur(); + return; + } + + if (event.key === 'ArrowDown') { + event.preventDefault(); + const items = getResultItems(); + if (!items.length) return; + showDropdown(); + const nextIndex = activeIndex < 0 ? 0 : activeIndex + 1; + setActiveItem(nextIndex); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + const items = getResultItems(); + if (!items.length) return; + showDropdown(); + const nextIndex = activeIndex < 0 ? items.length - 1 : activeIndex - 1; + setActiveItem(nextIndex); + return; + } + + if (event.key === 'Enter') { + const items = getResultItems(); + if (!items.length || searchResults.classList.contains('is-hidden')) return; + event.preventDefault(); + triggerActiveItem(); + } + }); + + document.addEventListener('click', function(event) { + const resultItem = event.target.closest('.search-result-item'); + if (resultItem && searchResults.contains(resultItem)) { + hideDropdown(); + searchInput.blur(); + expandToPath(resultItem.dataset.path); + return; + } + + if (!searchContainer.contains(event.target) && !searchResults.contains(event.target)) { + hideDropdown(); + } + }); + + document.addEventListener('keydown', function(event) { + const target = event.target; + if (event.key.toLowerCase() === 't' && !event.metaKey && !event.ctrlKey && !event.altKey) { + if (isTypingElement(target)) return; + event.preventDefault(); + searchInput.focus(); + } + }); + + document.body.addEventListener('htmx:afterSettle', function(event) { + if (event.target !== searchResults) return; + activeIndex = -1; + updateClearButtonVisibility(); + if (searchInput.value.trim()) { + showDropdown(); + updateDropdownPosition(); + } else { + hideDropdown(); + } + }); + + updateClearButtonVisibility(); + } + document.addEventListener("click", async e => { const node = e.target.closest("[data-folder], .is-file[data-file], .expand-in-tree, [data-chevron]"); if (!node) return; @@ -142,5 +332,6 @@ }); }); + initSearchInteractions(); }); -})(); \ No newline at end of file +})(); diff --git a/scancodeio/static/main.css b/scancodeio/static/main.css index a51269fcbe..a40d2f6810 100644 --- a/scancodeio/static/main.css +++ b/scancodeio/static/main.css @@ -551,13 +551,96 @@ body.full-screen #resource-viewer .message-header { min-width: 0; max-width: 100%; border-right: 1px solid #ccc; - overflow-y: auto; - overflow-x: hidden; + overflow: visible; + display: flex; + flex-direction: column; + min-height: 0; flex-basis: 25%; transition: opacity 0.2s ease; + position: relative; + z-index: 3000; +} +#resource-tree-container .resource-tree-scroll { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; text-overflow: ellipsis; white-space: nowrap; } +#resource-tree-container .search-container { + position: sticky; + top: 0; + z-index: 20; + background: var(--bulma-scheme-main); + padding: .25rem 0 .5rem; +} +#resource-tree-container .search-container .field { + margin-bottom: 0; +} +#resource-tree-container .search-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 1000; +} +#resource-tree-container .search-dropdown, +.search-dropdown.search-dropdown-portal { + border: 1px solid var(--bulma-border); + border-radius: 12px; + background: var(--bulma-scheme-main); + box-shadow: var(--bulma-shadow); + min-height: 0; + max-height: 62vh; + overflow-y: auto; + overflow-x: hidden; +} +.search-dropdown.search-dropdown-portal { + position: fixed; + top: 0; + left: 0; + right: auto; + z-index: 11000; +} +#resource-tree-container .search-result-item, +.search-dropdown.search-dropdown-portal .search-result-item { + display: flex; + align-items: center; + gap: .5rem; + color: var(--bulma-text); + font-size: 14px; + line-height: 20px; + font-weight: 400; + white-space: nowrap; +} +#resource-tree-container .search-results, +.search-dropdown.search-dropdown-portal .search-results { + margin: 0; +} +#resource-tree-container .search-result-item:hover, +#resource-tree-container .search-result-item.is-active, +.search-dropdown.search-dropdown-portal .search-result-item:hover, +.search-dropdown.search-dropdown-portal .search-result-item.is-active { + background-color: var(--bulma-background-hover); +} +#resource-tree-container .search-result-item .icon, +.search-dropdown.search-dropdown-portal .search-result-item .icon { + color: inherit; + width: 16px; + min-width: 16px; + height: 16px; +} +#resource-tree-container .search-result-item .icon i, +.search-dropdown.search-dropdown-portal .search-result-item .icon i { + font-size: 13px; +} +#resource-tree-container .search-result-path, +.search-dropdown.search-dropdown-portal .search-result-path { + overflow: hidden; + text-overflow: ellipsis; + font-size: 14px; +} #resource-tree-container .left-pane.collapsed { opacity: 0; pointer-events: none; diff --git a/scanpipe/templates/scanpipe/tree/resource_left_pane.html b/scanpipe/templates/scanpipe/tree/resource_left_pane.html index 62d1bc70b5..7bb42ec1a6 100644 --- a/scanpipe/templates/scanpipe/tree/resource_left_pane.html +++ b/scanpipe/templates/scanpipe/tree/resource_left_pane.html @@ -1,4 +1,34 @@ {% include "scanpipe/tree/resource_left_pane_header.html" only %} -
- {% include "scanpipe/tree/resource_left_pane_tree.html" with children=children path=path %} -
\ No newline at end of file +
+
+
+ + + + +
+
+ +
+
+ +
+ +
+ {% include "scanpipe/tree/resource_left_pane_tree.html" with children=children %} +
diff --git a/scanpipe/templates/scanpipe/tree/resource_search_results.html b/scanpipe/templates/scanpipe/tree/resource_search_results.html new file mode 100644 index 0000000000..19cd4a6d7f --- /dev/null +++ b/scanpipe/templates/scanpipe/tree/resource_search_results.html @@ -0,0 +1,33 @@ +{% if search_results %} +
+ {% for resource in search_results %} + + + {% if resource.is_dir %} + + {% else %} + + {% endif %} + + {{ resource.path }} + + {% endfor %} +
+{% elif query %} +
+
+ +
+

No files found matching "{{ query }}"

+
+{% endif %} diff --git a/scanpipe/urls.py b/scanpipe/urls.py index b595cafd13..476f257713 100644 --- a/scanpipe/urls.py +++ b/scanpipe/urls.py @@ -135,6 +135,11 @@ views.ProjectCodebasePanelView.as_view(), name="project_codebase", ), + path( + "project//resource_tree/search/", + views.ProjectResourceSearchView.as_view(), + name="project_resource_tree_search", + ), path( "project//resource_tree//", views.ProjectResourceTreeView.as_view(), diff --git a/scanpipe/views.py b/scanpipe/views.py index e5fd2bca2b..c6ef480320 100644 --- a/scanpipe/views.py +++ b/scanpipe/views.py @@ -2846,6 +2846,35 @@ def get_context_data(self, **kwargs): return context +class ProjectResourceSearchView( + ConditionalLoginRequired, + ProjectRelatedViewMixin, + generic.ListView, +): + model = CodebaseResource + template_name = "scanpipe/tree/resource_search_results.html" + context_object_name = "search_results" + paginate_by = 30 + + def get_queryset(self): + search_query = self.request.GET.get("search", "").strip() + if not search_query: + return CodebaseResource.objects.none() + + return ( + super() + .get_queryset() + .filter(path__icontains=search_query) + .only("project_id", "path", "type") + .order_by("path") + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["query"] = self.request.GET.get("search", "").strip() + return context + + class GenerateAPIKeyView(ConditionalLoginRequired, BaseGenerateAPIKeyView): success_url = reverse_lazy("account_profile") success_message = ( From 7c48019ac83b31f036c64d6b1ca97f7d184aa503 Mon Sep 17 00:00:00 2001 From: Aayush Kumar Date: Tue, 24 Mar 2026 23:25:43 +0530 Subject: [PATCH 2/3] fix search bar css and add tests Signed-off-by: Aayush Kumar --- scancodeio/static/main.css | 14 ++++++++------ .../scanpipe/tree/resource_left_pane.html | 17 +++++++++-------- .../scanpipe/tree/resource_search_results.html | 2 +- scanpipe/tests/test_views.py | 15 +++++++++++++++ 4 files changed, 33 insertions(+), 15 deletions(-) diff --git a/scancodeio/static/main.css b/scancodeio/static/main.css index a40d2f6810..a0a6d6ddb4 100644 --- a/scancodeio/static/main.css +++ b/scancodeio/static/main.css @@ -575,8 +575,14 @@ body.full-screen #resource-viewer .message-header { background: var(--bulma-scheme-main); padding: .25rem 0 .5rem; } -#resource-tree-container .search-container .field { - margin-bottom: 0; +#resource-tree-container #clear-search { + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + cursor: pointer; + pointer-events: auto; } #resource-tree-container .search-dropdown { position: absolute; @@ -614,10 +620,6 @@ body.full-screen #resource-viewer .message-header { font-weight: 400; white-space: nowrap; } -#resource-tree-container .search-results, -.search-dropdown.search-dropdown-portal .search-results { - margin: 0; -} #resource-tree-container .search-result-item:hover, #resource-tree-container .search-result-item.is-active, .search-dropdown.search-dropdown-portal .search-result-item:hover, diff --git a/scanpipe/templates/scanpipe/tree/resource_left_pane.html b/scanpipe/templates/scanpipe/tree/resource_left_pane.html index 7bb42ec1a6..4fbdf74595 100644 --- a/scanpipe/templates/scanpipe/tree/resource_left_pane.html +++ b/scanpipe/templates/scanpipe/tree/resource_left_pane.html @@ -1,7 +1,7 @@ {% include "scanpipe/tree/resource_left_pane_header.html" only %}
-
-
+
+
-
-
-
diff --git a/scanpipe/templates/scanpipe/tree/resource_search_results.html b/scanpipe/templates/scanpipe/tree/resource_search_results.html index 19cd4a6d7f..e5b3006b73 100644 --- a/scanpipe/templates/scanpipe/tree/resource_search_results.html +++ b/scanpipe/templates/scanpipe/tree/resource_search_results.html @@ -1,5 +1,5 @@ {% if search_results %} -
+
{% for resource in search_results %} Date: Wed, 25 Mar 2026 00:25:27 +0530 Subject: [PATCH 3/3] fix icon and move search bar to a template Signed-off-by: Aayush Kumar --- .../scanpipe/tree/resource_left_pane.html | 31 +------------------ .../scanpipe/tree/resource_search_bar.html | 30 ++++++++++++++++++ scanpipe/views.py | 2 +- 3 files changed, 32 insertions(+), 31 deletions(-) create mode 100644 scanpipe/templates/scanpipe/tree/resource_search_bar.html diff --git a/scanpipe/templates/scanpipe/tree/resource_left_pane.html b/scanpipe/templates/scanpipe/tree/resource_left_pane.html index 4fbdf74595..2fee689d44 100644 --- a/scanpipe/templates/scanpipe/tree/resource_left_pane.html +++ b/scanpipe/templates/scanpipe/tree/resource_left_pane.html @@ -1,34 +1,5 @@ {% include "scanpipe/tree/resource_left_pane_header.html" only %} -
-
-
- - - - - -
-
- -
+{% include "scanpipe/tree/resource_search_bar.html" %}
{% include "scanpipe/tree/resource_left_pane_tree.html" with children=children %} diff --git a/scanpipe/templates/scanpipe/tree/resource_search_bar.html b/scanpipe/templates/scanpipe/tree/resource_search_bar.html new file mode 100644 index 0000000000..9a7a215830 --- /dev/null +++ b/scanpipe/templates/scanpipe/tree/resource_search_bar.html @@ -0,0 +1,30 @@ +
+
+
+ + + + + +
+
+ +
diff --git a/scanpipe/views.py b/scanpipe/views.py index c6ef480320..404e0fe029 100644 --- a/scanpipe/views.py +++ b/scanpipe/views.py @@ -2859,7 +2859,7 @@ class ProjectResourceSearchView( def get_queryset(self): search_query = self.request.GET.get("search", "").strip() if not search_query: - return CodebaseResource.objects.none() + return super().get_queryset().none() return ( super()