mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-31 23:55:23 +00:00
feat(ssh-terminal): add toolbar position, start state, display and fade host settings
This commit is contained in:
1 parent
1fd89acd90
commit
f0d720cf05
8 files changed
+418
-28
No files matched your search
@@ -198,6 +198,38 @@
|
|||||||
"label": "Enable Terminal Toolbar",
|
"label": "Enable Terminal Toolbar",
|
||||||
"description": "Show a quick-access toolbar docked to the active terminal for this host's tools and live stats"
|
"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": {
|
"enableCommandHistory": {
|
||||||
"label": "Command History",
|
"label": "Command History",
|
||||||
"description": "Record commands run in this terminal for history and autocomplete"
|
"description": "Record commands run in this terminal for history and autocomplete"
|
||||||
|
|||||||
@@ -230,6 +230,100 @@
|
|||||||
"descriptionKey": "settings.host.enableTerminalToolbar.description",
|
"descriptionKey": "settings.host.enableTerminalToolbar.description",
|
||||||
"default": true
|
"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",
|
"key": "enableCommandHistory",
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export const ADMIN_KEYS = {
|
|||||||
export const HOST_KEYS = {
|
export const HOST_KEYS = {
|
||||||
enableTerminal: "enableTerminal",
|
enableTerminal: "enableTerminal",
|
||||||
enableTerminalToolbar: "enableTerminalToolbar",
|
enableTerminalToolbar: "enableTerminalToolbar",
|
||||||
|
terminalToolbarPosition: "terminalToolbarPosition",
|
||||||
|
terminalToolbarStartState: "terminalToolbarStartState",
|
||||||
|
terminalToolbarDisplay: "terminalToolbarDisplay",
|
||||||
|
terminalToolbarShowStatus: "terminalToolbarShowStatus",
|
||||||
|
terminalToolbarFade: "terminalToolbarFade",
|
||||||
enableCommandHistory: "enableCommandHistory",
|
enableCommandHistory: "enableCommandHistory",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ import {
|
|||||||
type ToolbarPosition,
|
type ToolbarPosition,
|
||||||
type ToolbarDensity,
|
type ToolbarDensity,
|
||||||
} from "./toolbar-geometry";
|
} from "./toolbar-geometry";
|
||||||
|
import {
|
||||||
|
isLeftAnchor,
|
||||||
|
readToolbarSettings,
|
||||||
|
toolbarAnchorClasses,
|
||||||
|
} from "./toolbar-settings";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -143,15 +148,19 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
if (action.run) action.run(record, tabs as unknown as ShellApi);
|
if (action.run) action.run(record, tabs as unknown as ShellApi);
|
||||||
else if (action.tabType) tabs.openTab(record, action.tabType);
|
else if (action.tabType) tabs.openTab(record, action.tabType);
|
||||||
};
|
};
|
||||||
const [density, setDensity] =
|
const settings = useMemo(() => readToolbarSettings(host), [host]);
|
||||||
useState<SelectedToolbarDensity>(readStoredDensity);
|
const { anchor } = settings;
|
||||||
|
const rememberDensity = settings.density === null;
|
||||||
|
const [density, setDensity] = useState<SelectedToolbarDensity>(
|
||||||
|
() => settings.density ?? readStoredDensity(),
|
||||||
|
);
|
||||||
const [responsiveDensity, setResponsiveDensity] =
|
const [responsiveDensity, setResponsiveDensity] =
|
||||||
useState<SelectedToolbarDensity>(readStoredDensity);
|
useState<SelectedToolbarDensity>(() => density);
|
||||||
const [position, setPosition] = useState<ToolbarPosition>(
|
const [position, setPosition] = useState<ToolbarPosition>(() =>
|
||||||
readStoredToolbarPosition,
|
readStoredToolbarPosition(anchor),
|
||||||
);
|
);
|
||||||
const positionRef = useRef(position);
|
const positionRef = useRef(position);
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(settings.startCollapsed);
|
||||||
const [densityOpen, setDensityOpen] = useState(false);
|
const [densityOpen, setDensityOpen] = useState(false);
|
||||||
const [imageError, setImageError] = useState<string | null>(null);
|
const [imageError, setImageError] = useState<string | null>(null);
|
||||||
const [retryImageAction, setRetryImageAction] = useState<(() => void) | null>(
|
const [retryImageAction, setRetryImageAction] = useState<(() => void) | null>(
|
||||||
@@ -160,7 +169,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const hideToolbarRef = useRef<HTMLButtonElement>(null);
|
const hideToolbarRef = useRef<HTMLButtonElement>(null);
|
||||||
const pendingExpansionFocusRef = useRef(false);
|
const pendingExpansionFocusRef = useRef(false);
|
||||||
const pendingRightEdgeRef = useRef<number | null>(null);
|
// Keeps the anchored edge still when the toolbar grows or shrinks.
|
||||||
|
const pendingEdgeRef = useRef<number | null>(null);
|
||||||
const mountedRef = useRef(false);
|
const mountedRef = useRef(false);
|
||||||
const imageGenerationRef = useRef(0);
|
const imageGenerationRef = useRef(0);
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -182,7 +192,9 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
? responsiveDensity
|
? responsiveDensity
|
||||||
: "icon";
|
: "icon";
|
||||||
const showStats =
|
const showStats =
|
||||||
effectiveDensity === "expanded" && statusContributions.length > 0;
|
settings.showStatus &&
|
||||||
|
effectiveDensity === "expanded" &&
|
||||||
|
statusContributions.length > 0;
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -196,23 +208,35 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
}, [isMobile]);
|
}, [isMobile]);
|
||||||
|
|
||||||
useEffect(() => {
|
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;
|
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(DENSITY_STORAGE_KEY, density);
|
window.localStorage.setItem(DENSITY_STORAGE_KEY, density);
|
||||||
} catch {
|
} catch {
|
||||||
// Storage can be unavailable in hardened browser contexts.
|
// 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
|
// Picking an interface preset seeds this key, so pick the new value up
|
||||||
// without needing the tab to remount.
|
// without needing the tab to remount.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!rememberDensity) return;
|
||||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||||
const handler = () => setDensity(readStoredDensity());
|
const handler = () => setDensity(readStoredDensity());
|
||||||
window.addEventListener("terminalToolbarDensityChanged", handler);
|
window.addEventListener("terminalToolbarDensityChanged", handler);
|
||||||
return () =>
|
return () =>
|
||||||
window.removeEventListener("terminalToolbarDensityChanged", handler);
|
window.removeEventListener("terminalToolbarDensityChanged", handler);
|
||||||
}, [desktopViewportReady, isMobile]);
|
}, [desktopViewportReady, isMobile, rememberDensity]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (desktopViewportReady !== true || isMobile !== false) return;
|
if (desktopViewportReady !== true || isMobile !== false) return;
|
||||||
const measurement = measurementRef.current;
|
const measurement = measurementRef.current;
|
||||||
@@ -288,19 +312,21 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
if (extremeOffset) {
|
if (extremeOffset) {
|
||||||
positionRef.current = { x: 0, y: 0 };
|
positionRef.current = { x: 0, y: 0 };
|
||||||
setPosition({ x: 0, y: 0 });
|
setPosition({ x: 0, y: 0 });
|
||||||
persistToolbarPosition({ x: 0, y: 0 });
|
persistToolbarPosition({ x: 0, y: 0 }, anchor);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (toolbarRect.width > 0 && toolbarRect.height > 0) {
|
if (toolbarRect.width > 0 && toolbarRect.height > 0) {
|
||||||
const pendingRightEdge = pendingRightEdgeRef.current;
|
const pendingEdge = pendingEdgeRef.current;
|
||||||
pendingRightEdgeRef.current = null;
|
pendingEdgeRef.current = null;
|
||||||
const baseRight = toolbarRect.right - positionRef.current.x;
|
const baseEdge =
|
||||||
|
(isLeftAnchor(anchor) ? toolbarRect.left : toolbarRect.right) -
|
||||||
|
positionRef.current.x;
|
||||||
const next = clampToolbarPosition(
|
const next = clampToolbarPosition(
|
||||||
{
|
{
|
||||||
x:
|
x:
|
||||||
pendingRightEdge == null
|
pendingEdge == null
|
||||||
? positionRef.current.x
|
? positionRef.current.x
|
||||||
: pendingRightEdge - baseRight,
|
: pendingEdge - baseEdge,
|
||||||
y: positionRef.current.y,
|
y: positionRef.current.y,
|
||||||
},
|
},
|
||||||
toolbarRect,
|
toolbarRect,
|
||||||
@@ -313,7 +339,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
) {
|
) {
|
||||||
positionRef.current = next;
|
positionRef.current = next;
|
||||||
setPosition(next);
|
setPosition(next);
|
||||||
persistToolbarPosition(next);
|
persistToolbarPosition(next, anchor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!collapsed && pendingExpansionFocusRef.current) {
|
if (!collapsed && pendingExpansionFocusRef.current) {
|
||||||
@@ -321,6 +347,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
hideToolbarRef.current?.focus();
|
hideToolbarRef.current?.focus();
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
anchor,
|
||||||
collapsed,
|
collapsed,
|
||||||
density,
|
density,
|
||||||
desktopViewportReady,
|
desktopViewportReady,
|
||||||
@@ -392,6 +419,11 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
reportImageResult(onPasteImage, pasteImage);
|
reportImageResult(onPasteImage, pasteImage);
|
||||||
};
|
};
|
||||||
const chooseFile = () => fileInputRef.current?.click();
|
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 = (
|
const handleGrabPointerDown = (
|
||||||
event: React.PointerEvent<HTMLButtonElement>,
|
event: React.PointerEvent<HTMLButtonElement>,
|
||||||
) => {
|
) => {
|
||||||
@@ -436,7 +468,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
) => {
|
) => {
|
||||||
const drag = dragRef.current;
|
const drag = dragRef.current;
|
||||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||||
persistToolbarPosition(positionRef.current);
|
persistToolbarPosition(positionRef.current, anchor);
|
||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
if (capturedPointerIdRef.current === event.pointerId) {
|
if (capturedPointerIdRef.current === event.pointerId) {
|
||||||
capturedPointerIdRef.current = null;
|
capturedPointerIdRef.current = null;
|
||||||
@@ -517,8 +549,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
aria-label={t("terminalToolbar.showToolbar")}
|
aria-label={t("terminalToolbar.showToolbar")}
|
||||||
title={t("terminalToolbar.showToolbar")}
|
title={t("terminalToolbar.showToolbar")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
pendingRightEdgeRef.current =
|
pendingEdgeRef.current = anchoredEdge();
|
||||||
toolbarRef.current?.getBoundingClientRect().right ?? null;
|
|
||||||
pendingExpansionFocusRef.current = true;
|
pendingExpansionFocusRef.current = true;
|
||||||
setCollapsed(false);
|
setCollapsed(false);
|
||||||
}}
|
}}
|
||||||
@@ -548,8 +579,10 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={hostRef}
|
ref={hostRef}
|
||||||
data-terminal-toolbar-host
|
data-terminal-toolbar-host
|
||||||
|
data-anchor={anchor}
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-none @container absolute inset-0 z-[110] flex items-end justify-center pb-2",
|
"pointer-events-none @container absolute inset-0 z-[110] flex",
|
||||||
|
toolbarAnchorClasses(anchor),
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
@@ -650,7 +683,9 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
data-terminal-toolbar-wide
|
data-terminal-toolbar-wide
|
||||||
className={cn(
|
className={cn(
|
||||||
"pointer-events-auto transition-opacity duration-300 hover:duration-0 focus-within:duration-0 hover:opacity-100 focus-within:opacity-100",
|
"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={{
|
style={{
|
||||||
transform: `translate(${position.x}px, ${position.y}px)`,
|
transform: `translate(${position.x}px, ${position.y}px)`,
|
||||||
@@ -741,8 +776,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
|||||||
aria-label={t("terminalToolbar.hideToolbar")}
|
aria-label={t("terminalToolbar.hideToolbar")}
|
||||||
title={t("terminalToolbar.hideToolbar")}
|
title={t("terminalToolbar.hideToolbar")}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
pendingRightEdgeRef.current =
|
pendingEdgeRef.current = anchoredEdge();
|
||||||
toolbarRef.current?.getBoundingClientRect().right ?? null;
|
|
||||||
setCollapsed(true);
|
setCollapsed(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -53,12 +53,22 @@ function persistPosition(storageKey: string, position: ToolbarPosition): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readStoredToolbarPosition(): ToolbarPosition {
|
/** Each anchor keeps its own drag offset; bottom keeps the original key. */
|
||||||
return readStoredPosition(TOOLBAR_POSITION_STORAGE_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 {
|
export function readStoredToolbarPosition(anchor?: string): ToolbarPosition {
|
||||||
persistPosition(TOOLBAR_POSITION_STORAGE_KEY, position);
|
return readStoredPosition(toolbarPositionStorageKey(anchor));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistToolbarPosition(
|
||||||
|
position: ToolbarPosition,
|
||||||
|
anchor?: string,
|
||||||
|
): void {
|
||||||
|
persistPosition(toolbarPositionStorageKey(anchor), position);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clampToolbarPosition(
|
export function clampToolbarPosition(
|
||||||
|
|||||||
@@ -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<string, Record<string, unknown>>;
|
||||||
|
} | 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}`;
|
||||||
|
}
|
||||||
@@ -976,4 +976,63 @@ describe("TerminalToolbar Phase 1", () => {
|
|||||||
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
describe("host toolbar settings", () => {
|
||||||
|
const withSettings = (values: Record<string, unknown>) =>
|
||||||
|
({
|
||||||
|
...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");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -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<string, unknown>) => ({
|
||||||
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user