feat: improve terminal stability and split out the host manager

This commit is contained in:
LukeGus
2025-12-26 01:17:12 -06:00
parent 850645843e
commit 7ff6559cdb
17 changed files with 74 additions and 100 deletions

View File

@@ -9,7 +9,7 @@ import {
} from "@/ui/desktop/navigation/tabs/TabContext.tsx"; } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx"; import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx";
import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx"; import { CommandHistoryProvider } from "@/ui/desktop/apps/features/terminal/command-history/CommandHistoryContext.tsx";
import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx"; import { AdminSettings } from "@/ui/desktop/apps/admin/AdminSettings.tsx";
import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx"; import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx";
import { Toaster } from "@/components/ui/sonner.tsx"; import { Toaster } from "@/components/ui/sonner.tsx";
import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx"; import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx";

View File

@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { useSidebar } from "@/components/ui/sidebar"; import { useSidebar } from "@/components/ui/sidebar.tsx";
import { Separator } from "@/components/ui/separator.tsx"; import { Separator } from "@/components/ui/separator.tsx";
import { import {
Tabs, Tabs,
@@ -21,12 +21,12 @@ import {
getSessions, getSessions,
unlinkOIDCFromPasswordAccount, unlinkOIDCFromPasswordAccount,
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { RolesTab } from "./widgets/RolesTab.tsx"; import { RolesTab } from "@/ui/desktop/apps/admin/tabs/RolesTab.tsx";
import { GeneralSettingsTab } from "./widgets/GeneralSettingsTab.tsx"; import { GeneralSettingsTab } from "@/ui/desktop/apps/admin/tabs/GeneralSettingsTab.tsx";
import { OIDCSettingsTab } from "./widgets/OIDCSettingsTab.tsx"; import { OIDCSettingsTab } from "@/ui/desktop/apps/admin/tabs/OIDCSettingsTab.tsx";
import { UserManagementTab } from "./widgets/UserManagementTab.tsx"; import { UserManagementTab } from "@/ui/desktop/apps/admin/tabs/UserManagementTab.tsx";
import { SessionManagementTab } from "./widgets/SessionManagementTab.tsx"; import { SessionManagementTab } from "@/ui/desktop/apps/admin/tabs/SessionManagementTab.tsx";
import { DatabaseSecurityTab } from "./widgets/DatabaseSecurityTab.tsx"; import { DatabaseSecurityTab } from "@/ui/desktop/apps/admin/tabs/DatabaseSecurityTab.tsx";
import { CreateUserDialog } from "./dialogs/CreateUserDialog.tsx"; import { CreateUserDialog } from "./dialogs/CreateUserDialog.tsx";
import { UserEditDialog } from "./dialogs/UserEditDialog.tsx"; import { UserEditDialog } from "./dialogs/UserEditDialog.tsx";
import { LinkAccountDialog } from "./dialogs/LinkAccountDialog.tsx"; import { LinkAccountDialog } from "./dialogs/LinkAccountDialog.tsx";

View File

@@ -123,8 +123,6 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const resizeTimeout = useRef<NodeJS.Timeout | null>(null); const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
const wasDisconnectedBySSH = useRef(false); const wasDisconnectedBySSH = useRef(false);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null); const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [visible, setVisible] = useState(false);
const [isReady, setIsReady] = useState(false);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false); const [isConnecting, setIsConnecting] = useState(false);
const [isFitted, setIsFitted] = useState(true); const [isFitted, setIsFitted] = useState(true);
@@ -342,7 +340,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
if ( if (
!fitAddonRef.current || !fitAddonRef.current ||
!terminal || !terminal ||
!isVisibleRef.current || !isVisible ||
isFittingRef.current isFittingRef.current
) { ) {
return; return;
@@ -1144,6 +1142,15 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal.open(xtermRef.current); terminal.open(xtermRef.current);
// Immediately fit to establish correct dimensions
fitAddonRef.current?.fit();
if (terminal.cols < 10 || terminal.rows < 3) {
// Terminal opened with invalid dimensions, retry fit in next frame
requestAnimationFrame(() => {
fitAddonRef.current?.fit();
});
}
const element = xtermRef.current; const element = xtermRef.current;
const handleContextMenu = async (e: MouseEvent) => { const handleContextMenu = async (e: MouseEvent) => {
if (!getUseRightClickCopyPaste()) return; if (!getUseRightClickCopyPaste()) return;
@@ -1225,22 +1232,19 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
if (resizeTimeout.current) clearTimeout(resizeTimeout.current); if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
resizeTimeout.current = setTimeout(() => { resizeTimeout.current = setTimeout(() => {
if (!isVisibleRef.current || !isReady) return; if (isVisible && terminal?.cols > 0) {
performFit(); performFit();
}
}, 50); }, 50);
}); });
resizeObserver.observe(xtermRef.current); resizeObserver.observe(xtermRef.current);
setVisible(true);
return () => { return () => {
isUnmountingRef.current = true; isUnmountingRef.current = true;
shouldNotReconnectRef.current = true; shouldNotReconnectRef.current = true;
isReconnectingRef.current = false; isReconnectingRef.current = false;
setIsConnecting(false); setIsConnecting(false);
setVisible(false);
setIsReady(false);
isFittingRef.current = false; isFittingRef.current = false;
resizeObserver.disconnect(); resizeObserver.disconnect();
element?.removeEventListener("contextmenu", handleContextMenu); element?.removeEventListener("contextmenu", handleContextMenu);
@@ -1444,75 +1448,48 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
terminal.attachCustomKeyEventHandler(handleCustomKey); terminal.attachCustomKeyEventHandler(handleCustomKey);
}, [terminal]); }, [terminal]);
// Connection initialization effect
useEffect(() => { useEffect(() => {
if (!terminal || !hostConfig || !visible) return; if (!terminal || !hostConfig || !isVisible) return;
if (isConnected || isConnecting) return; if (isConnected || isConnecting) return;
setIsConnecting(true); // Ensure terminal has valid dimensions before connecting
if (terminal.cols < 10 || terminal.rows < 3) {
// Start connection immediately without waiting for fonts // Wait for next frame when dimensions will be valid
requestAnimationFrame(() => { requestAnimationFrame(() => {
fitAddonRef.current?.fit(); if (terminal.cols > 0 && terminal.rows > 0) {
if (terminal && terminal.cols > 0 && terminal.rows > 0) { setIsConnecting(true);
scheduleNotify(terminal.cols, terminal.rows); fitAddonRef.current?.fit();
} scheduleNotify(terminal.cols, terminal.rows);
hardRefresh(); connectToHost(terminal.cols, terminal.rows);
}
setVisible(true); });
setIsReady(true);
if (terminal && !splitScreen) {
terminal.focus();
}
const jwtToken = getCookie("jwt");
if (!jwtToken || jwtToken.trim() === "") {
setIsConnected(false);
setIsConnecting(false);
setConnectionError("Authentication required");
return;
}
const cols = terminal.cols;
const rows = terminal.rows;
connectToHost(cols, rows);
});
}, [terminal, hostConfig, visible, isConnected, isConnecting, splitScreen]);
useEffect(() => {
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
return; return;
} }
let rafId: number; setIsConnecting(true);
fitAddonRef.current?.fit();
rafId = requestAnimationFrame(() => { if (terminal.cols > 0 && terminal.rows > 0) {
performFit(); scheduleNotify(terminal.cols, terminal.rows);
}); connectToHost(terminal.cols, terminal.rows);
return () => {
if (rafId) cancelAnimationFrame(rafId);
};
}, [isVisible, isReady, splitScreen, terminal]);
useEffect(() => {
if (
isFitted &&
isVisible &&
isReady &&
!isConnecting &&
terminal &&
!splitScreen
) {
const rafId = requestAnimationFrame(() => {
terminal.focus();
});
return () => cancelAnimationFrame(rafId);
} }
}, [isFitted, isVisible, isReady, isConnecting, terminal, splitScreen]); }, [terminal, hostConfig, isVisible, isConnected, isConnecting]);
// Consolidated fitting and focus effect
useEffect(() => {
if (!terminal || !fitAddonRef.current || !isVisible) return;
const fitTimeoutId = setTimeout(() => {
if (!isFittingRef.current && terminal.cols > 0 && terminal.rows > 0) {
performFit();
if (!splitScreen && !isConnecting) {
requestAnimationFrame(() => terminal.focus());
}
}
}, 0);
return () => clearTimeout(fitTimeoutId);
}, [terminal, isVisible, splitScreen, isConnecting]);
return ( return (
<div className="h-full w-full relative" style={{ backgroundColor }}> <div className="h-full w-full relative" style={{ backgroundColor }}>
@@ -1520,8 +1497,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
ref={xtermRef} ref={xtermRef}
className="h-full w-full" className="h-full w-full"
style={{ style={{
visibility: isReady ? "visible" : "hidden", pointerEvents: isVisible ? "auto" : "none",
pointerEvents: isReady ? "auto" : "none",
}} }}
onClick={() => { onClick={() => {
if (terminal && !splitScreen) { if (terminal && !splitScreen) {

View File

@@ -38,7 +38,7 @@ import {
} from "@/ui/main-axios.ts"; } from "@/ui/main-axios.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx"; import { CredentialSelector } from "@/ui/desktop/apps/host-manager/credentials/CredentialSelector.tsx";
import { HostSharingTab } from "./HostSharingTab.tsx"; import { HostSharingTab } from "./tabs/HostSharingTab.tsx";
import CodeMirror from "@uiw/react-codemirror"; import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@codemirror/theme-one-dark"; import { oneDark } from "@codemirror/theme-one-dark";
import { githubLight } from "@uiw/codemirror-theme-github"; import { githubLight } from "@uiw/codemirror-theme-github";

View File

@@ -70,7 +70,7 @@ import type {
SSHManagerHostViewerProps, SSHManagerHostViewerProps,
} from "../../../../../types"; } from "../../../../../types";
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets.ts";
import { FolderEditDialog } from "../components/FolderEditDialog.tsx"; import { FolderEditDialog } from "@/ui/desktop/apps/host-manager/dialogs/FolderEditDialog.tsx";
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {

View File

@@ -101,13 +101,18 @@ export function AppView({
const splitIds = allSplitScreenTab as number[]; const splitIds = allSplitScreenTab as number[];
visibleIds.push(currentTab, ...splitIds.filter((i) => i !== currentTab)); visibleIds.push(currentTab, ...splitIds.filter((i) => i !== currentTab));
} }
terminalTabs.forEach((t: TabData) => {
if (visibleIds.includes(t.id)) { const operations = terminalTabs
const ref = t.terminalRef?.current; .filter((t: TabData) => visibleIds.includes(t.id))
if (ref?.fit) ref.fit(); .map((t: TabData) => t.terminalRef?.current)
if (ref?.notifyResize) ref.notifyResize(); .filter((ref) => ref?.fit);
if (ref?.refresh) ref.refresh();
} requestAnimationFrame(() => {
operations.forEach((ref) => {
ref.fit?.();
ref.notifyResize?.();
ref.refresh?.();
});
}); });
}, [allSplitScreenTab, currentTab, terminalTabs]); }, [allSplitScreenTab, currentTab, terminalTabs]);
@@ -117,18 +122,14 @@ export function AppView({
cancelAnimationFrame(layoutScheduleRef.current); cancelAnimationFrame(layoutScheduleRef.current);
layoutScheduleRef.current = requestAnimationFrame(() => { layoutScheduleRef.current = requestAnimationFrame(() => {
updatePanelRects(); updatePanelRects();
layoutScheduleRef.current = requestAnimationFrame(() => { fitActiveAndNotify();
fitActiveAndNotify();
});
}); });
}, [updatePanelRects, fitActiveAndNotify]); }, [updatePanelRects, fitActiveAndNotify]);
const hideThenFit = React.useCallback(() => { const hideThenFit = React.useCallback(() => {
requestAnimationFrame(() => { requestAnimationFrame(() => {
updatePanelRects(); updatePanelRects();
requestAnimationFrame(() => { fitActiveAndNotify();
fitActiveAndNotify();
});
}); });
}, [updatePanelRects, fitActiveAndNotify]); }, [updatePanelRects, fitActiveAndNotify]);
@@ -237,7 +238,6 @@ export function AppView({
display: "block" as const, display: "block" as const,
pointerEvents: "auto" as const, pointerEvents: "auto" as const,
opacity: 1, opacity: 1,
transition: "opacity 150ms ease-in-out",
}; };
styles[mainTab.id] = newStyle; styles[mainTab.id] = newStyle;
previousStylesRef.current[mainTab.id] = newStyle; previousStylesRef.current[mainTab.id] = newStyle;
@@ -256,7 +256,6 @@ export function AppView({
display: "block" as const, display: "block" as const,
pointerEvents: "auto" as const, pointerEvents: "auto" as const,
opacity: 1, opacity: 1,
transition: "opacity 150ms ease-in-out",
}; };
styles[t.id] = newStyle; styles[t.id] = newStyle;
previousStylesRef.current[t.id] = newStyle; previousStylesRef.current[t.id] = newStyle;
@@ -298,7 +297,6 @@ export function AppView({
pointerEvents: "auto", pointerEvents: "auto",
zIndex: 20, zIndex: 20,
display: "block", display: "block",
transition: "opacity 150ms ease-in-out",
overflow: "hidden", overflow: "hidden",
} }
: ({ : ({
@@ -306,7 +304,7 @@ export function AppView({
opacity: 0, opacity: 0,
pointerEvents: "none", pointerEvents: "none",
zIndex: 0, zIndex: 0,
transition: "opacity 150ms ease-in-out", display: "none",
overflow: "hidden", overflow: "hidden",
} as React.CSSProperties); } as React.CSSProperties);