Skip to content

Commit 392fa50

Browse files
authored
new file typeahead control (#1913)
1 parent e018e7b commit 392fa50

File tree

15 files changed

+731
-29
lines changed

15 files changed

+731
-29
lines changed

frontend/app/block/blockframe.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,12 @@ const BlockFrame_Header = ({
242242
};
243243

244244
return (
245-
<div className="block-frame-default-header" ref={dragHandleRef} onContextMenu={onContextMenu}>
245+
<div
246+
className="block-frame-default-header"
247+
data-role="block-header"
248+
ref={dragHandleRef}
249+
onContextMenu={onContextMenu}
250+
>
246251
{preIconButtonElem}
247252
<div className="block-frame-default-header-iconview">
248253
{viewIconElem}

frontend/app/store/wshclientapi.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,11 @@ class RpcApiType {
147147
return client.wshRpcCall("eventunsuball", null, opts);
148148
}
149149

150+
// command "fetchsuggestions" [call]
151+
FetchSuggestionsCommand(client: WshClient, data: FetchSuggestionsData, opts?: RpcOpts): Promise<FetchSuggestionsResponse> {
152+
return client.wshRpcCall("fetchsuggestions", data, opts);
153+
}
154+
150155
// command "fileappend" [call]
151156
FileAppendCommand(client: WshClient, data: FileData, opts?: RpcOpts): Promise<void> {
152157
return client.wshRpcCall("fileappend", data, opts);

frontend/app/typeahead/typeahead.tsx

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Copyright 2025, Command Line Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { atoms } from "@/app/store/global";
5+
import { isBlank, makeIconClass } from "@/util/util";
6+
import { offset, useFloating } from "@floating-ui/react";
7+
import clsx from "clsx";
8+
import { useAtomValue } from "jotai";
9+
import React, { ReactNode, useEffect, useId, useRef, useState } from "react";
10+
11+
interface TypeaheadProps {
12+
anchorRef: React.RefObject<HTMLElement>;
13+
isOpen: boolean;
14+
onClose: () => void;
15+
onSelect: (item: SuggestionType, queryStr: string) => void;
16+
fetchSuggestions: SuggestionsFnType;
17+
className?: string;
18+
placeholderText?: string;
19+
}
20+
21+
const Typeahead: React.FC<TypeaheadProps> = ({ anchorRef, isOpen, onClose, onSelect, fetchSuggestions, className }) => {
22+
if (!isOpen || !anchorRef.current || !fetchSuggestions) return null;
23+
24+
return <TypeaheadInner {...{ anchorRef, onClose, onSelect, fetchSuggestions, className }} />;
25+
};
26+
27+
function highlightSearchMatch(target: string, search: string, highlightFn: (char: string) => ReactNode): ReactNode[] {
28+
if (!search || !target) return [target];
29+
30+
const result: ReactNode[] = [];
31+
let targetIndex = 0;
32+
let searchIndex = 0;
33+
34+
while (targetIndex < target.length) {
35+
// If we've matched all search chars, add remaining target string
36+
if (searchIndex >= search.length) {
37+
result.push(target.slice(targetIndex));
38+
break;
39+
}
40+
41+
// If current chars match
42+
if (target[targetIndex].toLowerCase() === search[searchIndex].toLowerCase()) {
43+
// Add highlighted character
44+
result.push(highlightFn(target[targetIndex]));
45+
searchIndex++;
46+
targetIndex++;
47+
} else {
48+
// Add non-matching character
49+
result.push(target[targetIndex]);
50+
targetIndex++;
51+
}
52+
}
53+
return result;
54+
}
55+
56+
function defaultHighlighter(target: string, search: string): ReactNode[] {
57+
return highlightSearchMatch(target, search, (char) => <span className="text-blue-500 font-bold">{char}</span>);
58+
}
59+
60+
function highlightPositions(target: string, positions: number[]): ReactNode[] {
61+
const result: ReactNode[] = [];
62+
let targetIndex = 0;
63+
let posIndex = 0;
64+
65+
while (targetIndex < target.length) {
66+
if (posIndex < positions.length && targetIndex === positions[posIndex]) {
67+
result.push(<span className="text-blue-500 font-bold">{target[targetIndex]}</span>);
68+
posIndex++;
69+
} else {
70+
result.push(target[targetIndex]);
71+
}
72+
targetIndex++;
73+
}
74+
return result;
75+
}
76+
77+
function getHighlightedText(suggestion: SuggestionType, highlightTerm: string): ReactNode[] {
78+
if (suggestion.matchpositions != null && suggestion.matchpositions.length > 0) {
79+
return highlightPositions(suggestion["file:name"], suggestion.matchpositions);
80+
}
81+
if (isBlank(highlightTerm)) {
82+
return [suggestion["file:name"]];
83+
}
84+
return defaultHighlighter(suggestion["file:name"], highlightTerm);
85+
}
86+
87+
function getMimeTypeIconAndColor(fullConfig: FullConfigType, mimeType: string): [string, string] {
88+
if (mimeType == null) {
89+
return [null, null];
90+
}
91+
while (mimeType.length > 0) {
92+
const icon = fullConfig.mimetypes?.[mimeType]?.icon ?? null;
93+
const iconColor = fullConfig.mimetypes?.[mimeType]?.color ?? null;
94+
if (icon != null) {
95+
return [icon, iconColor];
96+
}
97+
mimeType = mimeType.slice(0, -1);
98+
}
99+
return [null, null];
100+
}
101+
102+
const SuggestionIcon: React.FC<{ suggestion: SuggestionType }> = ({ suggestion }) => {
103+
const fullConfig = useAtomValue(atoms.fullConfigAtom);
104+
let icon = suggestion.icon;
105+
let iconColor: string = null;
106+
if (icon == null && suggestion["file:mimetype"] != null) {
107+
[icon, iconColor] = getMimeTypeIconAndColor(fullConfig, suggestion["file:mimetype"]);
108+
}
109+
if (suggestion.iconcolor != null) {
110+
iconColor = suggestion.iconcolor;
111+
}
112+
const iconClass = makeIconClass(icon, true, { defaultIcon: "file" });
113+
return <i className={iconClass} style={{ color: iconColor }} />;
114+
};
115+
116+
const TypeaheadInner: React.FC<Omit<TypeaheadProps, "isOpen">> = ({
117+
anchorRef,
118+
onClose,
119+
onSelect,
120+
fetchSuggestions,
121+
className,
122+
placeholderText,
123+
}) => {
124+
const widgetId = useId();
125+
const [query, setQuery] = useState("");
126+
const reqNumRef = useRef(0);
127+
const [suggestions, setSuggestions] = useState<SuggestionType[]>([]);
128+
const [selectedIndex, setSelectedIndex] = useState(0);
129+
const [highlightTerm, setHighlightTerm] = useState("");
130+
const [fetched, setFetched] = useState(false);
131+
const inputRef = useRef<HTMLInputElement>(null);
132+
const dropdownRef = useRef<HTMLDivElement>(null);
133+
const { refs, floatingStyles, middlewareData } = useFloating({
134+
placement: "bottom",
135+
strategy: "absolute",
136+
middleware: [offset(5)],
137+
});
138+
139+
useEffect(() => {
140+
if (anchorRef.current == null) {
141+
refs.setReference(null);
142+
return;
143+
}
144+
const headerElem = anchorRef.current.querySelector("[data-role='block-header']");
145+
refs.setReference(headerElem);
146+
}, [anchorRef.current]);
147+
148+
useEffect(() => {
149+
reqNumRef.current++;
150+
fetchSuggestions(query, { widgetid: widgetId, reqnum: reqNumRef.current }).then((results) => {
151+
if (results.reqnum != reqNumRef.current) {
152+
return;
153+
}
154+
setSuggestions(results.suggestions ?? []);
155+
setHighlightTerm(results.highlightterm ?? "");
156+
setFetched(true);
157+
});
158+
}, [query, fetchSuggestions]);
159+
160+
useEffect(() => {
161+
return () => {
162+
reqNumRef.current++;
163+
fetchSuggestions("", { widgetid: widgetId, reqnum: reqNumRef.current, dispose: true });
164+
};
165+
}, []);
166+
167+
useEffect(() => {
168+
inputRef.current?.focus();
169+
}, []);
170+
171+
useEffect(() => {
172+
const handleClickOutside = (event: MouseEvent) => {
173+
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
174+
onClose();
175+
}
176+
};
177+
document.addEventListener("mousedown", handleClickOutside);
178+
return () => document.removeEventListener("mousedown", handleClickOutside);
179+
}, [onClose, anchorRef]);
180+
181+
const handleKeyDown = (e: React.KeyboardEvent) => {
182+
if (e.key === "ArrowDown") {
183+
e.preventDefault();
184+
setSelectedIndex((prev) => Math.min(prev + 1, suggestions.length - 1));
185+
} else if (e.key === "ArrowUp") {
186+
e.preventDefault();
187+
setSelectedIndex((prev) => Math.max(prev - 1, 0));
188+
} else if (e.key === "Enter" && selectedIndex >= 0) {
189+
e.preventDefault();
190+
onSelect(suggestions[selectedIndex], query);
191+
onClose();
192+
} else if (e.key === "Escape") {
193+
e.preventDefault();
194+
onClose();
195+
} else if (e.key === "Tab") {
196+
e.preventDefault();
197+
const suggestion = suggestions[selectedIndex];
198+
if (suggestion != null) {
199+
// set the query to the suggestion
200+
if (suggestion["file:mimetype"] == "directory") {
201+
setQuery(suggestion["file:name"] + "/");
202+
} else {
203+
setQuery(suggestion["file:name"]);
204+
}
205+
}
206+
}
207+
};
208+
209+
return (
210+
<div
211+
className={clsx(
212+
"w-96 rounded-lg bg-gray-800 shadow-lg border border-gray-700 z-[var(--zindex-typeahead-modal)] absolute",
213+
middlewareData?.offset == null ? "opacity-0" : null,
214+
className
215+
)}
216+
ref={refs.setFloating}
217+
style={floatingStyles}
218+
>
219+
<div className="p-2">
220+
<input
221+
ref={inputRef}
222+
type="text"
223+
value={query}
224+
onChange={(e) => {
225+
setQuery(e.target.value);
226+
setSelectedIndex(0);
227+
}}
228+
onKeyDown={handleKeyDown}
229+
className="w-full bg-gray-900 text-gray-100 px-4 py-2 rounded-md
230+
border border-gray-700 focus:outline-none focus:border-blue-500
231+
placeholder-gray-500"
232+
placeholder={placeholderText}
233+
/>
234+
</div>
235+
{fetched && suggestions.length > 0 && (
236+
<div ref={dropdownRef} className="max-h-96 overflow-y-auto divide-y divide-gray-700">
237+
{suggestions.map((suggestion, index) => (
238+
<div
239+
key={suggestion.suggestionid}
240+
className={clsx(
241+
"flex items-center gap-3 px-4 py-2 cursor-pointer",
242+
"hover:bg-gray-700",
243+
index === selectedIndex ? "bg-gray-700" : "",
244+
"text-gray-100"
245+
)}
246+
onClick={() => {
247+
onSelect(suggestion, query);
248+
onClose();
249+
}}
250+
>
251+
<SuggestionIcon suggestion={suggestion} />
252+
<span className="truncate">{getHighlightedText(suggestion, highlightTerm)}</span>
253+
</div>
254+
))}
255+
</div>
256+
)}
257+
</div>
258+
);
259+
};
260+
261+
export { Typeahead };

frontend/app/view/preview/directorypreview.tsx

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -117,13 +117,6 @@ function isIconValid(icon: string): boolean {
117117
return icon.match(iconRegex) != null;
118118
}
119119

120-
function getIconClass(icon: string): string {
121-
if (!isIconValid(icon)) {
122-
return "fa fa-solid fa-question fa-fw";
123-
}
124-
return `fa fa-solid fa-${icon} fa-fw`;
125-
}
126-
127120
function getSortIcon(sortType: string | boolean): React.ReactNode {
128121
switch (sortType) {
129122
case "asc":

0 commit comments

Comments
 (0)