feat(sidebar): add keyboard type-ahead search for hosts (#1516)

This commit is contained in:
ZacharyZcR authored and GitHub committed 2026-09-30 09:51:10 -05:00
1 parent 5b0423c8e0
commit fd91d4925a
5 files changed
+273 -5

No files matched your search

+3 -1
View File
@@ -857,7 +857,9 @@
"editorCancel": "Cancel",
"editorSaving": "Saving...",
"editorUpdate": "Update Host",
"editorAdd": "Add Host"
"editorAdd": "Add Host",
"speedSearch": "Search hosts. Arrow keys to select, Enter to connect, Esc to close",
"speedSearchList": "Host list. Type or press / to search."
},
"commandPalette": {
"searchPlaceholder": "Search hosts, commands, or settings...",
+73 -3
View File
@@ -1,3 +1,4 @@
import { useHostSpeedSearch } from "./hooks/useHostSpeedSearch";
import {
useCallback,
useEffect,
@@ -62,6 +63,7 @@ import {
collectAllHosts,
collectAllFolderPaths,
hostExpandKey,
hostMatchesQuery,
buildReorderRows,
collectOrderableRows,
rowKey,
@@ -78,7 +80,7 @@ export function SidebarTree({
onOpenTab,
onEditHost,
onShareHost,
query = "",
query: externalQuery = "",
selectionMode,
onToggleSelectionMode,
loading = false,
@@ -664,6 +666,10 @@ export function SidebarTree({
}
}
const speedSearch = useHostSpeedSearch();
const query = speedSearch.open
? speedSearch.text.trim().toLowerCase()
: externalQuery;
const allHosts = collectAllHosts(children);
const allFolderPaths = collectAllFolderPaths(children);
@@ -676,6 +682,13 @@ export function SidebarTree({
closedHostParents,
);
const parentRef = useRef<HTMLDivElement>(null);
const matchingRows = visibleRows
.map((row, index) => ({ row, index }))
.filter(
({ row }) => !isFolder(row.item) && hostMatchesQuery(row.item, query),
);
const activeMatch =
matchingRows[Math.min(speedSearch.index, matchingRows.length - 1)];
const isTouchOnly =
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
@@ -869,6 +882,15 @@ export function SidebarTree({
.forEach((element) => virtualizer.measureElement(element));
}, [virtualizer, density, trayTrigger, showTags, showResourceBars]);
useEffect(() => {
if (speedSearch.open) speedSearch.inputRef.current?.focus();
}, [speedSearch.open, speedSearch.inputRef]);
const activeMatchIndex = activeMatch?.index;
useEffect(() => {
if (speedSearch.open && activeMatchIndex !== undefined)
virtualizer.scrollToIndex(activeMatchIndex, { align: "auto" });
}, [speedSearch.open, activeMatchIndex, query, virtualizer]);
if (loading) {
return (
<div className="relative flex flex-col flex-1 min-h-0">
@@ -895,9 +917,53 @@ export function SidebarTree({
}
return (
<div className="relative flex flex-col flex-1 min-h-0">
<div
className="relative flex flex-col flex-1 min-h-0"
onKeyDown={(event) => {
if (selectionMode || arrangeMode) return;
speedSearch.onKeyDown(
event,
matchingRows.length,
(index) => {
const host = matchingRows[index]?.row.item;
if (!host || isFolder(host)) return;
const type = resolveHostTabType(host);
if (type)
onOpenTab(host, type, {
forceNewTab: hostClickBehavior === "newTab",
});
},
() => parentRef.current?.focus(),
);
}}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
speedSearch.close();
}}
>
{speedSearch.open && (
<div className="flex items-center gap-2 border-b border-border bg-popover px-3 py-2">
<input
ref={speedSearch.inputRef}
type="search"
aria-label={t("hosts.speedSearch")}
placeholder={t("hosts.speedSearch")}
value={speedSearch.text}
onChange={(event) => speedSearch.change(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-sm outline-none"
/>
<span role="status" className="text-xs text-muted-foreground">
{matchingRows.length
? Math.min(speedSearch.index + 1, matchingRows.length)
: 0}{" "}
/ {matchingRows.length}
</span>
</div>
)}
<div
ref={parentRef}
tabIndex={0}
aria-label={t("hosts.speedSearchList")}
className={`flex-1 min-h-0 overflow-y-auto ${rootDragOver ? "ring-1 ring-inset ring-accent-brand/50" : ""}`}
// Only the container's own empty space is a root drop target. Without
// the target check this fired for every child row the pointer crossed
@@ -965,7 +1031,11 @@ export function SidebarTree({
key={vItem.key}
data-index={vItem.index}
ref={virtualizer.measureElement}
className="absolute top-0 left-0 w-full"
data-speed-search-active={
(speedSearch.open && activeMatch?.index === vItem.index) ||
undefined
}
className={`absolute top-0 left-0 w-full ${speedSearch.open && activeMatch?.index === vItem.index ? "ring-1 ring-inset ring-accent-brand bg-accent/30" : ""}`}
style={{
transform: `translateY(${vItem.start}px)`,
}}
@@ -0,0 +1,63 @@
import { useRef, useState, type KeyboardEvent } from "react";
/** Keyboard search belongs to the focused host list, never to an editor or terminal. */
export function useHostSpeedSearch() {
const [open, setOpen] = useState(false);
const [text, setText] = useState("");
const [index, setIndex] = useState(0);
const inputRef = useRef<HTMLInputElement>(null);
const close = () => {
setOpen(false);
setText("");
setIndex(0);
};
const change = (value: string) => {
setText(value);
setIndex(0);
};
const onKeyDown = (
event: KeyboardEvent,
count: number,
activate: (index: number) => void,
restoreFocus: () => void,
) => {
const target = event.target as HTMLElement;
const inSearch = target === inputRef.current;
if (
(!inSearch &&
target.closest("input, textarea, select, [contenteditable=true]")) ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.nativeEvent.isComposing ||
event.keyCode === 229
)
return;
const selected = Math.min(index, Math.max(0, count - 1));
if (open && event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
close();
restoreFocus();
} else if (open && ["ArrowDown", "ArrowUp"].includes(event.key)) {
event.preventDefault();
event.stopPropagation();
if (count)
setIndex(
(selected + (event.key === "ArrowDown" ? 1 : -1) + count) % count,
);
} else if (open && event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
if (count) activate(selected);
} else if (!inSearch && event.key.length === 1 && event.key !== " ") {
event.preventDefault();
event.stopPropagation();
setOpen(true);
change(open ? text + event.key : event.key === "/" ? "" : event.key);
}
};
return { open, text, index, inputRef, change, close, onKeyDown };
}
@@ -0,0 +1,89 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useHostSpeedSearch } from "@/sidebar/tree/hooks/useHostSpeedSearch";
function SearchList({ activate }: { activate: (index: number) => void }) {
const search = useHostSpeedSearch();
return (
<div
data-testid="list"
tabIndex={0}
onKeyDown={(event) =>
search.onKeyDown(
event,
search.text === "missing" ? 0 : 2,
activate,
() => {},
)
}
>
<input aria-label="unrelated editor" />
<div
contentEditable
suppressContentEditableWarning
data-testid="editable"
/>
{search.open && (
<input
aria-label="search"
ref={search.inputRef}
value={search.text}
onChange={(event) => search.change(event.target.value)}
/>
)}
<span data-testid="selection">{search.index}</span>
</div>
);
}
describe("host speed search keyboard", () => {
it("starts from typing, navigates matches and closes with Escape", () => {
const activate = vi.fn();
render(<SearchList activate={activate} />);
fireEvent.keyDown(screen.getByTestId("list"), { key: "a" });
const input = screen.getByLabelText("search");
expect((input as HTMLInputElement).value).toBe("a");
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.keyDown(input, { key: "Enter" });
expect(activate).toHaveBeenLastCalledWith(1);
fireEvent.keyDown(input, { key: "ArrowDown" });
expect(screen.getByTestId("selection").textContent).toBe("0");
fireEvent.keyDown(input, { key: "ArrowUp" });
expect(screen.getByTestId("selection").textContent).toBe("1");
fireEvent.keyDown(input, { key: "Escape" });
expect(screen.queryByLabelText("search")).toBeNull();
});
it("accepts committed IME text without treating composition Enter as connect", () => {
const activate = vi.fn();
render(<SearchList activate={activate} />);
fireEvent.keyDown(screen.getByTestId("list"), { key: "/" });
const input = screen.getByLabelText("search");
fireEvent.change(input, { target: { value: "服务器" } });
fireEvent.keyDown(input, { key: "Enter", isComposing: true, keyCode: 229 });
expect(activate).not.toHaveBeenCalled();
expect((input as HTMLInputElement).value).toBe("服务器");
fireEvent.keyDown(input, { key: "Enter" });
expect(activate).toHaveBeenCalledWith(0);
});
it("ignores other editors and modified shortcuts", () => {
render(<SearchList activate={vi.fn()} />);
fireEvent.keyDown(screen.getByLabelText("unrelated editor"), { key: "a" });
fireEvent.keyDown(screen.getByTestId("editable"), { key: "b" });
fireEvent.keyDown(screen.getByTestId("list"), { key: "c", ctrlKey: true });
expect(screen.queryByLabelText("search")).toBeNull();
});
it("resets selection after editing and does not open anything with no matches", () => {
const activate = vi.fn();
render(<SearchList activate={activate} />);
fireEvent.keyDown(screen.getByTestId("list"), { key: "/" });
const input = screen.getByLabelText("search");
fireEvent.keyDown(input, { key: "ArrowDown" });
fireEvent.change(input, { target: { value: "missing" } });
expect(screen.getByTestId("selection").textContent).toBe("0");
fireEvent.keyDown(input, { key: "Enter" });
expect(activate).not.toHaveBeenCalled();
});
});
@@ -1,8 +1,11 @@
import { act, render } from "@testing-library/react";
import { act, fireEvent, render } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import type { Host, HostFolder } from "@/types/ui-types";
import { SidebarTree } from "@/sidebar/tree/SidebarTree";
vi.mock("@/lib/host-connection-tabs", () => ({
resolveHostTabType: () => "terminal",
}));
vi.mock("@/main-axios", () => ({}));
vi.mock("@/api/plugins-api", () => ({}));
vi.mock("@/settings/HostPluginSections", () => ({
@@ -127,3 +130,44 @@ it("keeps an unchanged folder's measured height after changing density", () => {
resize([rows()[1]]);
expect((rows()[1] as HTMLElement).style.transform).toBe("translateY(40px)");
});
it("finds a host inside a closed folder and opens the keyboard-selected match", () => {
const alpha = {
id: "1",
name: "alpha",
ip: "192.0.2.1",
username: "u",
} as Host;
const beta = {
id: "2",
name: "beta",
ip: "192.0.2.2",
username: "u",
} as Host;
const onOpenTab = vi.fn();
const view = render(
<SidebarTree
children={[{ name: "closed", path: "closed", children: [alpha, beta] }]}
onOpenTab={onOpenTab}
onEditHost={vi.fn()}
selectionMode={false}
onToggleSelectionMode={vi.fn()}
hostClickBehavior="focusExisting"
/>,
);
const list = view.getByLabelText("hosts.speedSearchList");
expect(view.container.querySelectorAll("[data-index]")).toHaveLength(1);
fireEvent.keyDown(list, { key: "a" });
const search = view.getByLabelText("hosts.speedSearch");
expect(document.activeElement).toBe(search);
expect(view.container.querySelectorAll("[data-index]")).toHaveLength(3);
fireEvent.keyDown(search, { key: "ArrowDown" });
fireEvent.keyDown(search, { key: "Enter" });
expect(onOpenTab).toHaveBeenCalledWith(beta, "terminal", {
forceNewTab: false,
});
fireEvent.keyDown(search, { key: "Escape" });
expect(view.queryByLabelText("hosts.speedSearch")).toBeNull();
expect(view.container.querySelectorAll("[data-index]")).toHaveLength(1);
expect(document.activeElement).toBe(list);
});