feat: terminal theme improvemnts, file manager improvements, host manager improvements (ready for initial user testing?)

This commit is contained in:
LukeGus
2026-05-20 14:55:41 -05:00
parent 9f0aaa16a5
commit bc713aca7d
14 changed files with 406 additions and 451 deletions
+2 -2
View File
@@ -739,7 +739,7 @@ router.post(
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
enableSsh: enableSsh !== false ? 1 : 0,
enableSsh: enableSsh ? 1 : 0,
enableRdp: enableRdp ? 1 : 0,
enableVnc: enableVnc ? 1 : 0,
enableTelnet: enableTelnet ? 1 : 0,
@@ -1274,7 +1274,7 @@ router.put(
portKnockSequence: portKnockSequence
? JSON.stringify(portKnockSequence)
: null,
enableSsh: enableSsh !== false ? 1 : 0,
enableSsh: enableSsh ? 1 : 0,
enableRdp: enableRdp ? 1 : 0,
enableVnc: enableVnc ? 1 : 0,
enableTelnet: enableTelnet ? 1 : 0,
+2
View File
@@ -14,6 +14,8 @@ export type Host = {
credentialId?: string;
overrideCredentialUsername?: boolean;
password?: string;
hasPassword?: boolean;
hasKey?: boolean;
key?: string;
keyPassword?: string;
keyType?: string;
+24 -33
View File
@@ -265,6 +265,11 @@ export function AppShell({
loadHosts();
}, [loadHosts]);
useEffect(() => {
window.addEventListener("termix:hosts-changed", loadHosts);
return () => window.removeEventListener("termix:hosts-changed", loadHosts);
}, [loadHosts]);
// Sync tab host data when allHosts updates (e.g. after editing terminal theme in host settings)
useEffect(() => {
if (allHosts.length === 0) return;
@@ -293,37 +298,29 @@ export function AppShell({
// ─── Tab management ──────────────────────────────────────────────────────
function openTab(host: Host, type: TabType) {
const same = tabs.filter(
(t) => t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
);
if (same.length === 0) {
const tabId = `${host.name}-${type}`;
const ref = type === "terminal" ? createRef() : undefined;
if (ref) terminalRefs.current.set(tabId, ref);
const tab = { id: tabId, type, label: host.name, host, terminalRef: ref };
setTabs((prev) => [...prev, tab]);
setActiveTabId(tab.id);
return;
}
const tabId = `${host.name}-${type}-${Date.now()}`;
const ref = type === "terminal" ? createRef() : undefined;
if (ref) terminalRefs.current.set(tabId, ref);
const tab = {
id: tabId,
type,
label: `${host.name} (${same.length + 1})`,
host,
terminalRef: ref,
};
setTabs((prev) => {
const next = prev.map((t) =>
t.id === same[0].id && !/\(\d+\)$/.test(t.label)
? { ...t, label: `${host.name} (1)`, host }
: t,
const same = prev.filter(
(t) =>
t.type === type && t.label.replace(/ \(\d+\)$/, "") === host.name,
);
return [...next, tab];
const label =
same.length === 0 ? host.name : `${host.name} (${same.length + 1})`;
// Retrofit the first duplicate's label to "(1)" if needed
const next =
same.length === 1 && !/\(\d+\)$/.test(same[0].label)
? prev.map((t) =>
t.id === same[0].id ? { ...t, label: `${host.name} (1)` } : t,
)
: prev;
return [...next, { id: tabId, type, label, host, terminalRef: ref }];
});
setActiveTabId(tab.id);
setActiveTabId(tabId);
}
function connectHost(host: Host, preferredType?: TabType) {
@@ -674,11 +671,7 @@ export function AppShell({
) : (
<>
{/* Terminal tabs: always in DOM, absolutely positioned so xterm always has real dimensions */}
{(() => {
const activeTab = tabs.find((t) => t.id === activeTabId);
const nonTerminalActive =
activeTab && activeTab.type !== "terminal";
return tabs
{tabs
.filter((tab) => tab.type === "terminal")
.map((tab) => {
const visible = tab.id === activeTabId;
@@ -687,7 +680,6 @@ export function AppShell({
key={tab.id}
className="absolute inset-0 overflow-hidden"
style={{
display: nonTerminalActive ? "none" : undefined,
visibility: visible ? "visible" : "hidden",
pointerEvents: visible ? "auto" : "none",
zIndex: visible ? 1 : 0,
@@ -702,8 +694,7 @@ export function AppShell({
)}
</div>
);
});
})()}
})}
{/* Non-terminal tabs: absolutely positioned above terminals when active */}
{tabs
.filter((tab) => tab.type !== "terminal")
+5 -3
View File
@@ -37,21 +37,23 @@ function Slider({
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
"bg-input relative grow overflow-hidden rounded-none data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5",
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
"absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full",
)}
style={{ backgroundColor: "var(--accent-brand)" }}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-elevated shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
className="block size-3.5 shrink-0 rounded-none shadow-sm transition-[color,box-shadow] hover:ring-4 hover:ring-[#f59145]/40 focus-visible:ring-4 focus-visible:ring-[#f59145]/40 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
style={{ backgroundColor: "var(--accent-brand)" }}
/>
))}
</SliderPrimitive.Root>
+8 -11
View File
@@ -557,10 +557,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return false;
}
if (isLoading && currentLoadingPathRef.current !== path) {
return false;
}
let resolvedPath = path;
if (path.includes("$") || path.startsWith("~")) {
resolvedPath = await resolveSSHPath(sshSessionId, path);
@@ -711,6 +707,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
const navigateTo = useCallback(
(path: string) => {
if (sshSessionId) setIsLoading(true);
setCurrentPath(path);
setNavHistory((prev) => {
const next = [...prev.slice(0, navIndex + 1), path];
@@ -718,24 +715,26 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
return next;
});
},
[navIndex],
[navIndex, sshSessionId],
);
const goBack = useCallback(() => {
if (navIndex > 0) {
if (sshSessionId) setIsLoading(true);
const newIndex = navIndex - 1;
setNavIndex(newIndex);
setCurrentPath(navHistory[newIndex]);
}
}, [navIndex, navHistory]);
}, [navIndex, navHistory, sshSessionId]);
const goForward = useCallback(() => {
if (navIndex < navHistory.length - 1) {
if (sshSessionId) setIsLoading(true);
const newIndex = navIndex + 1;
setNavIndex(newIndex);
setCurrentPath(navHistory[newIndex]);
}
}, [navIndex, navHistory]);
}, [navIndex, navHistory, sshSessionId]);
const goUp = useCallback(() => {
if (currentPath === "/") return;
@@ -1291,6 +1290,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
async function handleFileOpen(file: FileItem) {
if (file.type === "directory") {
if (sshSessionId) setIsLoading(true);
setCurrentPath(file.path);
} else if (file.type === "link") {
await handleSymlinkClick(file);
@@ -2599,11 +2599,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
variant="ghost"
size="icon"
onClick={handleRefreshDirectory}
disabled={isLoading}
className="size-8 rounded-none"
>
<RefreshCw
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
className={`size-4 ${isLoading && !!sshSessionId ? "animate-spin [animation-duration:0.5s]" : ""}`}
/>
</Button>
</div>
@@ -2878,8 +2877,6 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
onFileOpen={handleFileOpen}
onSelectionChange={setSelection}
currentPath={currentPath}
isLoading={isLoading}
isConnected={!!sshSessionId}
onPathChange={navigateTo}
onRefresh={handleRefreshDirectory}
onUpload={handleFilesDropped}
@@ -21,7 +21,6 @@ import {
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { FileItem } from "@/types/index";
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
interface CreateIntent {
id: string;
@@ -66,8 +65,6 @@ interface FileManagerGridProps {
onFileOpen: (file: FileItem) => void;
onSelectionChange: (files: FileItem[]) => void;
currentPath: string;
isLoading?: boolean;
isConnected?: boolean;
onPathChange: (path: string) => void;
onRefresh: () => void;
onUpload?: (files: FileList) => void;
@@ -191,8 +188,6 @@ export function FileManagerGrid({
onFileOpen,
onSelectionChange,
currentPath,
isLoading,
isConnected,
onPathChange,
onRefresh,
onUpload,
@@ -1240,11 +1235,6 @@ export function FileManagerGrid({
</div>,
document.body,
)}
<SimpleLoader
visible={!!isLoading && !!isConnected}
message={t("common.loading")}
/>
</div>
);
}
+1 -1
View File
@@ -83,7 +83,7 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
.then((status) => {
if (status.guacd.status !== "connected") {
setError(
"Remote desktop service (guacd) is not available. Please ensure guacd is running and accessible.",
"Remote desktop service (guacd) is not available. Please ensure guacd is running and accessible and configured properly in admin settings.",
);
return;
}
+44 -49
View File
@@ -53,6 +53,46 @@ import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
import { toast } from "sonner";
import { Button } from "@/components/button";
// Background/foreground per UI theme for "Termix Default" — must match index.css
const TERMIX_DEFAULT_COLORS: Record<
string,
{ background: string; foreground: string }
> = {
dark: { background: "#0c0d0b", foreground: "#fafafa" },
light: { background: "#ffffff", foreground: "#111210" },
dracula: { background: "#282a36", foreground: "#f8f8f2" },
catppuccin: { background: "#1e1e2e", foreground: "#cdd6f4" },
nord: { background: "#2e3440", foreground: "#eceff4" },
solarized: { background: "#002b36", foreground: "#839496" },
"tokyo-night": { background: "#1a1b26", foreground: "#a9b1d6" },
"one-dark": { background: "#282c34", foreground: "#abb2bf" },
gruvbox: { background: "#282828", foreground: "#ebdbb2" },
};
function resolveTermixThemeColors(activeTheme: string, appTheme: string) {
if (activeTheme !== "termix") {
return (
TERMINAL_THEMES[activeTheme]?.colors || TERMINAL_THEMES.termixDark.colors
);
}
let resolvedUiTheme = appTheme;
if (appTheme === "system") {
resolvedUiTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
const uiColors =
TERMIX_DEFAULT_COLORS[resolvedUiTheme] ?? TERMIX_DEFAULT_COLORS.dark;
const base = TERMINAL_THEMES.termixDark.colors;
return {
...base,
background: uiColors.background,
foreground: uiColors.foreground,
cursor: uiColors.foreground,
cursorAccent: uiColors.background,
};
}
interface HostConfig {
id?: number;
instanceId?: string;
@@ -130,27 +170,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
DEFAULT_TERMINAL_CONFIG.theme,
};
const isDarkMode =
appTheme === "dark" ||
appTheme === "dracula" ||
appTheme === "gentlemansChoice" ||
appTheme === "midnightEspresso" ||
appTheme === "catppuccinMocha" ||
(appTheme === "system" &&
window.matchMedia("(prefers-color-scheme: dark)").matches);
let themeColors;
const activeTheme = previewTheme || config.theme;
if (activeTheme === "termix") {
themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} else {
themeColors =
TERMINAL_THEMES[activeTheme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
}
const themeColors = resolveTermixThemeColors(activeTheme, appTheme);
const backgroundColor = themeColors.background;
const fitAddonRef = useRef<FitAddon | null>(null);
const webSocketRef = useRef<WebSocket | null>(null);
@@ -1828,18 +1849,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
...hostConfig.terminalConfig,
};
let themeColors;
const activeTheme = previewTheme || config.theme;
if (activeTheme === "termix") {
themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} else {
themeColors =
TERMINAL_THEMES[activeTheme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
}
const themeColors = resolveTermixThemeColors(activeTheme, appTheme);
const fontConfig = TERMINAL_FONTS.find(
(f) => f.value === config.fontFamily,
@@ -1896,13 +1907,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
// Refresh terminal to apply new theme colors to existing buffer content
hardRefresh();
}, [
terminal,
hostConfig.terminalConfig,
previewTheme,
isDarkMode,
isFitted,
]);
}, [terminal, hostConfig.terminalConfig, previewTheme, appTheme, isFitted]);
useEffect(() => {
if (!terminal || !xtermRef.current) return;
@@ -1917,18 +1922,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
);
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
let themeColors;
const activeTheme = previewTheme || config.theme;
if (activeTheme === "termix") {
themeColors = isDarkMode
? TERMINAL_THEMES.termixDark.colors
: TERMINAL_THEMES.termixLight.colors;
} else {
themeColors =
TERMINAL_THEMES[activeTheme]?.colors ||
TERMINAL_THEMES.termixDark.colors;
}
const themeColors = resolveTermixThemeColors(activeTheme, appTheme);
// Set initial options before opening the terminal
terminal.options = {
+14
View File
@@ -171,6 +171,7 @@
"upload": "Upload",
"authentication": "Authentication",
"password": "Password",
"passwordSaved": "Password saved, type to change",
"key": "Key",
"credential": "Credential",
"none": "None",
@@ -435,11 +436,24 @@
"copiedToClipboard": "Copied to clipboard",
"terminalUrlCopied": "Terminal URL copied",
"fileManagerUrlCopied": "File Manager URL copied",
"tunnelUrlCopied": "Tunnel URL copied",
"dockerUrlCopied": "Docker URL copied",
"serverStatsUrlCopied": "Server Stats URL copied",
"rdpUrlCopied": "RDP URL copied",
"vncUrlCopied": "VNC URL copied",
"telnetUrlCopied": "Telnet URL copied",
"remoteDesktopUrlCopied": "Remote Desktop URL copied",
"cloneHostAction": "Clone Host",
"copyAddress": "Copy Address",
"copyLink": "Copy Link",
"copyTerminalUrlAction": "Copy Terminal URL",
"copyFileManagerUrlAction": "Copy File Manager URL",
"copyTunnelUrlAction": "Copy Tunnel URL",
"copyDockerUrlAction": "Copy Docker URL",
"copyServerStatsUrlAction": "Copy Server Stats URL",
"copyRdpUrlAction": "Copy RDP URL",
"copyVncUrlAction": "Copy VNC URL",
"copyTelnetUrlAction": "Copy Telnet URL",
"copyRemoteDesktopUrlAction": "Copy Remote Desktop URL",
"deleteCredentialConfirm": "Delete credential \"{{name}}\"?",
"deletedCredential": "Deleted {{name}}",
+6 -173
View File
@@ -1107,102 +1107,18 @@ export async function getSSHHosts(): Promise<SSHHostWithStatus[]> {
export async function createSSHHost(hostData: SSHHostData): Promise<SSHHost> {
try {
const submitData = {
connectionType: hostData.connectionType || "ssh",
name: hostData.name || "",
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
username: hostData.username,
folder: hostData.folder || "",
tags: hostData.tags || [],
pin: Boolean(hostData.pin),
authType: hostData.authType,
password:
hostData.connectionType !== "ssh"
? hostData.password || null
: hostData.authType === "password"
? hostData.password
: null,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword: hostData.authType === "key" ? hostData.keyPassword : null,
keyType: hostData.authType === "key" ? hostData.keyType : null,
credentialId:
hostData.authType === "credential" ? hostData.credentialId : null,
overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername),
enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager),
enableDocker: Boolean(hostData.enableDocker),
showTerminalInSidebar: Boolean(hostData.showTerminalInSidebar),
showFileManagerInSidebar: Boolean(hostData.showFileManagerInSidebar),
showTunnelInSidebar: Boolean(hostData.showTunnelInSidebar),
showDockerInSidebar: Boolean(hostData.showDockerInSidebar),
showServerStatsInSidebar: Boolean(hostData.showServerStatsInSidebar),
defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [],
quickActions: hostData.quickActions || [],
sudoPassword: hostData.sudoPassword || null,
statsConfig: hostData.statsConfig || null,
dockerConfig: hostData.dockerConfig || null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
domain: hostData.domain || null,
security: hostData.security || null,
ignoreCert: Boolean(hostData.ignoreCert),
guacamoleConfig: hostData.guacamoleConfig || null,
notes: hostData.notes || "",
useSocks5: Boolean(hostData.useSocks5),
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
socks5Username: hostData.socks5Username || null,
socks5Password: hostData.socks5Password || null,
socks5ProxyChain: hostData.socks5ProxyChain || null,
macAddress: hostData.macAddress || null,
portKnockSequence: hostData.portKnockSequence || null,
enableSsh: hostData.enableSsh !== false,
enableRdp: Boolean(hostData.enableRdp),
enableVnc: Boolean(hostData.enableVnc),
enableTelnet: Boolean(hostData.enableTelnet),
sshPort: hostData.sshPort || hostData.port || 22,
rdpPort: hostData.rdpPort || 3389,
vncPort: hostData.vncPort || 5900,
telnetPort: hostData.telnetPort || 23,
rdpUser: hostData.rdpUser || null,
rdpPassword: hostData.rdpPassword || null,
rdpDomain: hostData.rdpDomain || null,
rdpSecurity: hostData.rdpSecurity || null,
rdpIgnoreCert: Boolean(hostData.rdpIgnoreCert),
vncPassword: hostData.vncPassword || null,
vncUser: hostData.vncUser || null,
telnetUser: hostData.telnetUser || null,
telnetPassword: hostData.telnetPassword || null,
};
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
if (!submitData.enableFileManager) {
submitData.defaultPath = "";
}
if (hostData.authType === "key" && hostData.key instanceof File) {
const formData = new FormData();
formData.append("key", hostData.key);
const dataWithoutFile = { ...submitData };
delete dataWithoutFile.key;
const dataWithoutFile = { ...hostData, key: undefined };
formData.append("data", JSON.stringify(dataWithoutFile));
const response = await sshHostApi.post("/db/host", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return response.data;
} else {
const response = await sshHostApi.post("/db/host", submitData);
return response.data;
}
const response = await sshHostApi.post("/db/host", hostData);
return response.data;
} catch (error) {
throw handleApiError(error, "create SSH host");
}
@@ -1213,101 +1129,18 @@ export async function updateSSHHost(
hostData: SSHHostData,
): Promise<SSHHost> {
try {
const submitData = {
connectionType: hostData.connectionType || "ssh",
name: hostData.name || "",
ip: hostData.ip,
port: parseInt(hostData.port.toString()) || 22,
username: hostData.username,
folder: hostData.folder || "",
tags: hostData.tags || [],
pin: Boolean(hostData.pin),
authType: hostData.authType,
password:
hostData.connectionType !== "ssh"
? hostData.password || null
: hostData.authType === "password"
? hostData.password
: null,
key: hostData.authType === "key" ? hostData.key : null,
keyPassword: hostData.authType === "key" ? hostData.keyPassword : null,
keyType: hostData.authType === "key" ? hostData.keyType : null,
credentialId:
hostData.authType === "credential" ? hostData.credentialId : null,
overrideCredentialUsername: Boolean(hostData.overrideCredentialUsername),
enableTerminal: Boolean(hostData.enableTerminal),
enableTunnel: Boolean(hostData.enableTunnel),
enableFileManager: Boolean(hostData.enableFileManager),
enableDocker: Boolean(hostData.enableDocker),
showTerminalInSidebar: Boolean(hostData.showTerminalInSidebar),
showFileManagerInSidebar: Boolean(hostData.showFileManagerInSidebar),
showTunnelInSidebar: Boolean(hostData.showTunnelInSidebar),
showDockerInSidebar: Boolean(hostData.showDockerInSidebar),
showServerStatsInSidebar: Boolean(hostData.showServerStatsInSidebar),
defaultPath: hostData.defaultPath || "/",
tunnelConnections: hostData.tunnelConnections || [],
jumpHosts: hostData.jumpHosts || [],
quickActions: hostData.quickActions || [],
sudoPassword: hostData.sudoPassword || null,
statsConfig: hostData.statsConfig || null,
dockerConfig: hostData.dockerConfig || null,
terminalConfig: hostData.terminalConfig || null,
forceKeyboardInteractive: Boolean(hostData.forceKeyboardInteractive),
domain: hostData.domain || null,
security: hostData.security || null,
ignoreCert: Boolean(hostData.ignoreCert),
guacamoleConfig: hostData.guacamoleConfig || null,
notes: hostData.notes || "",
useSocks5: Boolean(hostData.useSocks5),
socks5Host: hostData.socks5Host || null,
socks5Port: hostData.socks5Port || null,
socks5Username: hostData.socks5Username || null,
socks5Password: hostData.socks5Password || null,
socks5ProxyChain: hostData.socks5ProxyChain || null,
macAddress: hostData.macAddress || null,
portKnockSequence: hostData.portKnockSequence || null,
enableSsh: hostData.enableSsh !== false,
enableRdp: Boolean(hostData.enableRdp),
enableVnc: Boolean(hostData.enableVnc),
enableTelnet: Boolean(hostData.enableTelnet),
sshPort: hostData.sshPort || hostData.port || 22,
rdpPort: hostData.rdpPort || 3389,
vncPort: hostData.vncPort || 5900,
telnetPort: hostData.telnetPort || 23,
rdpUser: hostData.rdpUser || null,
rdpPassword: hostData.rdpPassword || null,
rdpDomain: hostData.rdpDomain || null,
rdpSecurity: hostData.rdpSecurity || null,
rdpIgnoreCert: Boolean(hostData.rdpIgnoreCert),
vncPassword: hostData.vncPassword || null,
vncUser: hostData.vncUser || null,
telnetUser: hostData.telnetUser || null,
telnetPassword: hostData.telnetPassword || null,
};
if (!submitData.enableTunnel) {
submitData.tunnelConnections = [];
}
if (!submitData.enableFileManager) {
submitData.defaultPath = "";
}
if (hostData.authType === "key" && hostData.key instanceof File) {
const formData = new FormData();
formData.append("key", hostData.key);
const dataWithoutFile = { ...submitData };
delete dataWithoutFile.key;
const dataWithoutFile = { ...hostData, key: undefined };
formData.append("data", JSON.stringify(dataWithoutFile));
const response = await sshHostApi.put(`/db/host/${hostId}`, formData, {
headers: { "Content-Type": "multipart/form-data" },
});
return response.data;
} else {
const response = await sshHostApi.put(`/db/host/${hostId}`, submitData);
return response.data;
}
const response = await sshHostApi.put(`/db/host/${hostId}`, hostData);
return response.data;
} catch (error) {
throw handleApiError(error, "update SSH host");
}
+41 -14
View File
@@ -23,6 +23,7 @@ import { TunnelTab } from "@/features/tunnel/TunnelTab";
import { NetworkGraphCard } from "@/dashboard/cards/NetworkGraphCard";
import type { Tab, TabType, Host } from "@/types/ui-types";
import type { SSHHost } from "@/types";
import { useTabsSafe } from "@/shell/TabContext";
function hostToSSHHost(h: Host): SSHHost {
return {
@@ -109,6 +110,41 @@ export function tabIcon(type: TabType) {
}
}
function TerminalTabContent({
tab,
host,
label,
isVisible,
onCloseTab,
}: {
tab: Tab;
host: Host;
label: string;
isVisible: boolean;
onCloseTab?: (id: string) => void;
}) {
const { previewTerminalTheme } = useTabsSafe();
return (
<CommandHistoryProvider>
<TerminalFeature
ref={tab.terminalRef as any}
hostConfig={
{
...hostToSSHHost(host),
sshPort: host.sshPort ?? host.port,
} as any
}
isVisible={isVisible}
title={label}
showTitle={false}
splitScreen={false}
onClose={() => onCloseTab?.(tab.id)}
previewTheme={previewTerminalTheme}
/>
</CommandHistoryProvider>
);
}
export function renderTabContent(
tab: Tab,
onOpenSingletonTab?: (type: TabType) => void,
@@ -136,22 +172,13 @@ export function renderTabContent(
/>
);
return (
<CommandHistoryProvider>
<TerminalFeature
ref={tab.terminalRef as any}
hostConfig={
{
...hostToSSHHost(host),
sshPort: host.sshPort ?? host.port,
} as any
}
<TerminalTabContent
tab={tab}
host={host}
label={label}
isVisible={isVisible}
title={label}
showTitle={false}
splitScreen={false}
onClose={() => onCloseTab?.(tab.id)}
onCloseTab={onCloseTab}
/>
</CommandHistoryProvider>
);
case "files":
+162 -63
View File
@@ -14,6 +14,7 @@ import {
} from "@/lib/terminal-themes";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { Slider } from "@/components/slider";
import {
Activity,
@@ -32,6 +33,7 @@ import {
Info,
KeyRound,
LayoutDashboard,
Link,
ListChecks,
Lock,
MoreHorizontal,
@@ -60,6 +62,9 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { toast } from "sonner";
@@ -98,6 +103,7 @@ import {
import type { SSHHostWithStatus } from "@/main-axios";
import type { Host, Credential } from "@/types/ui-types";
import { useTabsSafe } from "@/shell/TabContext";
function sshHostToHost(h: SSHHostWithStatus): Host {
const parseJson = (v: any) => {
@@ -125,6 +131,8 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
tags: h.tags ?? [],
authType: h.authType,
password: h.password,
hasPassword: !!(h as any).hasPassword || !!h.password,
hasKey: !!(h as any).hasKey || !!(typeof h.key === "string" && h.key),
key: typeof h.key === "string" ? h.key : undefined,
keyPassword: h.keyPassword,
keyType: h.keyType,
@@ -132,15 +140,10 @@ function sshHostToHost(h: SSHHostWithStatus): Host {
notes: h.notes,
pin: h.pin ?? false,
macAddress: h.macAddress,
enableSsh:
h.enableSsh != null
? h.enableSsh
: h.connectionType === "ssh" || !h.connectionType,
enableSsh: h.enableSsh != null ? h.enableSsh : h.connectionType === "ssh",
enableTerminal:
h.enableTerminal ??
(h.enableSsh != null
? h.enableSsh
: h.connectionType === "ssh" || !h.connectionType),
(h.enableSsh != null ? h.enableSsh : h.connectionType === "ssh"),
enableTunnel: h.enableTunnel ?? false,
enableFileManager: h.enableFileManager ?? false,
enableDocker: h.enableDocker ?? false,
@@ -539,7 +542,7 @@ function HostRow({
paddingRight: "8px",
}}
>
{host.enableTerminal && (
{host.enableSsh && host.enableTerminal && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -551,7 +554,7 @@ function HostRow({
<span>{t("hosts.terminal")}</span>
</button>
)}
{host.enableFileManager && (
{host.enableSsh && host.enableFileManager && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -563,7 +566,7 @@ function HostRow({
<span>{t("hosts.fileManager")}</span>
</button>
)}
{host.enableDocker && (
{host.enableSsh && host.enableDocker && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -575,7 +578,7 @@ function HostRow({
<span>{t("hosts.docker")}</span>
</button>
)}
{host.enableTunnel && (
{host.enableSsh && host.enableTunnel && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -587,7 +590,7 @@ function HostRow({
<span>{t("hosts.tunnel")}</span>
</button>
)}
{metricsEnabled && (
{host.enableSsh && metricsEnabled && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -672,7 +675,13 @@ function HostRow({
<Copy className="size-3.5 mr-2" />
{t("hosts.copyAddress")}
</DropdownMenuItem>
{host.enableTerminal && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<Link className="size-3.5 mr-2" />
{t("hosts.copyLink")}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
{host.enableSsh && host.enableTerminal && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
@@ -681,41 +690,103 @@ function HostRow({
toast.success(t("hosts.terminalUrlCopied"));
}}
>
<Copy className="size-3.5 mr-2" />
<Terminal className="size-3.5 mr-2" />
{t("hosts.copyTerminalUrlAction")}
</DropdownMenuItem>
)}
{host.enableFileManager && (
{host.enableSsh && host.enableFileManager && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}?view=file_manager&hostId=${host.id}`,
`${window.location.origin}?view=file-manager&hostId=${host.id}`,
);
toast.success(t("hosts.fileManagerUrlCopied"));
}}
>
<Copy className="size-3.5 mr-2" />
<FolderSearch className="size-3.5 mr-2" />
{t("hosts.copyFileManagerUrlAction")}
</DropdownMenuItem>
)}
{(host.enableRdp || host.enableVnc || host.enableTelnet) && (
{host.enableSsh && host.enableTunnel && (
<DropdownMenuItem
onClick={() => {
const proto = host.enableRdp
? "rdp"
: host.enableVnc
? "vnc"
: "telnet";
navigator.clipboard.writeText(
`${window.location.origin}?view=${proto}&hostId=${host.id}`,
`${window.location.origin}?view=tunnel&hostId=${host.id}`,
);
toast.success(t("hosts.remoteDesktopUrlCopied"));
toast.success(t("hosts.tunnelUrlCopied"));
}}
>
<Copy className="size-3.5 mr-2" />
{t("hosts.copyRemoteDesktopUrlAction")}
<Network className="size-3.5 mr-2" />
{t("hosts.copyTunnelUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && host.enableDocker && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}?view=docker&hostId=${host.id}`,
);
toast.success(t("hosts.dockerUrlCopied"));
}}
>
<Box className="size-3.5 mr-2" />
{t("hosts.copyDockerUrlAction")}
</DropdownMenuItem>
)}
{host.enableSsh && metricsEnabled && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}?view=server-stats&hostId=${host.id}`,
);
toast.success(t("hosts.serverStatsUrlCopied"));
}}
>
<Server className="size-3.5 mr-2" />
{t("hosts.copyServerStatsUrlAction")}
</DropdownMenuItem>
)}
{host.enableRdp && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}?view=rdp&hostId=${host.id}`,
);
toast.success(t("hosts.rdpUrlCopied"));
}}
>
<Monitor className="size-3.5 mr-2" />
{t("hosts.copyRdpUrlAction")}
</DropdownMenuItem>
)}
{host.enableVnc && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}?view=vnc&hostId=${host.id}`,
);
toast.success(t("hosts.vncUrlCopied"));
}}
>
<Monitor className="size-3.5 mr-2" />
{t("hosts.copyVncUrlAction")}
</DropdownMenuItem>
)}
{host.enableTelnet && (
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(
`${window.location.origin}?view=telnet&hostId=${host.id}`,
);
toast.success(t("hosts.telnetUrlCopied"));
}}
>
<Terminal className="size-3.5 mr-2" />
{t("hosts.copyTelnetUrlAction")}
</DropdownMenuItem>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onClick={onDelete}
@@ -759,6 +830,7 @@ function HostEditor({
credentials: { id: string; name: string; username: string }[];
}) {
const { t } = useTranslation();
const { setPreviewTerminalTheme } = useTabsSafe();
const [form, setForm] = useState(() => {
const rawTheme = host?.terminalConfig?.theme;
const normalizedTheme =
@@ -779,8 +851,9 @@ function HostEditor({
vncPort: host?.vncPort ?? 5900,
telnetPort: host?.telnetPort ?? 23,
authType: host?.authType ?? "password",
password: host?.password ?? "",
key: host?.key ?? "",
password:
host?.password ?? (host?.hasPassword ? "existing_password" : ""),
key: host?.key ?? (host?.hasKey ? "existing_key" : ""),
keyPassword: host?.keyPassword ?? "",
keyType: host?.keyType ?? "auto",
keySubTab: "paste" as "paste" | "upload",
@@ -994,8 +1067,11 @@ function HostEditor({
tags,
pin: form.pin,
authType: form.authType,
password: form.password || null,
key: form.key || null,
password:
form.password === "existing_password"
? undefined
: form.password || null,
key: form.key === "existing_key" ? undefined : form.key || null,
keyPassword: form.keyPassword || null,
keyType: form.keyType !== "auto" ? form.keyType : null,
credentialId: form.credentialId ? Number(form.credentialId) : null,
@@ -1088,6 +1164,7 @@ function HostEditor({
? await updateSSHHost(Number(host.id), data as any)
: await createSSHHost(data as any);
toast.success(host ? t("hosts.hostUpdated") : t("hosts.hostCreated"));
setPreviewTerminalTheme(null);
onSave(saved);
} catch {
toast.error(t("hosts.failedToSave"));
@@ -1515,9 +1592,8 @@ function HostEditor({
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPassword")}
</label>
<Input
className="h-7 text-xs"
type="password"
<PasswordInput
className="h-7 text-xs pr-8"
placeholder={t("hosts.optional")}
value={form.socks5Password}
onChange={(e) =>
@@ -1629,9 +1705,8 @@ function HostEditor({
<label className="text-[9px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.proxyPassword")}
</label>
<Input
className="h-7 text-xs"
type="password"
<PasswordInput
className="h-7 text-xs pr-8"
placeholder={t("hosts.optional")}
value={node.password}
onChange={(e) => {
@@ -1854,10 +1929,22 @@ function HostEditor({
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.password")}
</label>
<Input
type="password"
placeholder="••••••••"
value={form.password}
<PasswordInput
className="h-8 text-xs pr-8"
placeholder={
form.password === "existing_password"
? t("hosts.passwordSaved")
: "••••••••"
}
value={
form.password === "existing_password"
? ""
: form.password
}
onFocus={() => {
if (form.password === "existing_password")
setField("password", "");
}}
onChange={(e) => setField("password", e.target.value)}
/>
</div>
@@ -1932,9 +2019,9 @@ function HostEditor({
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.keyPassphrase")}
</label>
<Input
type="password"
placeholder="Optional"
<PasswordInput
className="h-8 text-xs pr-8"
placeholder={t("hosts.optional")}
value={form.keyPassword}
onChange={(e) =>
setField("keyPassword", e.target.value)
@@ -2065,7 +2152,10 @@ function HostEditor({
</label>
<select
value={form.theme}
onChange={(e) => setField("theme", e.target.value)}
onChange={(e) => {
setField("theme", e.target.value);
setPreviewTerminalTheme(e.target.value);
}}
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
>
{Object.entries(TERMINAL_THEMES)
@@ -2286,8 +2376,8 @@ function HostEditor({
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.sudoPasswordLabel")}
</label>
<Input
type="password"
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.sudoPassword}
onChange={(e) => setField("sudoPassword", e.target.value)}
@@ -3178,8 +3268,8 @@ function HostEditor({
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.password")}
</label>
<Input
type="password"
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.rdpPassword}
onChange={(e) => setField("rdpPassword", e.target.value)}
@@ -3665,8 +3755,8 @@ function HostEditor({
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.gatewayPassword")}
</label>
<Input
type="password"
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.guacamoleConfig["gateway-password"] ?? ""}
onChange={(e) =>
@@ -3954,8 +4044,8 @@ function HostEditor({
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.vncPassword")}
</label>
<Input
type="password"
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.vncPassword}
onChange={(e) => setField("vncPassword", e.target.value)}
@@ -4338,8 +4428,8 @@ function HostEditor({
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.guac.password")}
</label>
<Input
type="password"
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={form.telnetPassword}
onChange={(e) => setField("telnetPassword", e.target.value)}
@@ -4753,7 +4843,14 @@ function HostEditor({
</div>
<div className="flex justify-end gap-3 mt-3 mb-6">
<Button variant="ghost" onClick={onBack} disabled={saving}>
<Button
variant="ghost"
onClick={() => {
setPreviewTerminalTheme(null);
onBack();
}}
disabled={saving}
>
{t("hosts.guac.cancelBtn")}
</Button>
<Button
@@ -4975,8 +5072,8 @@ function CredentialEditorView({
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.password")}
</label>
<Input
type="password"
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={credForm.value}
onChange={(e) => setCredField("value", e.target.value)}
@@ -5083,8 +5180,8 @@ function CredentialEditorView({
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("hosts.keyPassphraseOptional")}
</label>
<Input
type="password"
<PasswordInput
className="h-8 text-xs pr-8"
placeholder="••••••••"
value={credForm.passphrase}
onChange={(e) => setCredField("passphrase", e.target.value)}
@@ -6033,6 +6130,7 @@ export function HostManager({
}
return [...prev, updated];
});
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
setEditingHost(null);
setActiveHostTab("general");
}}
@@ -6173,9 +6271,7 @@ export function HostManager({
const normalized = hostsArray.map((h: any) => ({
...h,
port: h.port ?? h.sshPort ?? 22,
enableSsh:
h.enableSsh ??
(h.connectionType === "ssh" || !h.connectionType),
enableSsh: h.enableSsh ?? h.connectionType === "ssh",
enableRdp: h.enableRdp ?? h.connectionType === "rdp",
enableVnc: h.enableVnc ?? h.connectionType === "vnc",
enableTelnet:
@@ -6187,6 +6283,9 @@ export function HostManager({
);
const raw = await getSSHHosts();
setHosts(raw.map(sshHostToHost));
window.dispatchEvent(
new CustomEvent("termix:hosts-changed"),
);
const msg = [
result.success ? `${result.success} imported` : null,
result.updated ? `${result.updated} updated` : null,
+9 -4
View File
@@ -21,23 +21,28 @@ export function isFolder(item: Host | HostFolder): item is HostFolder {
function getSshActions(
host: Host,
): { type: TabType; icon: typeof Terminal; label: string }[] {
const metricsEnabled = host.statsConfig?.metricsEnabled !== false;
const metricsEnabled =
host.enableSsh && host.statsConfig?.metricsEnabled !== false;
return [
host.enableSsh &&
host.enableTerminal && {
type: "terminal" as TabType,
icon: Terminal,
label: "Terminal",
},
host.enableSsh &&
host.enableFileManager && {
type: "files" as TabType,
icon: FolderSearch,
label: "Files",
},
host.enableSsh &&
host.enableDocker && {
type: "docker" as TabType,
icon: Box,
label: "Docker",
},
host.enableSsh &&
host.enableTunnel && {
type: "tunnel" as TabType,
icon: Network,
@@ -211,8 +216,7 @@ export function HostItem({
)}
<div className="flex items-center flex-wrap gap-1 pt-1.5 pl-2 pb-1">
{host.enableSsh &&
getSshActions(host).map(({ type, icon: Icon, label }) => (
{getSshActions(host).map(({ type, icon: Icon, label }) => (
<button
key={type}
title={label}
@@ -226,7 +230,8 @@ export function HostItem({
</button>
))}
{host.enableSsh &&
(host.enableRdp || host.enableVnc || host.enableTelnet) && (
(host.enableRdp || host.enableVnc || host.enableTelnet) &&
getSshActions(host).length > 0 && (
<div className="w-px h-3.5 bg-border/60 mx-0.5 shrink-0" />
)}
{host.enableRdp && (
+3 -3
View File
@@ -41,12 +41,12 @@ export function WarpgateDialog({
};
return (
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200">
<div className="absolute inset-0 flex items-center justify-center z-500 animate-in fade-in duration-200 overflow-y-auto">
<div
className="absolute inset-0 bg-canvas rounded-md"
style={{ backgroundColor: backgroundColor || undefined }}
/>
<div className="bg-card border border-border w-full max-w-md mx-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="bg-card border border-border w-full max-w-md mx-4 my-4 relative z-10 animate-in fade-in zoom-in-95 duration-200">
<div className="p-4 border-b border-border">
<div className="flex items-center gap-2">
<Shield className="size-4 text-accent-brand" />
@@ -95,7 +95,7 @@ export function WarpgateDialog({
</div>
</div>
<div className="flex flex-col sm:flex-row justify-end gap-2 pt-1">
<div className="flex flex-wrap justify-end gap-2 pt-1">
<Button
type="button"
variant="ghost"