diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 5194fa4f2..73d8ea9df 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -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...", diff --git a/src/ui/sidebar/tree/SidebarTree.tsx b/src/ui/sidebar/tree/SidebarTree.tsx index e7d5eb41e..54ae3b32d 100644 --- a/src/ui/sidebar/tree/SidebarTree.tsx +++ b/src/ui/sidebar/tree/SidebarTree.tsx @@ -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(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 (
@@ -895,9 +917,53 @@ export function SidebarTree({ } return ( -
+
{ + 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 && ( +
+ speedSearch.change(event.target.value)} + className="min-w-0 flex-1 bg-transparent text-sm outline-none" + /> + + {matchingRows.length + ? Math.min(speedSearch.index + 1, matchingRows.length) + : 0}{" "} + / {matchingRows.length} + +
+ )}
(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 }; +} diff --git a/src/ui/tests/sidebar/tree/host-speed-search.test.tsx b/src/ui/tests/sidebar/tree/host-speed-search.test.tsx new file mode 100644 index 000000000..9273ddca4 --- /dev/null +++ b/src/ui/tests/sidebar/tree/host-speed-search.test.tsx @@ -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 ( +
+ search.onKeyDown( + event, + search.text === "missing" ? 0 : 2, + activate, + () => {}, + ) + } + > + +
+ {search.open && ( + search.change(event.target.value)} + /> + )} + {search.index} +
+ ); +} + +describe("host speed search keyboard", () => { + it("starts from typing, navigates matches and closes with Escape", () => { + const activate = vi.fn(); + render(); + 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(); + 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(); + 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(); + 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(); + }); +}); diff --git a/src/ui/tests/sidebar/tree/sidebar-density.test.tsx b/src/ui/tests/sidebar/tree/sidebar-density.test.tsx index 5bb17811a..8620e4d5d 100644 --- a/src/ui/tests/sidebar/tree/sidebar-density.test.tsx +++ b/src/ui/tests/sidebar/tree/sidebar-density.test.tsx @@ -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( + , + ); + 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); +});