Skip to content

Commit f886bda

Browse files
committed
add resource tree search
Signed-off-by: Aayush Kumar <code@aayushk.dev>
1 parent dce8dbd commit f886bda

6 files changed

Lines changed: 377 additions & 6 deletions

File tree

scancodeio/static/js/resource_tree.js

Lines changed: 192 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,196 @@
6767
}
6868
}
6969

70+
function isTypingElement(element) {
71+
if (!element) return false;
72+
const tagName = element.tagName ? element.tagName.toLowerCase() : "";
73+
return (
74+
["input", "textarea", "select"].includes(tagName) ||
75+
element.isContentEditable
76+
);
77+
}
78+
79+
function initSearchInteractions() {
80+
const searchContainer = document.getElementById('resource-search-container');
81+
const searchInput = document.getElementById('file-search-input');
82+
const searchResults = document.getElementById('search-results');
83+
const clearSearchBtn = document.getElementById('clear-search');
84+
if (!searchContainer || !searchInput || !searchResults || !clearSearchBtn) return;
85+
86+
let activeIndex = -1;
87+
88+
if (searchResults.parentNode !== document.body) {
89+
searchResults.classList.add('search-dropdown-portal');
90+
document.body.appendChild(searchResults);
91+
}
92+
93+
function getResultItems() {
94+
return Array.from(searchResults.querySelectorAll('.search-result-item'));
95+
}
96+
97+
function updateDropdownPosition() {
98+
if (searchResults.classList.contains('is-hidden')) return;
99+
100+
const rect = searchContainer.getBoundingClientRect();
101+
const viewportPadding = 8;
102+
const baseWidth = Math.max(rect.width + 300, window.innerWidth * 0.5);
103+
const width = Math.min(baseWidth, window.innerWidth - viewportPadding * 2);
104+
const left = Math.max(
105+
viewportPadding,
106+
Math.min(rect.left, window.innerWidth - width - viewportPadding)
107+
);
108+
const dropdownTop = rect.bottom + 4;
109+
const availableHeight = window.innerHeight - dropdownTop - viewportPadding;
110+
const maxHeight = Math.max(180, Math.min(window.innerHeight * 0.62, availableHeight));
111+
112+
searchResults.style.left = `${left}px`;
113+
searchResults.style.top = `${dropdownTop}px`;
114+
searchResults.style.width = `${width}px`;
115+
searchResults.style.maxHeight = `${maxHeight}px`;
116+
}
117+
118+
function showDropdown() {
119+
if (searchInput.value.trim()) {
120+
searchResults.classList.remove('is-hidden');
121+
updateDropdownPosition();
122+
}
123+
}
124+
125+
function hideDropdown() {
126+
searchResults.classList.add('is-hidden');
127+
setActiveItem(-1);
128+
}
129+
130+
function updateClearButtonVisibility() {
131+
clearSearchBtn.classList.toggle('is-hidden', !searchInput.value.trim());
132+
}
133+
134+
function setActiveItem(nextIndex) {
135+
const items = getResultItems();
136+
if (!items.length) {
137+
activeIndex = -1;
138+
return;
139+
}
140+
141+
items.forEach(item => item.classList.remove('is-active'));
142+
if (nextIndex < 0) {
143+
activeIndex = -1;
144+
return;
145+
}
146+
147+
activeIndex = ((nextIndex % items.length) + items.length) % items.length;
148+
const activeItem = items[activeIndex];
149+
activeItem.classList.add('is-active');
150+
activeItem.scrollIntoView({ block: 'nearest' });
151+
}
152+
153+
function triggerActiveItem() {
154+
const items = getResultItems();
155+
if (!items.length) return;
156+
157+
const index = activeIndex >= 0 ? activeIndex : 0;
158+
const activeItem = items[index];
159+
activeItem.click();
160+
}
161+
162+
function clearSearch() {
163+
searchInput.value = '';
164+
updateClearButtonVisibility();
165+
hideDropdown();
166+
searchResults.innerHTML = '';
167+
searchInput.focus();
168+
}
169+
170+
clearSearchBtn.addEventListener('click', clearSearch);
171+
172+
searchInput.addEventListener('input', function() {
173+
activeIndex = -1;
174+
updateClearButtonVisibility();
175+
if (!searchInput.value.trim()) {
176+
hideDropdown();
177+
} else {
178+
updateDropdownPosition();
179+
}
180+
});
181+
182+
searchInput.addEventListener('focus', showDropdown);
183+
184+
window.addEventListener('resize', updateDropdownPosition);
185+
window.addEventListener('scroll', updateDropdownPosition, true);
186+
187+
searchInput.addEventListener('keydown', function(event) {
188+
if (event.key === 'Escape') {
189+
hideDropdown();
190+
searchInput.blur();
191+
return;
192+
}
193+
194+
if (event.key === 'ArrowDown') {
195+
event.preventDefault();
196+
const items = getResultItems();
197+
if (!items.length) return;
198+
showDropdown();
199+
const nextIndex = activeIndex < 0 ? 0 : activeIndex + 1;
200+
setActiveItem(nextIndex);
201+
return;
202+
}
203+
204+
if (event.key === 'ArrowUp') {
205+
event.preventDefault();
206+
const items = getResultItems();
207+
if (!items.length) return;
208+
showDropdown();
209+
const nextIndex = activeIndex < 0 ? items.length - 1 : activeIndex - 1;
210+
setActiveItem(nextIndex);
211+
return;
212+
}
213+
214+
if (event.key === 'Enter') {
215+
const items = getResultItems();
216+
if (!items.length || searchResults.classList.contains('is-hidden')) return;
217+
event.preventDefault();
218+
triggerActiveItem();
219+
}
220+
});
221+
222+
document.addEventListener('click', function(event) {
223+
const resultItem = event.target.closest('.search-result-item');
224+
if (resultItem && searchResults.contains(resultItem)) {
225+
hideDropdown();
226+
searchInput.blur();
227+
expandToPath(resultItem.dataset.path);
228+
return;
229+
}
230+
231+
if (!searchContainer.contains(event.target) && !searchResults.contains(event.target)) {
232+
hideDropdown();
233+
}
234+
});
235+
236+
document.addEventListener('keydown', function(event) {
237+
const target = event.target;
238+
if (event.key.toLowerCase() === 't' && !event.metaKey && !event.ctrlKey && !event.altKey) {
239+
if (isTypingElement(target)) return;
240+
event.preventDefault();
241+
searchInput.focus();
242+
}
243+
});
244+
245+
document.body.addEventListener('htmx:afterSettle', function(event) {
246+
if (event.target !== searchResults) return;
247+
activeIndex = -1;
248+
updateClearButtonVisibility();
249+
if (searchInput.value.trim()) {
250+
showDropdown();
251+
updateDropdownPosition();
252+
} else {
253+
hideDropdown();
254+
}
255+
});
256+
257+
updateClearButtonVisibility();
258+
}
259+
70260
document.addEventListener("click", async e => {
71261
const node = e.target.closest("[data-folder], .is-file[data-file], .expand-in-tree, [data-chevron]");
72262
if (!node) return;
@@ -142,5 +332,6 @@
142332
});
143333
});
144334

335+
initSearchInteractions();
145336
});
146-
})();
337+
})();

scancodeio/static/main.css

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,13 +551,96 @@ body.full-screen #resource-viewer .message-header {
551551
min-width: 0;
552552
max-width: 100%;
553553
border-right: 1px solid #ccc;
554-
overflow-y: auto;
555-
overflow-x: hidden;
554+
overflow: visible;
555+
display: flex;
556+
flex-direction: column;
557+
min-height: 0;
556558
flex-basis: 25%;
557559
transition: opacity 0.2s ease;
560+
position: relative;
561+
z-index: 3000;
562+
}
563+
#resource-tree-container .resource-tree-scroll {
564+
flex: 1 1 auto;
565+
min-height: 0;
566+
overflow-y: auto;
567+
overflow-x: hidden;
558568
text-overflow: ellipsis;
559569
white-space: nowrap;
560570
}
571+
#resource-tree-container .search-container {
572+
position: sticky;
573+
top: 0;
574+
z-index: 20;
575+
background: var(--bulma-scheme-main);
576+
padding: .25rem 0 .5rem;
577+
}
578+
#resource-tree-container .search-container .field {
579+
margin-bottom: 0;
580+
}
581+
#resource-tree-container .search-dropdown {
582+
position: absolute;
583+
top: calc(100% + 4px);
584+
left: 0;
585+
right: 0;
586+
z-index: 1000;
587+
}
588+
#resource-tree-container .search-dropdown,
589+
.search-dropdown.search-dropdown-portal {
590+
border: 1px solid var(--bulma-border);
591+
border-radius: 12px;
592+
background: var(--bulma-scheme-main);
593+
box-shadow: var(--bulma-shadow);
594+
min-height: 0;
595+
max-height: 62vh;
596+
overflow-y: auto;
597+
overflow-x: hidden;
598+
}
599+
.search-dropdown.search-dropdown-portal {
600+
position: fixed;
601+
top: 0;
602+
left: 0;
603+
right: auto;
604+
z-index: 11000;
605+
}
606+
#resource-tree-container .search-result-item,
607+
.search-dropdown.search-dropdown-portal .search-result-item {
608+
display: flex;
609+
align-items: center;
610+
gap: .5rem;
611+
color: var(--bulma-text);
612+
font-size: 14px;
613+
line-height: 20px;
614+
font-weight: 400;
615+
white-space: nowrap;
616+
}
617+
#resource-tree-container .search-results,
618+
.search-dropdown.search-dropdown-portal .search-results {
619+
margin: 0;
620+
}
621+
#resource-tree-container .search-result-item:hover,
622+
#resource-tree-container .search-result-item.is-active,
623+
.search-dropdown.search-dropdown-portal .search-result-item:hover,
624+
.search-dropdown.search-dropdown-portal .search-result-item.is-active {
625+
background-color: var(--bulma-background-hover);
626+
}
627+
#resource-tree-container .search-result-item .icon,
628+
.search-dropdown.search-dropdown-portal .search-result-item .icon {
629+
color: inherit;
630+
width: 16px;
631+
min-width: 16px;
632+
height: 16px;
633+
}
634+
#resource-tree-container .search-result-item .icon i,
635+
.search-dropdown.search-dropdown-portal .search-result-item .icon i {
636+
font-size: 13px;
637+
}
638+
#resource-tree-container .search-result-path,
639+
.search-dropdown.search-dropdown-portal .search-result-path {
640+
overflow: hidden;
641+
text-overflow: ellipsis;
642+
font-size: 14px;
643+
}
561644
#resource-tree-container .left-pane.collapsed {
562645
opacity: 0;
563646
pointer-events: none;
Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,34 @@
11
{% include "scanpipe/tree/resource_left_pane_header.html" only %}
2-
<div id="resource-tree">
3-
{% include "scanpipe/tree/resource_left_pane_tree.html" with children=children path=path %}
4-
</div>
2+
<div class="mb-3 search-container" id="resource-search-container">
3+
<div class="field has-addons">
4+
<div class="control has-icons-left is-expanded">
5+
<input
6+
id="file-search-input"
7+
class="input is-small"
8+
type="text"
9+
placeholder="Go to file..."
10+
autocomplete="off"
11+
hx-get="{% url 'project_resource_tree_search' project.slug %}"
12+
hx-target="#search-results"
13+
hx-trigger="input changed delay:200ms"
14+
hx-include="this"
15+
name="search"
16+
>
17+
<span class="icon is-small is-left">
18+
<i class="fas fa-search"></i>
19+
</span>
20+
</div>
21+
<div class="control">
22+
<button id="clear-search" class="button is-small is-hidden" type="button" aria-label="Clear search">
23+
<span class="icon is-small">
24+
<i class="fas fa-times"></i>
25+
</span>
26+
</button>
27+
</div>
28+
</div>
29+
<div id="search-results" class="search-dropdown is-hidden"></div>
30+
</div>
31+
32+
<div id="resource-tree" class="resource-tree-scroll">
33+
{% include "scanpipe/tree/resource_left_pane_tree.html" with children=children %}
34+
</div>
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{% if search_results %}
2+
<div class="search-results">
3+
{% for resource in search_results %}
4+
<a
5+
class="search-result-item px-4 py-2 is-clickable"
6+
{% if resource.is_dir %}
7+
hx-get="{% url 'project_resource_tree_right_pane' project.slug resource.path %}"
8+
{% else %}
9+
hx-get="{% url 'resource_detail' project.slug resource.path %}"
10+
{% endif %}
11+
hx-target="#right-pane"
12+
hx-push-url="{% url 'project_resource_tree' project.slug resource.path %}"
13+
data-path="{{ resource.path }}"
14+
title="{{ resource.path }}">
15+
<span class="icon mr-2">
16+
{% if resource.is_dir %}
17+
<i class="fas fa-folder"></i>
18+
{% else %}
19+
<i class="far fa-file"></i>
20+
{% endif %}
21+
</span>
22+
<span class="search-result-path">{{ resource.path }}</span>
23+
</a>
24+
{% endfor %}
25+
</div>
26+
{% elif query %}
27+
<div class="has-text-centered px-4 py-5">
28+
<div class="icon is-large has-text-grey-light mb-3">
29+
<i class="fas fa-search fa-2x"></i>
30+
</div>
31+
<p class="has-text-grey">No files found matching "{{ query }}"</p>
32+
</div>
33+
{% endif %}

scanpipe/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,11 @@
135135
views.ProjectCodebasePanelView.as_view(),
136136
name="project_codebase",
137137
),
138+
path(
139+
"project/<slug:slug>/resource_tree/search/",
140+
views.ProjectResourceSearchView.as_view(),
141+
name="project_resource_tree_search",
142+
),
138143
path(
139144
"project/<slug:slug>/resource_tree/<path:path>/",
140145
views.ProjectResourceTreeView.as_view(),

0 commit comments

Comments
 (0)