mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat(sidebar): add keyboard type-ahead search for hosts (#1516)
This commit is contained in:
1 parent
5b0423c8e0
commit
fd91d4925a
5 files changed
+273
-5
No files matched your search
@@ -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...",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user