mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +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
@@ -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;
|
||||
|
||||
|
||||
@@ -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<TerminalToolbarProps> = ({
|
||||
if (action.run) action.run(record, tabs as unknown as ShellApi);
|
||||
else if (action.tabType) tabs.openTab(record, action.tabType);
|
||||
};
|
||||
const [density, setDensity] =
|
||||
useState<SelectedToolbarDensity>(readStoredDensity);
|
||||
const settings = useMemo(() => readToolbarSettings(host), [host]);
|
||||
const { anchor } = settings;
|
||||
const rememberDensity = settings.density === null;
|
||||
const [density, setDensity] = useState<SelectedToolbarDensity>(
|
||||
() => settings.density ?? readStoredDensity(),
|
||||
);
|
||||
const [responsiveDensity, setResponsiveDensity] =
|
||||
useState<SelectedToolbarDensity>(readStoredDensity);
|
||||
const [position, setPosition] = useState<ToolbarPosition>(
|
||||
readStoredToolbarPosition,
|
||||
useState<SelectedToolbarDensity>(() => density);
|
||||
const [position, setPosition] = useState<ToolbarPosition>(() =>
|
||||
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<string | null>(null);
|
||||
const [retryImageAction, setRetryImageAction] = useState<(() => void) | null>(
|
||||
@@ -160,7 +169,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const hideToolbarRef = useRef<HTMLButtonElement>(null);
|
||||
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 imageGenerationRef = useRef(0);
|
||||
const hostRef = useRef<HTMLDivElement>(null);
|
||||
@@ -182,7 +192,9 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
? 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<TerminalToolbarProps> = ({
|
||||
}, [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<TerminalToolbarProps> = ({
|
||||
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<TerminalToolbarProps> = ({
|
||||
) {
|
||||
positionRef.current = next;
|
||||
setPosition(next);
|
||||
persistToolbarPosition(next);
|
||||
persistToolbarPosition(next, anchor);
|
||||
}
|
||||
}
|
||||
if (!collapsed && pendingExpansionFocusRef.current) {
|
||||
@@ -321,6 +347,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
hideToolbarRef.current?.focus();
|
||||
}
|
||||
}, [
|
||||
anchor,
|
||||
collapsed,
|
||||
density,
|
||||
desktopViewportReady,
|
||||
@@ -392,6 +419,11 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
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<HTMLButtonElement>,
|
||||
) => {
|
||||
@@ -436,7 +468,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
) => {
|
||||
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<TerminalToolbarProps> = ({
|
||||
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<TerminalToolbarProps> = ({
|
||||
<div
|
||||
ref={hostRef}
|
||||
data-terminal-toolbar-host
|
||||
data-anchor={anchor}
|
||||
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
|
||||
@@ -650,7 +683,9 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
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<TerminalToolbarProps> = ({
|
||||
aria-label={t("terminalToolbar.hideToolbar")}
|
||||
title={t("terminalToolbar.hideToolbar")}
|
||||
onClick={() => {
|
||||
pendingRightEdgeRef.current =
|
||||
toolbarRef.current?.getBoundingClientRect().right ?? null;
|
||||
pendingEdgeRef.current = anchoredEdge();
|
||||
setCollapsed(true);
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user