mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15: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",
|
"editorCancel": "Cancel",
|
||||||
"editorSaving": "Saving...",
|
"editorSaving": "Saving...",
|
||||||
"editorUpdate": "Update Host",
|
"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": {
|
"commandPalette": {
|
||||||
"searchPlaceholder": "Search hosts, commands, or settings...",
|
"searchPlaceholder": "Search hosts, commands, or settings...",
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useHostSpeedSearch } from "./hooks/useHostSpeedSearch";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -62,6 +63,7 @@ import {
|
|||||||
collectAllHosts,
|
collectAllHosts,
|
||||||
collectAllFolderPaths,
|
collectAllFolderPaths,
|
||||||
hostExpandKey,
|
hostExpandKey,
|
||||||
|
hostMatchesQuery,
|
||||||
buildReorderRows,
|
buildReorderRows,
|
||||||
collectOrderableRows,
|
collectOrderableRows,
|
||||||
rowKey,
|
rowKey,
|
||||||
@@ -78,7 +80,7 @@ export function SidebarTree({
|
|||||||
onOpenTab,
|
onOpenTab,
|
||||||
onEditHost,
|
onEditHost,
|
||||||
onShareHost,
|
onShareHost,
|
||||||
query = "",
|
query: externalQuery = "",
|
||||||
selectionMode,
|
selectionMode,
|
||||||
onToggleSelectionMode,
|
onToggleSelectionMode,
|
||||||
loading = false,
|
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 allHosts = collectAllHosts(children);
|
||||||
const allFolderPaths = collectAllFolderPaths(children);
|
const allFolderPaths = collectAllFolderPaths(children);
|
||||||
|
|
||||||
@@ -676,6 +682,13 @@ export function SidebarTree({
|
|||||||
closedHostParents,
|
closedHostParents,
|
||||||
);
|
);
|
||||||
const parentRef = useRef<HTMLDivElement>(null);
|
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 =
|
const isTouchOnly =
|
||||||
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
typeof window !== "undefined" && window.matchMedia("(hover: none)").matches;
|
||||||
@@ -869,6 +882,15 @@ export function SidebarTree({
|
|||||||
.forEach((element) => virtualizer.measureElement(element));
|
.forEach((element) => virtualizer.measureElement(element));
|
||||||
}, [virtualizer, density, trayTrigger, showTags, showResourceBars]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col flex-1 min-h-0">
|
<div className="relative flex flex-col flex-1 min-h-0">
|
||||||
@@ -895,9 +917,53 @@ export function SidebarTree({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
ref={parentRef}
|
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" : ""}`}
|
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
|
// 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
|
// the target check this fired for every child row the pointer crossed
|
||||||
@@ -965,7 +1031,11 @@ export function SidebarTree({
|
|||||||
key={vItem.key}
|
key={vItem.key}
|
||||||
data-index={vItem.index}
|
data-index={vItem.index}
|
||||||
ref={virtualizer.measureElement}
|
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={{
|
style={{
|
||||||
transform: `translateY(${vItem.start}px)`,
|
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 { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||||
import type { Host, HostFolder } from "@/types/ui-types";
|
import type { Host, HostFolder } from "@/types/ui-types";
|
||||||
import { SidebarTree } from "@/sidebar/tree/SidebarTree";
|
import { SidebarTree } from "@/sidebar/tree/SidebarTree";
|
||||||
|
|
||||||
|
vi.mock("@/lib/host-connection-tabs", () => ({
|
||||||
|
resolveHostTabType: () => "terminal",
|
||||||
|
}));
|
||||||
vi.mock("@/main-axios", () => ({}));
|
vi.mock("@/main-axios", () => ({}));
|
||||||
vi.mock("@/api/plugins-api", () => ({}));
|
vi.mock("@/api/plugins-api", () => ({}));
|
||||||
vi.mock("@/settings/HostPluginSections", () => ({
|
vi.mock("@/settings/HostPluginSections", () => ({
|
||||||
@@ -127,3 +130,44 @@ it("keeps an unchanged folder's measured height after changing density", () => {
|
|||||||
resize([rows()[1]]);
|
resize([rows()[1]]);
|
||||||
expect((rows()[1] as HTMLElement).style.transform).toBe("translateY(40px)");
|
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