Skip to content

Commit 3c2b24c

Browse files
authored
feat: add search to resource tree (#2114)
Signed-off-by: Aayush Kumar <code@aayushk.dev>
1 parent 3745cee commit 3c2b24c

8 files changed

Lines changed: 396 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: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,13 +551,98 @@ 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 #clear-search {
579+
display: inline-flex;
580+
align-items: center;
581+
justify-content: center;
582+
border: none;
583+
background: transparent;
584+
cursor: pointer;
585+
pointer-events: auto;
586+
}
587+
#resource-tree-container .search-dropdown {
588+
position: absolute;
589+
top: calc(100% + 4px);
590+
left: 0;
591+
right: 0;
592+
z-index: 1000;
593+
}
594+
#resource-tree-container .search-dropdown,
595+
.search-dropdown.search-dropdown-portal {
596+
border: 1px solid var(--bulma-border);
597+
border-radius: 12px;
598+
background: var(--bulma-scheme-main);
599+
box-shadow: var(--bulma-shadow);
600+
min-height: 0;
601+
max-height: 62vh;
602+
overflow-y: auto;
603+
overflow-x: hidden;
604+
}
605+
.search-dropdown.search-dropdown-portal {
606+
position: fixed;
607+
top: 0;
608+
left: 0;
609+
right: auto;
610+
z-index: 11000;
611+
}
612+
#resource-tree-container .search-result-item,
613+
.search-dropdown.search-dropdown-portal .search-result-item {
614+
display: flex;
615+
align-items: center;
616+
gap: .5rem;
617+
color: var(--bulma-text);
618+
font-size: 14px;
619+
line-height: 20px;
620+
font-weight: 400;
621+
white-space: nowrap;
622+
}
623+
#resource-tree-container .search-result-item:hover,
624+
#resource-tree-container .search-result-item.is-active,
625+
.search-dropdown.search-dropdown-portal .search-result-item:hover,
626+
.search-dropdown.search-dropdown-portal .search-result-item.is-active {
627+
background-color: var(--bulma-background-hover);
628+
}
629+
#resource-tree-container .search-result-item .icon,
630+
.search-dropdown.search-dropdown-portal .search-result-item .icon {
631+
color: inherit;
632+
width: 16px;
633+
min-width: 16px;
634+
height: 16px;
635+
}
636+
#resource-tree-container .search-result-item .icon i,
637+
.search-dropdown.search-dropdown-portal .search-result-item .icon i {
638+
font-size: 13px;
639+
}
640+
#resource-tree-container .search-result-path,
641+
.search-dropdown.search-dropdown-portal .search-result-path {
642+
overflow: hidden;
643+
text-overflow: ellipsis;
644+
font-size: 14px;
645+
}
561646
#resource-tree-container .left-pane.collapsed {
562647
opacity: 0;
563648
pointer-events: none;
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
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+
{% include "scanpipe/tree/resource_search_bar.html" %}
3+
4+
<div id="resource-tree" class="resource-tree-scroll">
5+
{% include "scanpipe/tree/resource_left_pane_tree.html" with children=children %}
6+
</div>
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<div class="mb-3 search-container" id="resource-search-container">
2+
<div class="field mb-0">
3+
<div class="control has-icons-left has-icons-right is-expanded">
4+
<input
5+
id="file-search-input"
6+
class="input is-small"
7+
type="text"
8+
placeholder="Go to file..."
9+
autocomplete="off"
10+
hx-get="{% url 'project_resource_tree_search' project.slug %}"
11+
hx-target="#search-results"
12+
hx-trigger="input changed delay:200ms"
13+
hx-include="this"
14+
name="search"
15+
>
16+
<span class="icon is-small is-left">
17+
<i class="fas fa-search"></i>
18+
</span>
19+
<button
20+
id="clear-search"
21+
class="icon is-small is-right is-hidden has-text-grey"
22+
type="button"
23+
aria-label="Clear search"
24+
>
25+
<i class="fas fa-times-circle fa-lg"></i>
26+
</button>
27+
</div>
28+
</div>
29+
<div id="search-results" class="search-dropdown is-hidden"></div>
30+
</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 m-0">
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 %}

0 commit comments

Comments
 (0)