diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index cc32f3d1..34f78ea8 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -17,7 +17,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: blacksmith-4vcpu-ubuntu-2404 steps: - name: Checkout repository uses: actions/checkout@v5 diff --git a/README.md b/README.md index 9ddf04d6..d01a3aab 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Supported Devices: - MSI Installer - Chocolatey Package Manager - Linux (x64/ia32) - - Portable + - Portable [(AUR available)](https://aur.archlinux.org/packages/termix-bin) - AppImage - Deb - Flatpak diff --git a/src/hooks/use-confirmation.ts b/src/hooks/use-confirmation.ts index eca415b4..2d1c1800 100644 --- a/src/hooks/use-confirmation.ts +++ b/src/hooks/use-confirmation.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { toast } from "sonner"; interface ConfirmationOptions { @@ -9,10 +9,47 @@ interface ConfirmationOptions { variant?: "default" | "destructive"; } +interface ToastConfirmOptions { + confirmOnEnter?: boolean; + duration?: number; +} + export function useConfirmation() { const [isOpen, setIsOpen] = useState(false); const [options, setOptions] = useState(null); const [onConfirm, setOnConfirm] = useState<(() => void) | null>(null); + const [activeToastId, setActiveToastId] = useState(null); + const [pendingConfirmCallback, setPendingConfirmCallback] = useState<(() => void) | null>(null); + const [pendingResolve, setPendingResolve] = useState<((value: boolean) => void) | null>(null); + + const handleEnterKey = useCallback((event: KeyboardEvent) => { + if (event.key === "Enter" && activeToastId !== null) { + event.preventDefault(); + event.stopPropagation(); + + if (pendingConfirmCallback) { + pendingConfirmCallback(); + } + if (pendingResolve) { + pendingResolve(true); + } + + toast.dismiss(activeToastId); + setActiveToastId(null); + setPendingConfirmCallback(null); + setPendingResolve(null); + } + }, [activeToastId, pendingConfirmCallback, pendingResolve]); + + useEffect(() => { + if (activeToastId !== null) { + // Use capture phase to intercept Enter before terminal receives it + window.addEventListener("keydown", handleEnterKey, true); + return () => { + window.removeEventListener("keydown", handleEnterKey, true); + }; + } + }, [activeToastId, handleEnterKey]); const confirm = (opts: ConfirmationOptions, callback: () => void) => { setOptions(opts); @@ -40,6 +77,7 @@ export function useConfirmation() { callback?: () => void, variantOrConfirmLabel: "default" | "destructive" | string = "Confirm", cancelLabel: string = "Cancel", + toastOptions: ToastConfirmOptions = { confirmOnEnter: false }, ): Promise => { return new Promise((resolve) => { const isVariant = @@ -47,43 +85,56 @@ export function useConfirmation() { variantOrConfirmLabel === "destructive"; const confirmLabel = isVariant ? "Confirm" : variantOrConfirmLabel; - if (typeof opts === "string") { - toast(opts, { - action: { - label: confirmLabel, - onClick: () => { - if (callback) callback(); - resolve(true); - }, - }, - cancel: { - label: cancelLabel, - onClick: () => { - resolve(false); - }, - }, - } as any); - } else if (typeof opts === "object") { - const actualConfirmLabel = opts.confirmText || confirmLabel; - const actualCancelLabel = opts.cancelText || cancelLabel; + const { confirmOnEnter = false, duration = 8000 } = toastOptions; - toast(opts.description, { - action: { - label: actualConfirmLabel, - onClick: () => { - if (callback) callback(); - resolve(true); - }, - }, - cancel: { - label: actualCancelLabel, - onClick: () => { - resolve(false); - }, - }, - } as any); - } else { + const handleToastConfirm = () => { + if (callback) callback(); + resolve(true); + setActiveToastId(null); + setPendingConfirmCallback(null); + setPendingResolve(null); + }; + + const handleToastCancel = () => { resolve(false); + setActiveToastId(null); + setPendingConfirmCallback(null); + setPendingResolve(null); + }; + + const message = typeof opts === "string" ? opts : opts.description; + const actualConfirmLabel = typeof opts === "object" && opts.confirmText ? opts.confirmText : confirmLabel; + const actualCancelLabel = typeof opts === "object" && opts.cancelText ? opts.cancelText : cancelLabel; + + const toastId = toast(message, { + duration, + action: { + label: confirmOnEnter ? `${actualConfirmLabel} ↵` : actualConfirmLabel, + onClick: handleToastConfirm, + }, + cancel: { + label: actualCancelLabel, + onClick: handleToastCancel, + }, + onDismiss: () => { + setActiveToastId(null); + setPendingConfirmCallback(null); + setPendingResolve(null); + }, + onAutoClose: () => { + resolve(false); + setActiveToastId(null); + setPendingConfirmCallback(null); + setPendingResolve(null); + }, + } as any); + + if (confirmOnEnter) { + setActiveToastId(toastId); + setPendingConfirmCallback(() => () => { + if (callback) callback(); + }); + setPendingResolve(() => resolve); } }); }; diff --git a/src/ui/desktop/apps/features/terminal/Terminal.tsx b/src/ui/desktop/apps/features/terminal/Terminal.tsx index 5fa0a1c8..8e4a6c62 100644 --- a/src/ui/desktop/apps/features/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/features/terminal/Terminal.tsx @@ -618,15 +618,15 @@ export const Terminal = forwardRef( ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002` : isElectron() ? (() => { - const baseUrl = - (window as { configuredServerUrl?: string }) - .configuredServerUrl || "http://127.0.0.1:30001"; - const wsProtocol = baseUrl.startsWith("https://") - ? "wss://" - : "ws://"; - const wsHost = baseUrl.replace(/^https?:\/\//, ""); - return `${wsProtocol}${wsHost}/ssh/websocket/`; - })() + const baseUrl = + (window as { configuredServerUrl?: string }) + .configuredServerUrl || "http://127.0.0.1:30001"; + const wsProtocol = baseUrl.startsWith("https://") + ? "wss://" + : "ws://"; + const wsHost = baseUrl.replace(/^https?:\/\//, ""); + return `${wsProtocol}${wsHost}/ssh/websocket/`; + })() : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`; if ( @@ -715,8 +715,11 @@ export const Terminal = forwardRef( : msg.data; terminal.write(outputData); + // Universal regex pattern for sudo password prompt + // Matches "[sudo]" prefix + any text + ":" suffix, works for all languages + // Also matches "sudo: a password is required" for some systems const sudoPasswordPattern = - /(?:\[sudo\] password for \S+:|sudo: a password is required)/; + /(?:\[sudo\][^\n]*:\s*$|sudo:[^\n]*password[^\n]*required)/i; const passwordToFill = hostConfig.terminalConfig?.sudoPassword || hostConfig.password; if ( @@ -746,6 +749,7 @@ export const Terminal = forwardRef( }, t("common.confirm"), t("common.cancel"), + { confirmOnEnter: true }, ); setTimeout(() => { sudoPromptShownRef.current = false; @@ -1383,7 +1387,7 @@ export const Terminal = forwardRef( const selectedCommand = autocompleteSuggestionsRef.current[ - autocompleteSelectedIndexRef.current + autocompleteSelectedIndexRef.current ]; const currentCmd = currentAutocompleteCommand.current; const completion = selectedCommand.substring(currentCmd.length);