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..a0a6d6ddb4 100644 --- a/scancodeio/static/main.css +++ b/scancodeio/static/main.css @@ -551,13 +551,98 @@ 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 #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; + 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-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..2fee689d44 100644 --- a/scanpipe/templates/scanpipe/tree/resource_left_pane.html +++ b/scanpipe/templates/scanpipe/tree/resource_left_pane.html @@ -1,4 +1,6 @@ {% 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_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/templates/scanpipe/tree/resource_search_results.html b/scanpipe/templates/scanpipe/tree/resource_search_results.html new file mode 100644 index 0000000000..e5b3006b73 --- /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/tests/test_views.py b/scanpipe/tests/test_views.py index 09603c5316..9c1d3c7c21 100644 --- a/scanpipe/tests/test_views.py +++ b/scanpipe/tests/test_views.py @@ -1741,6 +1741,21 @@ def test_scanpipe_views_project_resource_tree_right_pane_view_empty_directory(se resources = list(response.context["resources"]) self.assertEqual(0, len(resources)) + def test_scanpipe_views_project_resource_tree_search_view_filters_results(self): + make_resource_file(self.project1, path="src/FooBar.py") + make_resource_file(self.project1, path="src/other.py") + + url = reverse( + "project_resource_tree_search", kwargs={"slug": self.project1.slug} + ) + response = self.client.get(url, data={"search": "foobar"}) + + self.assertEqual(200, response.status_code) + self.assertEqual("foobar", response.context["query"]) + self.assertEqual( + ["src/FooBar.py"], [r.path for r in response.context["search_results"]] + ) + @mock.patch("scanpipe.views.ProjectResourceTreeRightPaneView.paginate_by", 2) def test_scanpipe_views_project_resource_tree_right_pane_view_pagination(self): make_resource_directory(self.project1, path="parent") 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..404e0fe029 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 super().get_queryset().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 = (