mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
fix: general bug fixes
This commit is contained in:
@@ -121,11 +121,14 @@ export function ProxmoxDiscoverDialog({
|
||||
const credId = defaultCredentialId ?? discoveredCredentialId;
|
||||
const importAuth = resolveProxmoxImportAuth(defaultAuthType, credId);
|
||||
|
||||
const toImport = guests
|
||||
.filter((g) => selected.has(g.vmid))
|
||||
const selectedGuests = guests.filter((g) => selected.has(g.vmid));
|
||||
const skippedNoIp = selectedGuests.filter((g) => !g.ip).length;
|
||||
|
||||
const toImport = selectedGuests
|
||||
.filter((g) => !!g.ip)
|
||||
.map((g) => ({
|
||||
name: g.name,
|
||||
ip: g.ip ?? "0.0.0.0",
|
||||
ip: g.ip as string,
|
||||
port: g.connectionType === "rdp" ? 3389 : 22,
|
||||
username: defaultUsername ?? "root",
|
||||
folder: importFolder,
|
||||
@@ -152,10 +155,15 @@ export function ProxmoxDiscoverDialog({
|
||||
},
|
||||
}));
|
||||
|
||||
const result = await bulkImportSSHHosts(toImport, false);
|
||||
const updated = await getSSHHosts();
|
||||
onHostsChanged(updated);
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
const result = toImport.length
|
||||
? await bulkImportSSHHosts(toImport, false)
|
||||
: { success: 0, updated: 0, skipped: 0, failed: 0 };
|
||||
|
||||
if (toImport.length) {
|
||||
const updated = await getSSHHosts();
|
||||
onHostsChanged(updated);
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
}
|
||||
|
||||
const msg = [
|
||||
result.success
|
||||
@@ -167,6 +175,9 @@ export function ProxmoxDiscoverDialog({
|
||||
result.failed
|
||||
? t("hosts.proxmoxResultFailed", { count: result.failed })
|
||||
: null,
|
||||
skippedNoIp
|
||||
? t("hosts.proxmoxResultSkippedNoIp", { count: skippedNoIp })
|
||||
: null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
@@ -23,6 +23,7 @@ import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
|
||||
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
|
||||
interface ConsoleTerminalProps {
|
||||
@@ -77,6 +78,7 @@ export function ConsoleTerminal({
|
||||
(f) => f.value === terminalConfig.fontFamily,
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback ?? TERMINAL_FONTS[0].fallback;
|
||||
ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value);
|
||||
|
||||
terminal.options.cursorBlink = terminalConfig.cursorBlink;
|
||||
terminal.options.cursorStyle = terminalConfig.cursorStyle;
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
import {
|
||||
GuacamoleDisplay,
|
||||
type GuacamoleDisplayHandle,
|
||||
type GuacamoleTouchMode,
|
||||
} from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import {
|
||||
getGuacamoleTokenFromHost,
|
||||
@@ -114,6 +115,12 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [touchMode, setTouchMode] = useState<GuacamoleTouchMode | null>(() =>
|
||||
typeof window !== "undefined" &&
|
||||
(navigator.maxTouchPoints > 0 || "ontouchstart" in window)
|
||||
? "touchscreen"
|
||||
: null,
|
||||
);
|
||||
const displayRef = useRef<GuacamoleDisplayHandle>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -245,7 +252,7 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
</div>
|
||||
)}
|
||||
<GuacamoleDisplay
|
||||
key={token}
|
||||
key={`${token}-${touchMode}`}
|
||||
ref={displayRef}
|
||||
connectionConfig={{
|
||||
token,
|
||||
@@ -257,9 +264,15 @@ const GuacamoleAppInner = React.forwardRef<
|
||||
: undefined,
|
||||
}}
|
||||
isVisible={true}
|
||||
touchMode={touchMode}
|
||||
onError={(err) => setConnectionError(err)}
|
||||
/>
|
||||
<GuacamoleToolbar displayRef={displayRef} protocol={resolvedProtocol} />
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol={resolvedProtocol}
|
||||
touchMode={touchMode}
|
||||
onTouchModeChange={setTouchMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -44,9 +44,12 @@ export interface GuacamoleDisplayHandle {
|
||||
setClipboard: (data: string) => void;
|
||||
}
|
||||
|
||||
export type GuacamoleTouchMode = "touchscreen" | "touchpad";
|
||||
|
||||
interface GuacamoleDisplayProps {
|
||||
connectionConfig: GuacamoleConnectionConfig;
|
||||
isVisible: boolean;
|
||||
touchMode?: GuacamoleTouchMode | null;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
@@ -58,7 +61,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleDisplayProps
|
||||
>(function GuacamoleDisplay(
|
||||
{ connectionConfig, isVisible, onConnect, onDisconnect, onError },
|
||||
{ connectionConfig, isVisible, touchMode, onConnect, onDisconnect, onError },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
@@ -388,26 +391,46 @@ export const GuacamoleDisplay = forwardRef<
|
||||
setIsReady(true);
|
||||
}
|
||||
|
||||
const mouse = new Guacamole.Mouse(displayElement);
|
||||
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||
const sendMouseEvent = (event: Guacamole.Mouse.MouseEvent) => {
|
||||
displayElement.focus({ preventScroll: true });
|
||||
const scale = scaleRef.current;
|
||||
const adjustedX = Math.round(state.x / scale);
|
||||
const adjustedY = Math.round(state.y / scale);
|
||||
|
||||
const state = event.state;
|
||||
const adjustedState = new Guacamole.Mouse.State(
|
||||
adjustedX,
|
||||
adjustedY,
|
||||
Math.round(state.x / scale),
|
||||
Math.round(state.y / scale),
|
||||
state.left,
|
||||
state.middle,
|
||||
state.right,
|
||||
state.up,
|
||||
state.down,
|
||||
) as Guacamole.Mouse.State;
|
||||
|
||||
client.sendMouseState(adjustedState);
|
||||
};
|
||||
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
|
||||
|
||||
if (touchMode === "touchscreen") {
|
||||
const touchscreen = new Guacamole.Mouse.Touchscreen(displayElement);
|
||||
touchscreen.onEach(["mousedown", "mousemove", "mouseup"], sendMouseEvent);
|
||||
} else if (touchMode === "touchpad") {
|
||||
const touchpad = new Guacamole.Mouse.Touchpad(displayElement);
|
||||
touchpad.onEach(["mousedown", "mousemove", "mouseup"], sendMouseEvent);
|
||||
} else {
|
||||
const mouse = new Guacamole.Mouse(displayElement);
|
||||
const sendMouseState = (state: Guacamole.Mouse.State) => {
|
||||
displayElement.focus({ preventScroll: true });
|
||||
const scale = scaleRef.current;
|
||||
const adjustedState = new Guacamole.Mouse.State(
|
||||
Math.round(state.x / scale),
|
||||
Math.round(state.y / scale),
|
||||
state.left,
|
||||
state.middle,
|
||||
state.right,
|
||||
state.up,
|
||||
state.down,
|
||||
) as Guacamole.Mouse.State;
|
||||
client.sendMouseState(adjustedState);
|
||||
};
|
||||
mouse.onmousedown = mouse.onmouseup = mouse.onmousemove = sendMouseState;
|
||||
}
|
||||
|
||||
const keyboard = new Guacamole.Keyboard(displayElement);
|
||||
keyboardRef.current = keyboard;
|
||||
@@ -425,6 +448,9 @@ export const GuacamoleDisplay = forwardRef<
|
||||
displayElement.addEventListener("focus", handleDisplayFocus);
|
||||
displayElement.addEventListener("blur", handleDisplayBlur);
|
||||
displayElement.addEventListener("mousedown", handleDisplayFocus);
|
||||
displayElement.addEventListener("touchstart", handleDisplayFocus, {
|
||||
passive: true,
|
||||
});
|
||||
refreshKeyboardHandlers();
|
||||
|
||||
client.onstatechange = (state: number) => {
|
||||
@@ -529,6 +555,7 @@ export const GuacamoleDisplay = forwardRef<
|
||||
connectionConfig.protocol,
|
||||
connectionConfig.type,
|
||||
connectionConfig.dpi,
|
||||
touchMode,
|
||||
t,
|
||||
]);
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
ChevronsLeftRight,
|
||||
Touchpad,
|
||||
MousePointer,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -20,13 +22,18 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/tooltip.tsx";
|
||||
import type { GuacamoleDisplayHandle } from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import type {
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleTouchMode,
|
||||
} from "@/features/guacamole/GuacamoleDisplay.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface GuacamoleToolbarProps {
|
||||
displayRef: React.RefObject<GuacamoleDisplayHandle>;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
touchMode?: GuacamoleTouchMode | null;
|
||||
onTouchModeChange?: (mode: GuacamoleTouchMode) => void;
|
||||
}
|
||||
|
||||
const MODIFIER_KEYSYMS = {
|
||||
@@ -107,6 +114,8 @@ function TipIconBtn({
|
||||
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
displayRef,
|
||||
protocol,
|
||||
touchMode,
|
||||
onTouchModeChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [position, setPosition] = useState({ x: 0, y: 12 });
|
||||
@@ -287,6 +296,39 @@ export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Touch mode toggle — touch devices only */}
|
||||
{touchMode != null && onTouchModeChange && (
|
||||
<>
|
||||
<div className={SEP} />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onTouchModeChange(
|
||||
touchMode === "touchscreen"
|
||||
? "touchpad"
|
||||
: "touchscreen",
|
||||
)
|
||||
}
|
||||
className={cn(BTN_ICON)}
|
||||
>
|
||||
{touchMode === "touchscreen" ? (
|
||||
<MousePointer className="size-3.5" />
|
||||
) : (
|
||||
<Touchpad className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{touchMode === "touchscreen"
|
||||
? t("guacamole.toolbar.switchToTrackpad")
|
||||
: t("guacamole.toolbar.switchToTouch")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* System combos — RDP/VNC only */}
|
||||
{isRdpVnc && (
|
||||
<>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { isEmbeddedMode } from "@/main-axios";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { resolveTermixThemeColors } from "@/features/terminal/terminal-theme";
|
||||
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||
import { ensureTerminalFontsLoaded } from "@/features/terminal/terminal-global-styles";
|
||||
import type { SerialConfig } from "@/types/ui-types";
|
||||
import type { SerialHandle } from "./serial-types";
|
||||
|
||||
@@ -67,6 +68,7 @@ export const Serial = forwardRef<SerialHandle, SerialProps>(function Serial(
|
||||
const fontConfig = TERMINAL_FONTS.find(
|
||||
(f) => f.value === DEFAULT_TERMINAL_CONFIG.fontFamily,
|
||||
);
|
||||
ensureTerminalFontsLoaded(fontConfig?.value ?? TERMINAL_FONTS[0].value);
|
||||
terminal.options.theme = {
|
||||
background: themeColors.background,
|
||||
foreground: themeColors.foreground,
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clipboard,
|
||||
Pencil,
|
||||
X,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
@@ -252,6 +254,18 @@ export function MobileTerminalKeyboard({
|
||||
terminalRef.current?.sendInput?.(seq);
|
||||
}
|
||||
|
||||
async function handlePaste() {
|
||||
try {
|
||||
const text = window.electronClipboard
|
||||
? await window.electronClipboard.readText()
|
||||
: ((await navigator.clipboard?.readText?.()) ?? "");
|
||||
if (text) terminalRef.current?.paste?.(text);
|
||||
else toast.error(t("terminal.clipboardReadFailed"));
|
||||
} catch {
|
||||
toast.error(t("terminal.clipboardReadFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCtrl() {
|
||||
setCtrlActive((v) => !v);
|
||||
setShiftActive(false);
|
||||
@@ -322,6 +336,18 @@ export function MobileTerminalKeyboard({
|
||||
{shiftActive ? t("mobileKeyboard.backTab") : t("mobileKeyboard.tab")}
|
||||
</button>
|
||||
|
||||
{/* Paste */}
|
||||
<button
|
||||
className={cn(KEY_BASE, KEY_NORMAL, KEY_SM)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
handlePaste();
|
||||
}}
|
||||
title={t("mobileKeyboard.paste")}
|
||||
>
|
||||
<Clipboard className="size-4" />
|
||||
</button>
|
||||
|
||||
<div className={SEP} />
|
||||
|
||||
{/* Ctrl */}
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
TERMINAL_FONTS,
|
||||
} from "@/lib/terminal-themes.ts";
|
||||
import "./terminal-global-styles.ts";
|
||||
import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts";
|
||||
import { useTheme } from "@/components/theme-provider.tsx";
|
||||
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||
import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts";
|
||||
@@ -824,6 +824,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
},
|
||||
paste: (text: string) => {
|
||||
terminal?.paste(text);
|
||||
},
|
||||
notifyResize: () => {
|
||||
try {
|
||||
const cols = terminal?.cols ?? undefined;
|
||||
@@ -1986,6 +1989,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
(f) => f.value === config.fontFamily,
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
|
||||
ensureTerminalFontsLoaded(fontConfig?.value || TERMINAL_FONTS[0].value);
|
||||
|
||||
// Update terminal options individually to avoid re-initialization flashes
|
||||
terminal.options.cursorBlink = config.cursorBlink;
|
||||
@@ -2053,6 +2057,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
(f) => f.value === config.fontFamily,
|
||||
);
|
||||
const fontFamily = fontConfig?.fallback || TERMINAL_FONTS[0].fallback;
|
||||
ensureTerminalFontsLoaded(fontConfig?.value || TERMINAL_FONTS[0].value);
|
||||
|
||||
const activeTheme = previewTheme || config.theme;
|
||||
const themeColors = resolveTermixThemeColors(
|
||||
|
||||
@@ -46,6 +46,7 @@ style.innerHTML = `
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0,0,0,0.3) transparent;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
@@ -74,3 +75,22 @@ style.innerHTML = `
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Canvas fillText() does not reliably trigger @font-face fetches on every
|
||||
// browser engine (notably Android WebView) the way rendering real DOM text
|
||||
// does. xterm.js draws glyphs to a <canvas>, so without an explicit load the
|
||||
// terminal can keep painting the fallback font's tofu boxes even after
|
||||
// document.fonts.ready resolves. Forcing the load here ensures the glyph
|
||||
// data is actually fetched before the terminal renders with it.
|
||||
export function ensureTerminalFontsLoaded(fontFamily: string): void {
|
||||
if (typeof document === "undefined" || !document.fonts) return;
|
||||
const specs = [
|
||||
`400 16px "${fontFamily}"`,
|
||||
`700 16px "${fontFamily}"`,
|
||||
`italic 400 16px "${fontFamily}"`,
|
||||
`italic 700 16px "${fontFamily}"`,
|
||||
];
|
||||
for (const spec of specs) {
|
||||
document.fonts.load(spec).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface TerminalHandle {
|
||||
fit: () => void;
|
||||
focus: () => void;
|
||||
sendInput: (data: string) => void;
|
||||
paste: (text: string) => void;
|
||||
notifyResize: () => void;
|
||||
refresh: () => void;
|
||||
getApplicationCursorKeysMode: () => boolean;
|
||||
|
||||
@@ -256,11 +256,11 @@ function highlightPlainText(
|
||||
text: string,
|
||||
activePatterns: HighlightPattern[],
|
||||
activeSgr: string,
|
||||
protectedRanges: ProtectedRange[],
|
||||
): string {
|
||||
if (text.length > MAX_LINE_LENGTH || !text.trim()) return text;
|
||||
|
||||
const matches: MatchResult[] = [];
|
||||
const protectedRanges = getProtectedRanges(text);
|
||||
|
||||
for (const pattern of activePatterns) {
|
||||
pattern.regex.lastIndex = 0;
|
||||
@@ -381,13 +381,34 @@ function highlightLine(
|
||||
if (bare.length > MAX_LINE_LENGTH) return line;
|
||||
if (isShellPromptLine(bare)) return line;
|
||||
|
||||
// Compute protected ranges (e.g. SSH bracket headings) against the fully
|
||||
// stripped line rather than per-ANSI-segment text. A colored prompt theme
|
||||
// (e.g. "[<color>user<reset>@<color>host<reset>]") splits the heading across
|
||||
// multiple plain-text segments, so matching per-segment would miss it and
|
||||
// let a username like "warning" get wrongly highlighted as a log level.
|
||||
const plainLine = bare.replace(STRIP_ANSI_RE, "");
|
||||
const lineProtectedRanges = getProtectedRanges(plainLine);
|
||||
|
||||
const segments = parseAnsiSegments(bare);
|
||||
let plainOffset = 0;
|
||||
const result = segments
|
||||
.map((s) =>
|
||||
s.isAnsi
|
||||
? s.content
|
||||
: highlightPlainText(s.content, activePatterns, s.activeSgr ?? ""),
|
||||
)
|
||||
.map((s) => {
|
||||
if (s.isAnsi) return s.content;
|
||||
const segmentStart = plainOffset;
|
||||
plainOffset += s.content.length;
|
||||
const localRanges = lineProtectedRanges
|
||||
.map((r) => ({
|
||||
start: r.start - segmentStart,
|
||||
end: r.end - segmentStart,
|
||||
}))
|
||||
.filter((r) => r.start < s.content.length && r.end > 0);
|
||||
return highlightPlainText(
|
||||
s.content,
|
||||
activePatterns,
|
||||
s.activeSgr ?? "",
|
||||
localRanges,
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
return cr ? result + "\r" : result;
|
||||
|
||||
@@ -602,6 +602,7 @@
|
||||
"overrideCredentialUsername": "Override Credential Username",
|
||||
"overrideCredentialUsernameDesc": "Use the username specified above instead of the credential's username",
|
||||
"oidcUsernameHint": "Use $oidc.preferred_username to substitute your OIDC login name.",
|
||||
"tailscaleUsernameHint": "This must be a Unix user your Tailscale identity is granted in the tailnet's SSH ACL, not necessarily root.",
|
||||
"jumpHostChain": "Jump Host Chain",
|
||||
"portKnocking": "Port Knocking",
|
||||
"addKnock": "Add Port",
|
||||
@@ -895,6 +896,7 @@
|
||||
"proxmoxResultImported": "{{count}} imported",
|
||||
"proxmoxResultUpdated": "{{count}} updated",
|
||||
"proxmoxResultFailed": "{{count}} failed",
|
||||
"proxmoxResultSkippedNoIp": "{{count}} skipped (no IP found)",
|
||||
"proxmoxImportComplete": "Proxmox import complete: {{summary}}",
|
||||
"proxmoxDiscoveryFailed": "Discovery failed",
|
||||
"proxmoxImportFailed": "Import failed",
|
||||
@@ -1461,7 +1463,9 @@
|
||||
"reconnect": "Reconnect Session",
|
||||
"collapse": "Collapse toolbar",
|
||||
"expand": "Expand toolbar",
|
||||
"dragHandle": "Drag to reposition"
|
||||
"dragHandle": "Drag to reposition",
|
||||
"switchToTrackpad": "Switch to trackpad mode (drag to move cursor, tap to click)",
|
||||
"switchToTouch": "Switch to touch mode (tap directly where you want to click)"
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
@@ -3379,6 +3383,7 @@
|
||||
"pageUp": "PgUp",
|
||||
"pageDown": "PgDn",
|
||||
"delete": "Del",
|
||||
"paste": "Paste",
|
||||
"editQuickKeys": "Edit quick keys",
|
||||
"quickKeysTitle": "Quick Keys",
|
||||
"quickKeysDesc": "Tap × to remove. Supports up to 8 characters.",
|
||||
|
||||
@@ -446,6 +446,13 @@ export function AdminUserManagePanel({
|
||||
key={editor.credential ? editor.credential.id : "new-cred"}
|
||||
credential={editor.credential}
|
||||
activeTab={editorTab}
|
||||
existingFolders={Array.from(
|
||||
new Set(
|
||||
credentials
|
||||
.map((c) => c.folder)
|
||||
.filter((f): f is string => !!f),
|
||||
),
|
||||
).sort()}
|
||||
onBack={() => {
|
||||
setEditor(null);
|
||||
setEditorTab("general");
|
||||
|
||||
@@ -26,14 +26,14 @@ export function CredentialEditorView({
|
||||
onBack,
|
||||
onSave,
|
||||
adminTargetUserId,
|
||||
existingFolders = [],
|
||||
}: {
|
||||
credential: Credential | null;
|
||||
activeTab: string;
|
||||
onBack: () => void;
|
||||
onSave: (saved: Record<string, unknown>) => void;
|
||||
// When set, saves go to another user's credentials via the admin
|
||||
// impersonation endpoints.
|
||||
adminTargetUserId?: string;
|
||||
existingFolders?: string[];
|
||||
}) {
|
||||
const [credForm, setCredForm] = useState(() => ({
|
||||
name: credential?.name ?? "",
|
||||
@@ -155,7 +155,15 @@ export function CredentialEditorView({
|
||||
placeholder="e.g. Server Keys"
|
||||
value={credForm.folder}
|
||||
onChange={(e) => setCredField("folder", e.target.value)}
|
||||
list="cred-folder-suggestions"
|
||||
/>
|
||||
{existingFolders.length > 0 && (
|
||||
<datalist id="cred-folder-suggestions">
|
||||
{existingFolders.map((f) => (
|
||||
<option key={f} value={f} />
|
||||
))}
|
||||
</datalist>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5 col-span-2">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
|
||||
@@ -11,6 +11,10 @@ import { Button } from "@/components/button";
|
||||
import { Input } from "@/components/input";
|
||||
import { PasswordInput } from "@/components/password-input";
|
||||
import { Slider } from "@/components/slider";
|
||||
import {
|
||||
TERMINAL_FONT_ZOOM_MIN,
|
||||
TERMINAL_FONT_ZOOM_MAX,
|
||||
} from "@/features/terminal/terminal-font-zoom";
|
||||
import {
|
||||
Globe,
|
||||
Layers, // --- tmux-monitor ---
|
||||
@@ -385,6 +389,11 @@ export function HostEditor({
|
||||
{t("hosts.oidcUsernameHint")}
|
||||
</p>
|
||||
)}
|
||||
{authMethod === "tailscale" && (
|
||||
<p className="text-[10px] text-muted-foreground/60">
|
||||
{t("hosts.tailscaleUsernameHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{authMethod === "password" && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -950,8 +959,8 @@ export function HostEditor({
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
min={8}
|
||||
max={24}
|
||||
min={TERMINAL_FONT_ZOOM_MIN}
|
||||
max={TERMINAL_FONT_ZOOM_MAX}
|
||||
step={1}
|
||||
value={[form.fontSize]}
|
||||
onValueChange={([v]) => setField("fontSize", v)}
|
||||
|
||||
@@ -431,6 +431,13 @@ export function HostManager({
|
||||
: (editingCredential as Credential)
|
||||
}
|
||||
activeTab={activeCredentialTab}
|
||||
existingFolders={Array.from(
|
||||
new Set(
|
||||
credentials
|
||||
.map((c) => c.folder)
|
||||
.filter((f): f is string => !!f),
|
||||
),
|
||||
).sort()}
|
||||
onBack={() => {
|
||||
setEditingCredential(null);
|
||||
setActiveCredentialTab("general");
|
||||
|
||||
@@ -81,15 +81,28 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
|
||||
rdpPort: h.rdpPort ?? (h.connectionType === "rdp" ? h.port : 3389),
|
||||
vncPort: h.vncPort ?? (h.connectionType === "vnc" ? h.port : 5900),
|
||||
telnetPort: h.telnetPort ?? (h.connectionType === "telnet" ? h.port : 23),
|
||||
rdpAuthType:
|
||||
(h.rdpAuthType as "direct" | "credential") ??
|
||||
(h.rdpCredentialId ? "credential" : "direct"),
|
||||
rdpCredentialId:
|
||||
h.rdpCredentialId != null ? String(h.rdpCredentialId) : undefined,
|
||||
rdpUser: h.rdpUser,
|
||||
rdpPassword: h.rdpPassword ?? "",
|
||||
domain: h.rdpDomain,
|
||||
security: h.rdpSecurity,
|
||||
ignoreCert: h.rdpIgnoreCert ?? false,
|
||||
vncAuthType: h.vncAuthType ?? (h.vncCredentialId ? "credential" : "direct"),
|
||||
vncCredentialId: h.vncCredentialId ?? null,
|
||||
vncAuthType:
|
||||
(h.vncAuthType as "direct" | "credential") ??
|
||||
(h.vncCredentialId ? "credential" : "direct"),
|
||||
vncCredentialId:
|
||||
h.vncCredentialId != null ? String(h.vncCredentialId) : undefined,
|
||||
vncPassword: h.vncPassword ?? "",
|
||||
vncUser: h.vncUser,
|
||||
telnetAuthType:
|
||||
(h.telnetAuthType as "direct" | "credential") ??
|
||||
(h.telnetCredentialId ? "credential" : "direct"),
|
||||
telnetCredentialId:
|
||||
h.telnetCredentialId != null ? String(h.telnetCredentialId) : undefined,
|
||||
telnetUser: h.telnetUser,
|
||||
telnetPassword: h.telnetPassword ?? "",
|
||||
quickActions: (h.quickActions ?? []).map((a: HostQuickAction) => ({
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
|
||||
textarea.value = value;
|
||||
textarea.selectionStart = value.length;
|
||||
textarea.selectionEnd = value.length;
|
||||
}
|
||||
|
||||
// iOS Safari/WKWebView reports keyCode 229 for ordinary software-keyboard
|
||||
// input, not just true IME composition, so xterm routes typing through
|
||||
// CompositionHelper.keydown -> _handleAnyTextareaChanges instead of the
|
||||
// normal keypress path. That handler snapshots the textarea value on
|
||||
// keydown, then diffs it against the value a setTimeout(0) later.
|
||||
function dispatchIOSKeydown(textarea: HTMLTextAreaElement) {
|
||||
textarea.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { keyCode: 229 } as KeyboardEventInit),
|
||||
);
|
||||
}
|
||||
|
||||
describe("iOS rapid typing (keyCode 229 outside composition)", () => {
|
||||
let terminal: Terminal | undefined;
|
||||
let container: HTMLDivElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
terminal?.dispose();
|
||||
container?.remove();
|
||||
terminal = undefined;
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
it("forwards a mid-word autocorrect rewrite instead of dropping it", async () => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
terminal = new Terminal();
|
||||
terminal.open(container);
|
||||
|
||||
const input: string[] = [];
|
||||
terminal.onData((data) => input.push(data));
|
||||
|
||||
const textarea = terminal.textarea!;
|
||||
|
||||
// keydown fires while the textarea still holds the pre-keystroke value;
|
||||
// the browser (or, on iOS, autocorrect) mutates the value afterward.
|
||||
// Autocorrect can rewrite characters earlier in the word, not just
|
||||
// append at the cursor, so the old value is no longer a literal
|
||||
// substring of the new one.
|
||||
dispatchIOSKeydown(textarea);
|
||||
setTextareaValue(textarea, "wrold");
|
||||
await tick();
|
||||
|
||||
dispatchIOSKeydown(textarea);
|
||||
setTextareaValue(textarea, "world");
|
||||
await tick();
|
||||
|
||||
expect(input.join("")).toBe("wrold" + "orld");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ensureTerminalFontsLoaded } from "../../../features/terminal/terminal-global-styles";
|
||||
|
||||
describe("ensureTerminalFontsLoaded", () => {
|
||||
it("requests regular, bold, italic, and bold-italic variants for the given font", () => {
|
||||
const load = vi.fn().mockResolvedValue([]);
|
||||
const originalFonts = document.fonts;
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: { load },
|
||||
});
|
||||
|
||||
try {
|
||||
ensureTerminalFontsLoaded("Caskaydia Cove Nerd Font Mono");
|
||||
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'400 16px "Caskaydia Cove Nerd Font Mono"',
|
||||
);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'700 16px "Caskaydia Cove Nerd Font Mono"',
|
||||
);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'italic 400 16px "Caskaydia Cove Nerd Font Mono"',
|
||||
);
|
||||
expect(load).toHaveBeenCalledWith(
|
||||
'italic 700 16px "Caskaydia Cove Nerd Font Mono"',
|
||||
);
|
||||
expect(load).toHaveBeenCalledTimes(4);
|
||||
} finally {
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: originalFonts,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("does not throw when document.fonts is unavailable", () => {
|
||||
const originalFonts = document.fonts;
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => ensureTerminalFontsLoaded("JetBrains Mono")).not.toThrow();
|
||||
} finally {
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: originalFonts,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("swallows rejected font load promises", async () => {
|
||||
const load = vi.fn().mockRejectedValue(new Error("network error"));
|
||||
const originalFonts = document.fonts;
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: { load },
|
||||
});
|
||||
|
||||
try {
|
||||
expect(() => ensureTerminalFontsLoaded("Fira Code")).not.toThrow();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
} finally {
|
||||
Object.defineProperty(document, "fonts", {
|
||||
configurable: true,
|
||||
value: originalFonts,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -263,6 +263,14 @@ describe("highlightTerminalOutput", () => {
|
||||
expect(out).toContain(`${ESC}[91mERROR`);
|
||||
});
|
||||
|
||||
it("does not highlight a log-level-like username split across ANSI segments in a colored SSH heading", () => {
|
||||
// Prompt themes often color the user and host portions of "[user@host]"
|
||||
// separately, so the heading is not one contiguous plain-text segment.
|
||||
const chunk = `[${ESC}[1;33mwarning${ESC}[0m@host] some command output`;
|
||||
const out = highlightTerminalOutput(chunk);
|
||||
expect(out).toBe(chunk);
|
||||
});
|
||||
|
||||
it("does not highlight 'success' when immediately followed by a path (cd output)", () => {
|
||||
// Some shells print "success~/new/dir" or "success/path" after a cd command
|
||||
const out = highlightTerminalOutput("success~/home/user/projects");
|
||||
|
||||
Reference in New Issue
Block a user