From f0d720cf05810c04a94c9f833f105f24a788521d Mon Sep 17 00:00:00 2001 From: LukeGus Date: Mon, 28 Sep 2026 20:45:23 -0500 Subject: [PATCH] feat(ssh-terminal): add toolbar position, start state, display and fade host settings --- plugins/ssh-terminal/locales/en.json | 32 +++++++ plugins/ssh-terminal/manifest.json | 94 +++++++++++++++++++ plugins/ssh-terminal/src/backend/settings.ts | 5 + .../src/frontend/terminal/TerminalToolbar.tsx | 82 +++++++++++----- .../src/frontend/terminal/toolbar-geometry.ts | 18 +++- .../src/frontend/terminal/toolbar-settings.ts | 82 ++++++++++++++++ .../terminal/TerminalToolbar.test.tsx | 59 ++++++++++++ .../terminal/toolbar-settings.test.ts | 74 +++++++++++++++ 8 files changed, 418 insertions(+), 28 deletions(-) create mode 100644 plugins/ssh-terminal/src/frontend/terminal/toolbar-settings.ts create mode 100644 plugins/ssh-terminal/tests/frontend/terminal/toolbar-settings.test.ts diff --git a/plugins/ssh-terminal/locales/en.json b/plugins/ssh-terminal/locales/en.json index 2180cee2b..e17358504 100644 --- a/plugins/ssh-terminal/locales/en.json +++ b/plugins/ssh-terminal/locales/en.json @@ -198,6 +198,38 @@ "label": "Enable Terminal Toolbar", "description": "Show a quick-access toolbar docked to the active terminal for this host's tools and live stats" }, + "terminalToolbarPosition": { + "label": "Toolbar Position", + "description": "Where the toolbar sits in the terminal. You can still drag it from there.", + "bottom": "Bottom center", + "bottomLeft": "Bottom left", + "bottomRight": "Bottom right", + "top": "Top center", + "topLeft": "Top left", + "topRight": "Top right" + }, + "terminalToolbarStartState": { + "label": "Toolbar Start State", + "description": "Whether the toolbar opens expanded or minimized when the terminal connects", + "expanded": "Expanded", + "collapsed": "Minimized" + }, + "terminalToolbarDisplay": { + "label": "Toolbar Display Mode", + "description": "How toolbar buttons are shown. Last used keeps whatever you picked in the toolbar.", + "remember": "Last used", + "icon": "Icon only", + "labeled": "Icons and labels", + "expanded": "Expanded with stats" + }, + "terminalToolbarShowStatus": { + "label": "Show Live Stats", + "description": "Show live host stats above the toolbar buttons in expanded mode" + }, + "terminalToolbarFade": { + "label": "Fade When Idle", + "description": "Fade the toolbar out until you hover over it" + }, "enableCommandHistory": { "label": "Command History", "description": "Record commands run in this terminal for history and autocomplete" diff --git a/plugins/ssh-terminal/manifest.json b/plugins/ssh-terminal/manifest.json index 38e7a2698..7853f3573 100644 --- a/plugins/ssh-terminal/manifest.json +++ b/plugins/ssh-terminal/manifest.json @@ -230,6 +230,100 @@ "descriptionKey": "settings.host.enableTerminalToolbar.description", "default": true }, + { + "key": "terminalToolbarPosition", + "type": "select", + "labelKey": "settings.host.terminalToolbarPosition.label", + "descriptionKey": "settings.host.terminalToolbarPosition.description", + "requires": "enableTerminalToolbar", + "default": "bottom", + "options": [ + { + "value": "bottom", + "labelKey": "settings.host.terminalToolbarPosition.bottom" + }, + { + "value": "bottom-left", + "labelKey": "settings.host.terminalToolbarPosition.bottomLeft" + }, + { + "value": "bottom-right", + "labelKey": "settings.host.terminalToolbarPosition.bottomRight" + }, + { + "value": "top", + "labelKey": "settings.host.terminalToolbarPosition.top" + }, + { + "value": "top-left", + "labelKey": "settings.host.terminalToolbarPosition.topLeft" + }, + { + "value": "top-right", + "labelKey": "settings.host.terminalToolbarPosition.topRight" + } + ] + }, + { + "key": "terminalToolbarStartState", + "type": "select", + "labelKey": "settings.host.terminalToolbarStartState.label", + "descriptionKey": "settings.host.terminalToolbarStartState.description", + "requires": "enableTerminalToolbar", + "default": "expanded", + "options": [ + { + "value": "expanded", + "labelKey": "settings.host.terminalToolbarStartState.expanded" + }, + { + "value": "collapsed", + "labelKey": "settings.host.terminalToolbarStartState.collapsed" + } + ] + }, + { + "key": "terminalToolbarDisplay", + "type": "select", + "labelKey": "settings.host.terminalToolbarDisplay.label", + "descriptionKey": "settings.host.terminalToolbarDisplay.description", + "requires": "enableTerminalToolbar", + "default": "remember", + "options": [ + { + "value": "remember", + "labelKey": "settings.host.terminalToolbarDisplay.remember" + }, + { + "value": "icon", + "labelKey": "settings.host.terminalToolbarDisplay.icon" + }, + { + "value": "labeled", + "labelKey": "settings.host.terminalToolbarDisplay.labeled" + }, + { + "value": "expanded", + "labelKey": "settings.host.terminalToolbarDisplay.expanded" + } + ] + }, + { + "key": "terminalToolbarShowStatus", + "type": "boolean", + "labelKey": "settings.host.terminalToolbarShowStatus.label", + "descriptionKey": "settings.host.terminalToolbarShowStatus.description", + "requires": "enableTerminalToolbar", + "default": true + }, + { + "key": "terminalToolbarFade", + "type": "boolean", + "labelKey": "settings.host.terminalToolbarFade.label", + "descriptionKey": "settings.host.terminalToolbarFade.description", + "requires": "enableTerminalToolbar", + "default": true + }, { "key": "enableCommandHistory", "type": "boolean", diff --git a/plugins/ssh-terminal/src/backend/settings.ts b/plugins/ssh-terminal/src/backend/settings.ts index d6004e857..c8963922e 100644 --- a/plugins/ssh-terminal/src/backend/settings.ts +++ b/plugins/ssh-terminal/src/backend/settings.ts @@ -31,6 +31,11 @@ export const ADMIN_KEYS = { export const HOST_KEYS = { enableTerminal: "enableTerminal", enableTerminalToolbar: "enableTerminalToolbar", + terminalToolbarPosition: "terminalToolbarPosition", + terminalToolbarStartState: "terminalToolbarStartState", + terminalToolbarDisplay: "terminalToolbarDisplay", + terminalToolbarShowStatus: "terminalToolbarShowStatus", + terminalToolbarFade: "terminalToolbarFade", enableCommandHistory: "enableCommandHistory", } as const; diff --git a/plugins/ssh-terminal/src/frontend/terminal/TerminalToolbar.tsx b/plugins/ssh-terminal/src/frontend/terminal/TerminalToolbar.tsx index 682920ac0..790eebcf4 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/TerminalToolbar.tsx +++ b/plugins/ssh-terminal/src/frontend/terminal/TerminalToolbar.tsx @@ -32,6 +32,11 @@ import { type ToolbarPosition, type ToolbarDensity, } from "./toolbar-geometry"; +import { + isLeftAnchor, + readToolbarSettings, + toolbarAnchorClasses, +} from "./toolbar-settings"; import { Select, SelectContent, @@ -143,15 +148,19 @@ export const TerminalToolbar: React.FC = ({ if (action.run) action.run(record, tabs as unknown as ShellApi); else if (action.tabType) tabs.openTab(record, action.tabType); }; - const [density, setDensity] = - useState(readStoredDensity); + const settings = useMemo(() => readToolbarSettings(host), [host]); + const { anchor } = settings; + const rememberDensity = settings.density === null; + const [density, setDensity] = useState( + () => settings.density ?? readStoredDensity(), + ); const [responsiveDensity, setResponsiveDensity] = - useState(readStoredDensity); - const [position, setPosition] = useState( - readStoredToolbarPosition, + useState(() => density); + const [position, setPosition] = useState(() => + readStoredToolbarPosition(anchor), ); const positionRef = useRef(position); - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState(settings.startCollapsed); const [densityOpen, setDensityOpen] = useState(false); const [imageError, setImageError] = useState(null); const [retryImageAction, setRetryImageAction] = useState<(() => void) | null>( @@ -160,7 +169,8 @@ export const TerminalToolbar: React.FC = ({ const fileInputRef = useRef(null); const hideToolbarRef = useRef(null); const pendingExpansionFocusRef = useRef(false); - const pendingRightEdgeRef = useRef(null); + // Keeps the anchored edge still when the toolbar grows or shrinks. + const pendingEdgeRef = useRef(null); const mountedRef = useRef(false); const imageGenerationRef = useRef(0); const hostRef = useRef(null); @@ -182,7 +192,9 @@ export const TerminalToolbar: React.FC = ({ ? responsiveDensity : "icon"; const showStats = - effectiveDensity === "expanded" && statusContributions.length > 0; + settings.showStatus && + effectiveDensity === "expanded" && + statusContributions.length > 0; const isMobile = useIsMobile(); useLayoutEffect(() => { @@ -196,23 +208,35 @@ export const TerminalToolbar: React.FC = ({ }, [isMobile]); useEffect(() => { + if (settings.density) setDensity(settings.density); + }, [settings.density]); + + useEffect(() => { + const next = readStoredToolbarPosition(anchor); + positionRef.current = next; + setPosition(next); + }, [anchor]); + + useEffect(() => { + if (!rememberDensity) return; if (desktopViewportReady !== true || isMobile !== false) return; try { window.localStorage.setItem(DENSITY_STORAGE_KEY, density); } catch { // Storage can be unavailable in hardened browser contexts. } - }, [density, desktopViewportReady, isMobile]); + }, [density, desktopViewportReady, isMobile, rememberDensity]); // Picking an interface preset seeds this key, so pick the new value up // without needing the tab to remount. useEffect(() => { + if (!rememberDensity) return; if (desktopViewportReady !== true || isMobile !== false) return; const handler = () => setDensity(readStoredDensity()); window.addEventListener("terminalToolbarDensityChanged", handler); return () => window.removeEventListener("terminalToolbarDensityChanged", handler); - }, [desktopViewportReady, isMobile]); + }, [desktopViewportReady, isMobile, rememberDensity]); useEffect(() => { if (desktopViewportReady !== true || isMobile !== false) return; const measurement = measurementRef.current; @@ -288,19 +312,21 @@ export const TerminalToolbar: React.FC = ({ if (extremeOffset) { positionRef.current = { x: 0, y: 0 }; setPosition({ x: 0, y: 0 }); - persistToolbarPosition({ x: 0, y: 0 }); + persistToolbarPosition({ x: 0, y: 0 }, anchor); return; } if (toolbarRect.width > 0 && toolbarRect.height > 0) { - const pendingRightEdge = pendingRightEdgeRef.current; - pendingRightEdgeRef.current = null; - const baseRight = toolbarRect.right - positionRef.current.x; + const pendingEdge = pendingEdgeRef.current; + pendingEdgeRef.current = null; + const baseEdge = + (isLeftAnchor(anchor) ? toolbarRect.left : toolbarRect.right) - + positionRef.current.x; const next = clampToolbarPosition( { x: - pendingRightEdge == null + pendingEdge == null ? positionRef.current.x - : pendingRightEdge - baseRight, + : pendingEdge - baseEdge, y: positionRef.current.y, }, toolbarRect, @@ -313,7 +339,7 @@ export const TerminalToolbar: React.FC = ({ ) { positionRef.current = next; setPosition(next); - persistToolbarPosition(next); + persistToolbarPosition(next, anchor); } } if (!collapsed && pendingExpansionFocusRef.current) { @@ -321,6 +347,7 @@ export const TerminalToolbar: React.FC = ({ hideToolbarRef.current?.focus(); } }, [ + anchor, collapsed, density, desktopViewportReady, @@ -392,6 +419,11 @@ export const TerminalToolbar: React.FC = ({ reportImageResult(onPasteImage, pasteImage); }; const chooseFile = () => fileInputRef.current?.click(); + const anchoredEdge = () => { + const rect = toolbarRef.current?.getBoundingClientRect(); + if (!rect) return null; + return isLeftAnchor(anchor) ? rect.left : rect.right; + }; const handleGrabPointerDown = ( event: React.PointerEvent, ) => { @@ -436,7 +468,7 @@ export const TerminalToolbar: React.FC = ({ ) => { const drag = dragRef.current; if (!drag || drag.pointerId !== event.pointerId) return; - persistToolbarPosition(positionRef.current); + persistToolbarPosition(positionRef.current, anchor); dragRef.current = null; if (capturedPointerIdRef.current === event.pointerId) { capturedPointerIdRef.current = null; @@ -517,8 +549,7 @@ export const TerminalToolbar: React.FC = ({ aria-label={t("terminalToolbar.showToolbar")} title={t("terminalToolbar.showToolbar")} onClick={() => { - pendingRightEdgeRef.current = - toolbarRef.current?.getBoundingClientRect().right ?? null; + pendingEdgeRef.current = anchoredEdge(); pendingExpansionFocusRef.current = true; setCollapsed(false); }} @@ -548,8 +579,10 @@ export const TerminalToolbar: React.FC = ({
= ({ data-terminal-toolbar-wide className={cn( "pointer-events-auto transition-opacity duration-300 hover:duration-0 focus-within:duration-0 hover:opacity-100 focus-within:opacity-100", - collapsed || densityOpen ? "opacity-100" : "opacity-30", + collapsed || densityOpen || !settings.fadeWhenIdle + ? "opacity-100" + : "opacity-30", )} style={{ transform: `translate(${position.x}px, ${position.y}px)`, @@ -741,8 +776,7 @@ export const TerminalToolbar: React.FC = ({ aria-label={t("terminalToolbar.hideToolbar")} title={t("terminalToolbar.hideToolbar")} onClick={() => { - pendingRightEdgeRef.current = - toolbarRef.current?.getBoundingClientRect().right ?? null; + pendingEdgeRef.current = anchoredEdge(); setCollapsed(true); }} > diff --git a/plugins/ssh-terminal/src/frontend/terminal/toolbar-geometry.ts b/plugins/ssh-terminal/src/frontend/terminal/toolbar-geometry.ts index 548ed1901..199451eff 100644 --- a/plugins/ssh-terminal/src/frontend/terminal/toolbar-geometry.ts +++ b/plugins/ssh-terminal/src/frontend/terminal/toolbar-geometry.ts @@ -53,12 +53,22 @@ function persistPosition(storageKey: string, position: ToolbarPosition): void { } } -export function readStoredToolbarPosition(): ToolbarPosition { - return readStoredPosition(TOOLBAR_POSITION_STORAGE_KEY); +/** Each anchor keeps its own drag offset; bottom keeps the original key. */ +export function toolbarPositionStorageKey(anchor = "bottom"): string { + return anchor === "bottom" + ? TOOLBAR_POSITION_STORAGE_KEY + : `${TOOLBAR_POSITION_STORAGE_KEY}-${anchor}`; } -export function persistToolbarPosition(position: ToolbarPosition): void { - persistPosition(TOOLBAR_POSITION_STORAGE_KEY, position); +export function readStoredToolbarPosition(anchor?: string): ToolbarPosition { + return readStoredPosition(toolbarPositionStorageKey(anchor)); +} + +export function persistToolbarPosition( + position: ToolbarPosition, + anchor?: string, +): void { + persistPosition(toolbarPositionStorageKey(anchor), position); } export function clampToolbarPosition( diff --git a/plugins/ssh-terminal/src/frontend/terminal/toolbar-settings.ts b/plugins/ssh-terminal/src/frontend/terminal/toolbar-settings.ts new file mode 100644 index 000000000..f4c344087 --- /dev/null +++ b/plugins/ssh-terminal/src/frontend/terminal/toolbar-settings.ts @@ -0,0 +1,82 @@ +import type { ToolbarDensity } from "./toolbar-geometry"; + +export const TOOLBAR_ANCHORS = [ + "bottom", + "bottom-left", + "bottom-right", + "top", + "top-left", + "top-right", +] as const; +export type ToolbarAnchor = (typeof TOOLBAR_ANCHORS)[number]; + +export interface ToolbarSettings { + anchor: ToolbarAnchor; + startCollapsed: boolean; + /** null keeps whatever mode the user last picked in the toolbar. */ + density: ToolbarDensity | null; + showStatus: boolean; + fadeWhenIdle: boolean; +} + +export const DEFAULT_TOOLBAR_SETTINGS: ToolbarSettings = { + anchor: "bottom", + startCollapsed: false, + density: null, + showStatus: true, + fadeWhenIdle: true, +}; + +const DENSITIES: ToolbarDensity[] = ["icon", "labeled", "expanded"]; + +/** The toolbar's host settings, as the host payload carries them. */ +export function readToolbarSettings( + host: object | null | undefined, +): ToolbarSettings { + const values = + ( + host as { + pluginSettings?: Record>; + } | null + )?.pluginSettings?.["ssh-terminal"] ?? {}; + const anchor = values.terminalToolbarPosition; + const density = values.terminalToolbarDisplay; + const bool = (value: unknown, fallback: boolean) => + typeof value === "boolean" ? value : fallback; + return { + anchor: TOOLBAR_ANCHORS.includes(anchor as ToolbarAnchor) + ? (anchor as ToolbarAnchor) + : DEFAULT_TOOLBAR_SETTINGS.anchor, + startCollapsed: values.terminalToolbarStartState === "collapsed", + density: DENSITIES.includes(density as ToolbarDensity) + ? (density as ToolbarDensity) + : null, + showStatus: bool( + values.terminalToolbarShowStatus, + DEFAULT_TOOLBAR_SETTINGS.showStatus, + ), + fadeWhenIdle: bool( + values.terminalToolbarFade, + DEFAULT_TOOLBAR_SETTINGS.fadeWhenIdle, + ), + }; +} + +export function isLeftAnchor(anchor: ToolbarAnchor): boolean { + return anchor.endsWith("-left"); +} + +export function isTopAnchor(anchor: ToolbarAnchor): boolean { + return anchor.startsWith("top"); +} + +/** Flex placement for the full-size overlay that holds the toolbar. */ +export function toolbarAnchorClasses(anchor: ToolbarAnchor): string { + const vertical = isTopAnchor(anchor) ? "items-start pt-2" : "items-end pb-2"; + const horizontal = anchor.endsWith("-left") + ? "justify-start pl-2" + : anchor.endsWith("-right") + ? "justify-end pr-2" + : "justify-center"; + return `${vertical} ${horizontal}`; +} diff --git a/plugins/ssh-terminal/tests/frontend/terminal/TerminalToolbar.test.tsx b/plugins/ssh-terminal/tests/frontend/terminal/TerminalToolbar.test.tsx index 1a2705cbe..d2b120443 100644 --- a/plugins/ssh-terminal/tests/frontend/terminal/TerminalToolbar.test.tsx +++ b/plugins/ssh-terminal/tests/frontend/terminal/TerminalToolbar.test.tsx @@ -976,4 +976,63 @@ describe("TerminalToolbar Phase 1", () => { expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull(); }); }); + describe("host toolbar settings", () => { + const withSettings = (values: Record) => + ({ + ...host, + pluginSettings: { "ssh-terminal": values }, + }) as unknown as Host; + + it("anchors the toolbar where the host asks", () => { + const { container } = renderToolbar({ + host: withSettings({ terminalToolbarPosition: "top-right" }), + }); + const toolbarHost = container.querySelector( + "[data-terminal-toolbar-host]", + ); + expect(toolbarHost).toHaveClass("items-start", "justify-end", "pt-2"); + expect(toolbarHost).not.toHaveClass("items-end", "justify-center"); + }); + + it("keeps a separate drag offset per anchor", () => { + localStorage.setItem( + "termix-terminal-toolbar-position-v2", + JSON.stringify({ x: 0, y: -40 }), + ); + const { container } = renderToolbar({ + host: withSettings({ terminalToolbarPosition: "top-left" }), + }); + expect( + container.querySelector("[data-terminal-toolbar-wide]"), + ).toHaveStyle({ transform: "translate(0px, 0px)" }); + }); + + it("starts minimized when the host asks", () => { + renderToolbar({ + host: withSettings({ terminalToolbarStartState: "collapsed" }), + }); + expect( + screen.getByRole("button", { name: "Expand toolbar" }), + ).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Hide toolbar" })).toBeNull(); + }); + + it("uses the host display mode without overwriting the saved one", () => { + localStorage.setItem("termix-terminal-toolbar-density", "labeled"); + renderToolbar({ host: withSettings({ terminalToolbarDisplay: "icon" }) }); + expect(screen.queryByText("Upload image")).toBeNull(); + expect(localStorage.getItem("termix-terminal-toolbar-density")).toBe( + "labeled", + ); + }); + + it("stays fully visible when fading is off", () => { + const { container } = renderToolbar({ + host: withSettings({ terminalToolbarFade: false }), + }); + const toolbar = container.querySelector("[data-terminal-toolbar-wide]"); + expect(toolbar).toHaveClass("opacity-100"); + expect(toolbar).not.toHaveClass("opacity-30"); + }); + }); }); diff --git a/plugins/ssh-terminal/tests/frontend/terminal/toolbar-settings.test.ts b/plugins/ssh-terminal/tests/frontend/terminal/toolbar-settings.test.ts new file mode 100644 index 000000000..9ecf93dd4 --- /dev/null +++ b/plugins/ssh-terminal/tests/frontend/terminal/toolbar-settings.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_TOOLBAR_SETTINGS, + readToolbarSettings, + toolbarAnchorClasses, +} from "../../../src/frontend/terminal/toolbar-settings"; +import { toolbarPositionStorageKey } from "../../../src/frontend/terminal/toolbar-geometry"; + +const hostWith = (values: Record) => ({ + pluginSettings: { "ssh-terminal": values }, +}); + +describe("readToolbarSettings", () => { + it("falls back to defaults for a host without settings", () => { + expect(readToolbarSettings({})).toEqual(DEFAULT_TOOLBAR_SETTINGS); + expect(readToolbarSettings(null)).toEqual(DEFAULT_TOOLBAR_SETTINGS); + }); + + it("reads saved values", () => { + expect( + readToolbarSettings( + hostWith({ + terminalToolbarPosition: "top-left", + terminalToolbarStartState: "collapsed", + terminalToolbarDisplay: "expanded", + terminalToolbarShowStatus: false, + terminalToolbarFade: false, + }), + ), + ).toEqual({ + anchor: "top-left", + startCollapsed: true, + density: "expanded", + showStatus: false, + fadeWhenIdle: false, + }); + }); + + it("ignores unknown values", () => { + const settings = readToolbarSettings( + hostWith({ + terminalToolbarPosition: "middle", + terminalToolbarDisplay: "remember", + terminalToolbarShowStatus: "no", + }), + ); + expect(settings.anchor).toBe("bottom"); + expect(settings.density).toBeNull(); + expect(settings.showStatus).toBe(true); + }); +}); + +describe("toolbar anchors", () => { + it("maps anchors to flex placement", () => { + expect(toolbarAnchorClasses("bottom")).toBe( + "items-end pb-2 justify-center", + ); + expect(toolbarAnchorClasses("top-left")).toBe( + "items-start pt-2 justify-start pl-2", + ); + expect(toolbarAnchorClasses("bottom-right")).toBe( + "items-end pb-2 justify-end pr-2", + ); + }); + + it("keeps the original storage key for the bottom anchor", () => { + expect(toolbarPositionStorageKey()).toBe( + "termix-terminal-toolbar-position-v2", + ); + expect(toolbarPositionStorageKey("top")).toBe( + "termix-terminal-toolbar-position-v2-top", + ); + }); +});