mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
v2.3.2 (#874)
* fix: patch critical security vulnerabilities (GHSA-5fqh, GHSA-ccm8, GHSA-wqfw, GHSA-xmjh) - Remove passwordHash from /users/list API response - Require both password and TOTP code for MFA-critical operations - Restrict tunnel kill commands to tunnelMarker-only matching - Add session ownership middleware for file manager endpoints * fix: allow navigating away from split-view to non-pane tabs Show the normal view container on top of the split view when the active tab is not assigned to any pane, so users can switch to dashboard or other tabs while split mode is active. Closes #739 * fix: add inline quick-action buttons on host name row Show Terminal, Files, RDP, and VNC shortcut icons on the host name row on hover, so users can launch connections with a single click without expanding the full action tray. Closes #736 * fix: restore SSH keepalive interval to 30s to prevent random disconnects Revert keepalive defaults from 60s/5 to 30s/3 across terminal, tunnel, and server-stats SSH connections. The 60s interval introduced in 2.3.0 causes firewalls and NAT devices to drop idle connections before the next keepalive probe. Closes #733 * fix: apply guacamole-lite protocol patch in Docker builds The Dockerfile uses --ignore-scripts which skips the postinstall hook that patches guacamole-lite for guacd 1.6.0 protocol VERSION_1_5_0. Without this patch, the timezone handshake instruction is not sent for protocol versions above 1.1.0, causing VNC connections to fail immediately on connect. Closes #734 * fix: show correct icons for network interface types Detect interface type from name pattern and show appropriate icons: WiFi for wlan/wl*, Ethernet (Cable) for eth/en*, Container for docker/bridge/virtual, generic Network for others. Closes #720 * fix: resolve sudo password for shared host users The password endpoint required hosts.userId to match the requesting user, which fails for shared hosts. Now falls back to decrypting with the owner's key when the requesting user doesn't own the host. Closes #717 * fix: use jump hosts for online status check and metrics collection Status polling now pings the first jump host instead of the unreachable target when jump hosts are configured. The /metrics/start endpoint now tunnels through the jump host chain to reach the target host. Closes #716 * fix: broaden sudo prompt detection for newer distros Add patterns for 'password for <user>:' and bare 'Password:' prompts in addition to the existing [sudo] and sudo: patterns. Covers Ubuntu 26.04 and other distros that use different sudo prompt formats. Closes #718 * fix: recalculate terminal layout after web fonts load xterm.js measures character widths at open() time. If custom fonts haven't loaded yet, measurements use the fallback font and spacing becomes incorrect. Now refresh and re-fit the terminal once document.fonts.ready resolves. Closes #710 * fix: improve terminal cwd detection and initial directory command Remove '&& pwd' from initial directory command — the shell prompt shows the new directory naturally. Fixes PowerShell 5.1 which doesn't support '&&' as a statement separator. Prepend Ctrl+U to get_cwd command to clear any pending input before injecting the cwd probe, reducing interference with foreground programs. Closes #713, #714 * fix: decode base64 file content as UTF-8 in file manager Replace bare atob() with TextDecoder('utf-8') for base64 content decoding. atob() only handles Latin-1, so multi-byte UTF-8 characters like 'é' were decoded as 'é'. Closes #719 * fix: normalize lazy import default exports for iOS compatibility Wrap all lazy() imports with explicit .then(m => ({ default: m.default })) to ensure consistent module resolution across platforms. iOS Safari/WebView may handle bare lazy(() => import(...)) differently, returning the module object instead of extracting the default export. Closes #721 * fix: prevent RDP display from snapping back after container resize Remove immediate rescaleDisplay() from ResizeObserver callback. The display.onresize event already triggers rescaling when the RDP server responds with the new resolution. Calling rescaleDisplay before the server responds uses stale display dimensions, causing the bottom of the screen to be truncated. Closes #725 * fix: add portal Desktop DBus permission for Flatpak URL opening Flatpak sandbox blocks window.open() without the portal permission, causing terminal link clicks to open about:blank. Add talk-name for org.freedesktop.portal.Desktop to enable xdg-desktop-portal URL handling. Closes #704 * chore: remove unused code and fix PR checks (#851) * chore: remove unused frontend code * chore: prune unused theme exports * ci: fix pr check failures * chore: reduce lint warnings * feat(oidc): expose admin_group via OIDC_ADMIN_GROUP env var (#828) The admin-group OIDC sync added in 2.3.0 (#782) reads `config.admin_group` to sync the user's admin flag from OIDC group membership on each login. That field is only populated when the OIDC config is stored in the in-app DB — `getOIDCConfigFromEnv()` does not expose it, so deployments using the env-var config path (declarative IaC: Helm/Compose/Puppet) cannot enable the feature without abandoning env vars and pasting the client_secret into the admin UI. Add `admin_group: process.env.OIDC_ADMIN_GROUP || ""` to the env-config return type and object. Backward compatible: when unset, the existing `if (config.admin_group)` guard at users.ts:1336 keeps the sync block skipped, matching today's behavior. * chore: reduce explicit-any warnings * chore: reduce more explicit-any warnings * chore: reduce lint warnings * chore: silence intentional hook dependency warnings * chore: clean dependency tooling * chore: narrow frontend tsconfig scope * chore: reduce type assertion debt * refactor: split host manager components * refactor: split host editor sections * refactor: split api client modules * refactor: split more api clients * refactor: split user settings api clients * refactor: split tab and history api clients * refactor: split tunnel api clients * refactor: split server stats api client * refactor: split file manager data api * refactor: split ssh file operations api * refactor: split host editor general tab * refactor: split host editor guacamole tabs * refactor: split ssh host management api * refactor: split admin general settings sections * refactor: split admin database section * refactor: split admin management sections * refactor: split admin keys and dialogs * refactor: split system status api clients * refactor: split user route helpers * refactor: split host route helpers * refactor: split file manager ssh helpers * refactor: split file manager session helpers * refactor: split file manager listing routes * refactor: split host opkssh routes * refactor: split file manager content routes * refactor: split user api key routes * refactor: split host folder routes * refactor: split user settings routes * refactor: split user totp routes * refactor: split host file manager bookmark routes * refactor: split file manager operation routes * refactor: split server stats settings routes * refactor: split user session routes * refactor: split host command history routes * refactor: split server stats viewer routes * refactor: split docker container routes * refactor: split user oidc account routes * refactor: split host autostart routes * refactor: split host internal routes * refactor: split host network routes * refactor: split user password reset routes * refactor: split user admin routes * refactor: split user data access routes * refactor: split credential key routes * refactor: split credential deploy routes * refactor: split host bulk routes * refactor: split server stats connection helpers * refactor: split tunnel helpers * refactor: split file manager action routes * refactor: split terminal auth helpers * refactor: split terminal jump host helpers * refactor: split tunnel relay helpers * refactor: split tunnel socks relay helpers * refactor: split tunnel c2s relay handlers * refactor: split server stats session helpers * refactor: split terminal presentation helpers * refactor: split file manager presentation helpers * refactor: split file manager toolbar * fix(guacamole-lite): send name instruction for protocol >= 1.3.0 The Guacamole protocol added the `name` handshake instruction in 1.3.0 (an optional human-readable identifier for the joining user). guacd 1.6.0 began requiring it during the VNC handshake even when negotiating older protocol versions, causing connections to silently drop right after the "User joined" log line with no client-visible error. This patch extends scripts/patch-guacamole-lite.cjs with a third idempotent string-replacement that injects the `name` instruction send when guacamole-lite has negotiated protocol VERSION_1_3_0 or VERSION_1_5_0. Verified end-to-end: guacd debug logs now show `Processing instruction: name` and `Client is using protocol version "VERSION_1_5_0"` (previously stuck at VERSION_1_1_0). VNC session connects successfully against guacd 1.5.5 / macOS Tahoe target. Related: Termix-SSH/Support#567, #734 * fix: resolve recent support bugs * fix(admin): wire up OIDC-to-password link dialog submit + visibility The admin user-management UI already shipped a link icon and a "Link Account" dialog, but two things blocked the flow: 1. The submit button had no onClick handler and the username input was uncontrolled (no value/onChange). Clicking "Link Accounts" was a no-op — no network request, no console error, no toast. 2. The link icon's visibility condition was `user.isOidc && !user.passwordHash`, which hid the button on OIDC users that had been auto-provisioned with a passwordHash. Termix's OIDC provisioning sets a passwordHash by default, so the button was hidden on virtually every OIDC-provisioned user. This change: - Adds `linkOIDCToPasswordAccount` to the imports from `@/main-axios`. - Adds two pieces of dialog state: `linkAccountTargetUsername` and `linkAccountSubmitting`. - Makes the dialog's Input field a controlled component. - Wires the submit Button's onClick to call `linkOIDCToPasswordAccount`, emit success/error toasts, refresh the local user list, and close the dialog. - Loosens the visibility condition to `user.isOidc` (the backend handler already enforces all integrity checks). - Adds `linkAccountSuccess`, `linkAccountFailed`, and `linkAccountInProgress` translation keys to `en.json`. Verified locally: full Docker build via docker/Dockerfile passes; `tsc --noEmit` is clean; `prettier --check .` is clean; ESLint produces the same warning count as upstream (16 pre-existing `any`-type warnings, 0 errors). * fix: support native oidc callbacks (#856) * docs: add cloudflare tunnel guidance (#857) * fix: sync appearance preferences (#858) * fix: pass through terminal tab completion (#859) * fix: resolve terminal jump hosts server-side (#860) * fix(electron): auto-allow SSL certificates for private network hosts (#861) Add private network IP detection (RFC 1918, link-local, loopback, IPv6 ULA) to the Electron certificate-error handler so that connections to local/private servers like 192.168.x.x bypass SSL validation automatically. Also add an explicit "Allow invalid certificate" toggle in the server config UI for public HTTPS servers with self-signed certs. * fix: restore host password copy actions (#862) * feat: support single-host direct tunnels (ssh -L style) (#863) Add direct tunnel mode that uses a single SSH host for port forwarding, matching the behavior of ssh -L / ssh -R / ssh -D without requiring a second endpoint host in the Termix database. The Termix server creates a local TCP listener and forwards through the SSH channel directly. * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * Merge commit from fork * fix: backend build errors (Type) * fix: mobile auth failing to login with webview * fix: mobile app geting incorrectly sent auth token * feat: commit existing frontend/backend e2e/unit tests (skipped tests containing private info like OIDC and real server testing) * feat: host-to-host file transfer via server relay * feat: removed host management from command palette, fixed command palette opening wrong protocol, export/import failing for ssh key hosts, docker ssh2 native crypto not compiled, persisted terminal tabs attempt SSh on RDP hosts after migration, improved layout for click to expand hosts, show ip/username without having to hover over hosts * fix: credentials not indexing into host manager until refresh * feat: update credentials lists to match hosts list UI/UX * feat: add rename folder UI * feat: improve transfer to host UI/UX * chore: increment ver * feat: improve transfer to host UI * feat: implement initial auto release system --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: nicodarge <43711429+nicodarge@users.noreply.github.com> Co-authored-by: Raman Gupta <7243222+raman325@users.noreply.github.com> Co-authored-by: luc <luc_cook@hotmail.co.uk>
This commit is contained in:
co-authored by
ZacharyZcR
nicodarge
Raman Gupta
luc
parent
da79b01db4
commit
52f4e51ae0
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Alert, AlertDescription } from "@/components/alert.tsx";
|
||||
|
||||
@@ -46,7 +46,7 @@ export function ContainerStats({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, containerState]);
|
||||
}, [sessionId, containerId, containerState, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchStats();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -20,43 +21,11 @@ import { DiffWindow } from "./components/DiffWindow.tsx";
|
||||
import { useDragToDesktop } from "@/features/file-manager/hooks/useDragToDesktop";
|
||||
import { useDragToSystemDesktop } from "@/features/file-manager/hooks/useDragToSystemDesktop";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
|
||||
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
|
||||
import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
|
||||
import { CompressDialog } from "./components/CompressDialog.tsx";
|
||||
import { SudoPasswordDialog } from "./SudoPasswordDialog.tsx";
|
||||
import {
|
||||
Upload,
|
||||
FolderPlus,
|
||||
FilePlus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Grid3X3,
|
||||
List,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ArrowUp,
|
||||
Plus,
|
||||
Folder,
|
||||
Trash2,
|
||||
Copy,
|
||||
Layout,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu.tsx";
|
||||
import { FileManagerDialogs } from "./FileManagerDialogs.tsx";
|
||||
import { FileManagerToolbar } from "./FileManagerToolbar.tsx";
|
||||
import { TransferToHostDialog } from "./components/TransferToHostDialog.tsx";
|
||||
import { TerminalWindow } from "./components/TerminalWindow.tsx";
|
||||
import type { SSHHost, FileItem } from "@/types/index";
|
||||
import {
|
||||
@@ -64,7 +33,6 @@ import {
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
import { SimpleLoader } from "@/lib/SimpleLoader.tsx";
|
||||
import {
|
||||
listSSHFiles,
|
||||
@@ -94,56 +62,28 @@ import {
|
||||
compressSSHFiles,
|
||||
setSudoPassword,
|
||||
getServerMetricsById,
|
||||
transferToHost,
|
||||
addTransferRecent,
|
||||
type TransferMethodPreference,
|
||||
} from "@/main-axios.ts";
|
||||
import { beginTransferProgressMonitoring } from "./transferProgressMonitor.tsx";
|
||||
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
|
||||
import type { SidebarItem } from "./FileManagerSidebar.tsx";
|
||||
|
||||
interface FileManagerProps {
|
||||
initialHost?: SSHHost | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
|
||||
|
||||
type SSHConnectionError = Error & {
|
||||
connectionLogs?: ConnectionLogPayload[];
|
||||
requires_totp?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
status?: string;
|
||||
reason?: "no_keyboard" | "auth_failed" | "timeout";
|
||||
};
|
||||
|
||||
interface CreateIntent {
|
||||
id: string;
|
||||
type: "file" | "directory";
|
||||
defaultName: string;
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (bytes === undefined || bytes === null) return "-";
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
const formattedSize =
|
||||
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
|
||||
return `${formattedSize} ${units[unitIndex]}`;
|
||||
}
|
||||
import type {
|
||||
CreateIntent,
|
||||
FileManagerProps,
|
||||
PendingSudoOperation,
|
||||
SSHConnectionError,
|
||||
} from "./file-manager-types.ts";
|
||||
import { formatFileSize } from "./file-manager-utils.ts";
|
||||
|
||||
function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const { openWindow } = useWindowManager();
|
||||
const { t } = useTranslation();
|
||||
const formatTransferMetrics = useMemo(
|
||||
() => createFormatTransferMetrics(t),
|
||||
[t],
|
||||
);
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const {
|
||||
addLog,
|
||||
@@ -244,13 +184,13 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const [compressDialogFiles, setCompressDialogFiles] = useState<FileItem[]>(
|
||||
[],
|
||||
);
|
||||
const [transferDialogOpen, setTransferDialogOpen] = useState(false);
|
||||
const [transferFiles, setTransferFiles] = useState<FileItem[]>([]);
|
||||
const [transferMove, setTransferMove] = useState(false);
|
||||
|
||||
const [sudoDialogOpen, setSudoDialogOpen] = useState(false);
|
||||
const [pendingSudoOperation, setPendingSudoOperation] = useState<
|
||||
| { type: "delete"; files: FileItem[] }
|
||||
| { type: "navigate"; path: string }
|
||||
| null
|
||||
>(null);
|
||||
const [pendingSudoOperation, setPendingSudoOperation] =
|
||||
useState<PendingSudoOperation | null>(null);
|
||||
|
||||
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
||||
|
||||
@@ -776,6 +716,19 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
loadDirectory(currentPath);
|
||||
}, [currentPath, lastRefreshTime, loadDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ hostId: number; path: string }>)
|
||||
.detail;
|
||||
if (!detail || !currentHost?.id) return;
|
||||
if (detail.hostId === currentHost.id) {
|
||||
handleRefreshDirectory();
|
||||
}
|
||||
};
|
||||
window.addEventListener("file-manager:refresh", handler);
|
||||
return () => window.removeEventListener("file-manager:refresh", handler);
|
||||
}, [currentHost?.id, handleRefreshDirectory]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement;
|
||||
@@ -1358,6 +1311,19 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
});
|
||||
}
|
||||
|
||||
function handleSidebarItemContextMenu(
|
||||
event: React.MouseEvent,
|
||||
item: SidebarItem,
|
||||
) {
|
||||
const file: FileItem = {
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
type:
|
||||
item.type === "recent" || item.type === "pinned" ? "file" : "directory",
|
||||
};
|
||||
handleContextMenu(event, file);
|
||||
}
|
||||
|
||||
function handleCopyFiles(files: FileItem[]) {
|
||||
setClipboard({ files, operation: "copy" });
|
||||
toast.success(
|
||||
@@ -1623,6 +1589,76 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenTransferDialog(files: FileItem[], move: boolean) {
|
||||
setTransferFiles(files);
|
||||
setTransferMove(move);
|
||||
setTransferDialogOpen(true);
|
||||
}
|
||||
|
||||
async function handleTransferConfirm(
|
||||
destSessionId: string,
|
||||
destHostId: number,
|
||||
destPath: string,
|
||||
destPathLabel: string,
|
||||
methodPreference: TransferMethodPreference,
|
||||
parallelSegmentCount: number,
|
||||
) {
|
||||
if (!sshSessionId || !currentHost?.id || transferFiles.length === 0) return;
|
||||
|
||||
const sourcePaths = transferFiles.map((f) => f.path);
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const { transferId } = await transferToHost(
|
||||
sshSessionId,
|
||||
sourcePaths,
|
||||
destSessionId,
|
||||
destPath,
|
||||
transferMove,
|
||||
methodPreference,
|
||||
parallelSegmentCount,
|
||||
);
|
||||
|
||||
const monitorHandle = beginTransferProgressMonitoring(transferId, t, {
|
||||
formatTransferMetrics,
|
||||
});
|
||||
if (!monitorHandle) return;
|
||||
|
||||
const finalStatus = await monitorHandle.waitForCompletion;
|
||||
|
||||
if (
|
||||
finalStatus.status !== "success" &&
|
||||
finalStatus.status !== "partial"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
void addTransferRecent(
|
||||
currentHost.id,
|
||||
destHostId,
|
||||
destPathLabel,
|
||||
destPathLabel,
|
||||
);
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("file-manager:refresh", {
|
||||
detail: { hostId: destHostId, path: destPathLabel },
|
||||
}),
|
||||
);
|
||||
|
||||
if (transferMove) {
|
||||
handleRefreshDirectory();
|
||||
clearSelection();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("transfer.transferError")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUndo() {
|
||||
if (undoHistory.length === 0) {
|
||||
toast.info(t("fileManager.noUndoableActions"));
|
||||
@@ -2556,281 +2592,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
visibility: isConnectionLogExpanded ? "hidden" : "visible",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col shrink-0 mx-3 mt-3 border border-border bg-card">
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2 gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setMobileSidebarOpen((o) => !o)}
|
||||
className="md:hidden size-8 rounded-none"
|
||||
title={t("fileManager.toggleSidebar")}
|
||||
>
|
||||
<Layout className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={goBack}
|
||||
disabled={navIndex <= 0}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={goForward}
|
||||
disabled={navIndex >= navHistory.length - 1}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={goUp}
|
||||
disabled={currentPath === "/"}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefreshDirectory}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-4 ${isLoading && !!sshSessionId ? "animate-spin [animation-duration:0.5s]" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex flex-1 items-center px-3 h-8 bg-muted/50 border border-border rounded-none gap-2 overflow-hidden">
|
||||
<Folder className="size-3.5 text-accent-brand shrink-0" />
|
||||
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">
|
||||
{currentPath.split("/").map((part, i, arr) => (
|
||||
<React.Fragment key={i}>
|
||||
{part === "" && i === 0 ? (
|
||||
<button
|
||||
onClick={() => navigateTo("/")}
|
||||
className="hover:text-accent-brand transition-colors"
|
||||
>
|
||||
{t("fileManager.root")}
|
||||
</button>
|
||||
) : part !== "" ? (
|
||||
<button
|
||||
onClick={() =>
|
||||
navigateTo(arr.slice(0, i + 1).join("/") || "/")
|
||||
}
|
||||
className="hover:text-accent-brand transition-colors"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
) : null}
|
||||
{i < arr.length - 1 && part !== "" && (
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{i === 0 && arr.length > 1 && part === "" && (
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-accent-brand/10 border border-accent-brand/20 text-accent-brand text-[10px] font-black uppercase tracking-tighter">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
|
||||
onClick={() => handleDeleteFiles(selectedFiles)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
|
||||
onClick={() => handleCopyFiles(selectedFiles)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-28 md:w-48">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("fileManager.searchFiles")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 text-xs bg-muted/50 border-border rounded-none focus:ring-1 focus:ring-accent-brand/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center border border-border rounded-none overflow-hidden">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className={`size-8 rounded-none border-y-0 border-l-0 border-r border-border ${viewMode === "grid" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
|
||||
>
|
||||
<Grid3X3 className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`size-8 rounded-none border-y-0 border-r-0 border-border ${viewMode === "list" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
|
||||
>
|
||||
<List className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className="hidden md:block cursor-pointer"
|
||||
title={t("fileManager.upload")}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (files) handleFilesDropped(files);
|
||||
}}
|
||||
/>
|
||||
<div className="h-8 px-3 flex items-center gap-1.5 border border-border hover:bg-muted text-muted-foreground hover:text-foreground transition-colors text-[10px] font-bold uppercase tracking-widest">
|
||||
<Upload className="size-3.5" /> {t("fileManager.upload")}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none font-bold uppercase tracking-widest text-[10px]"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{t("fileManager.new")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-44 rounded-none border-border bg-card"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setTimeout(() => handleCreateNewFolder(), 0);
|
||||
}}
|
||||
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
|
||||
>
|
||||
<FolderPlus className="size-4 text-accent-brand" />
|
||||
{t("fileManager.newFolder")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setTimeout(() => handleCreateNewFile(), 0);
|
||||
}}
|
||||
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
|
||||
>
|
||||
<FilePlus className="size-4 text-muted-foreground" />
|
||||
{t("fileManager.newFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-[10px] uppercase tracking-widest text-muted-foreground py-1">
|
||||
{t("fileManager.sortBy")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortBy}
|
||||
onValueChange={(v) =>
|
||||
setSortBy(v as "name" | "modified" | "size")
|
||||
}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value="name"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.sortByName")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="modified"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.sortByDate")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="size"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.sortBySize")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortOrder}
|
||||
onValueChange={(v) => setSortOrder(v as "asc" | "desc")}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value="asc"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.ascending")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="desc"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.descending")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile breadcrumb row */}
|
||||
<div className="md:hidden flex items-center px-3 pb-2 gap-2">
|
||||
<div className="flex-1 flex items-center px-3 h-8 bg-muted/50 border border-border gap-2 overflow-hidden">
|
||||
<Folder className="size-3.5 text-accent-brand shrink-0" />
|
||||
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">
|
||||
{currentPath.split("/").map((part, i, arr) => (
|
||||
<React.Fragment key={i}>
|
||||
{part === "" && i === 0 ? (
|
||||
<button
|
||||
onClick={() => navigateTo("/")}
|
||||
className="hover:text-accent-brand transition-colors"
|
||||
>
|
||||
{t("fileManager.root")}
|
||||
</button>
|
||||
) : part !== "" ? (
|
||||
<button
|
||||
onClick={() =>
|
||||
navigateTo(arr.slice(0, i + 1).join("/") || "/")
|
||||
}
|
||||
className="hover:text-accent-brand transition-colors"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
) : null}
|
||||
{i < arr.length - 1 && part !== "" && (
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{i === 0 && arr.length > 1 && part === "" && (
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FileManagerToolbar
|
||||
t={t}
|
||||
currentPath={currentPath}
|
||||
navIndex={navIndex}
|
||||
navHistoryLength={navHistory.length}
|
||||
isLoading={isLoading}
|
||||
sshSessionId={sshSessionId}
|
||||
selectedFiles={selectedFiles}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
sortOrder={sortOrder}
|
||||
setSortOrder={setSortOrder}
|
||||
setMobileSidebarOpen={setMobileSidebarOpen}
|
||||
goBack={goBack}
|
||||
goForward={goForward}
|
||||
goUp={goUp}
|
||||
navigateTo={navigateTo}
|
||||
handleRefreshDirectory={handleRefreshDirectory}
|
||||
handleDeleteFiles={handleDeleteFiles}
|
||||
handleCopyFiles={handleCopyFiles}
|
||||
handleFilesDropped={handleFilesDropped}
|
||||
handleCreateNewFolder={handleCreateNewFolder}
|
||||
handleCreateNewFile={handleCreateNewFile}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex-1 flex px-3 pb-3 pt-2 gap-3 min-h-0 relative"
|
||||
@@ -2861,6 +2650,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
onPathChange={navigateTo}
|
||||
onLoadDirectory={loadDirectory}
|
||||
onFileOpen={handleSidebarFileOpen}
|
||||
onItemContextMenu={handleSidebarItemContextMenu}
|
||||
sshSessionId={sshSessionId}
|
||||
refreshTrigger={sidebarRefreshTrigger}
|
||||
diskInfo={diskInfo ?? undefined}
|
||||
@@ -2873,11 +2663,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
<FileManagerGrid
|
||||
files={filteredFiles}
|
||||
selectedFiles={selectedFiles}
|
||||
onFileSelect={() => {}}
|
||||
onFileOpen={handleFileOpen}
|
||||
onSelectionChange={setSelection}
|
||||
currentPath={currentPath}
|
||||
onPathChange={navigateTo}
|
||||
onRefresh={handleRefreshDirectory}
|
||||
onUpload={handleFilesDropped}
|
||||
sortBy={sortBy}
|
||||
@@ -2963,69 +2750,51 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
onExtractArchive={handleExtractArchive}
|
||||
onCompress={handleOpenCompressDialog}
|
||||
onCopyPath={handleCopyPath}
|
||||
onTransferToHost={handleOpenTransferDialog}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CompressDialog
|
||||
open={compressDialogFiles.length > 0}
|
||||
onOpenChange={(open) => !open && setCompressDialogFiles([])}
|
||||
fileNames={compressDialogFiles.map((f) => f.name)}
|
||||
onCompress={handleCompress}
|
||||
/>
|
||||
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handleTotpCancel}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
|
||||
<WarpgateDialog
|
||||
isOpen={warpgateRequired}
|
||||
url={warpgateUrl}
|
||||
securityKey={warpgateSecurityKey}
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handleWarpgateCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
|
||||
{currentHost && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authDialogReason}
|
||||
onSubmit={handleAuthDialogSubmit}
|
||||
onCancel={handleAuthDialogCancel}
|
||||
hostInfo={{
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
name: currentHost.name,
|
||||
}}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
<TransferToHostDialog
|
||||
open={transferDialogOpen}
|
||||
onOpenChange={setTransferDialogOpen}
|
||||
files={transferFiles}
|
||||
move={transferMove}
|
||||
sourceHost={currentHost}
|
||||
sourceSessionId={sshSessionId}
|
||||
onConfirm={handleTransferConfirm}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PermissionsDialog
|
||||
file={permissionsDialogFile}
|
||||
open={permissionsDialogFile !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPermissionsDialogFile(null);
|
||||
}}
|
||||
onSave={handleSavePermissions}
|
||||
/>
|
||||
|
||||
<SudoPasswordDialog
|
||||
open={sudoDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSudoDialogOpen(open);
|
||||
if (!open) setPendingSudoOperation(null);
|
||||
}}
|
||||
onSubmit={handleSudoPasswordSubmit}
|
||||
<FileManagerDialogs
|
||||
compressDialogFiles={compressDialogFiles}
|
||||
setCompressDialogFiles={setCompressDialogFiles}
|
||||
handleCompress={handleCompress}
|
||||
totpRequired={totpRequired}
|
||||
totpPrompt={totpPrompt}
|
||||
handleTotpSubmit={handleTotpSubmit}
|
||||
handleTotpCancel={handleTotpCancel}
|
||||
warpgateRequired={warpgateRequired}
|
||||
warpgateUrl={warpgateUrl}
|
||||
warpgateSecurityKey={warpgateSecurityKey}
|
||||
handleWarpgateContinue={handleWarpgateContinue}
|
||||
handleWarpgateCancel={handleWarpgateCancel}
|
||||
handleWarpgateOpenUrl={handleWarpgateOpenUrl}
|
||||
currentHost={currentHost}
|
||||
showAuthDialog={showAuthDialog}
|
||||
authDialogReason={authDialogReason}
|
||||
handleAuthDialogSubmit={handleAuthDialogSubmit}
|
||||
handleAuthDialogCancel={handleAuthDialogCancel}
|
||||
permissionsDialogFile={permissionsDialogFile}
|
||||
setPermissionsDialogFile={setPermissionsDialogFile}
|
||||
handleSavePermissions={handleSavePermissions}
|
||||
sudoDialogOpen={sudoDialogOpen}
|
||||
setSudoDialogOpen={setSudoDialogOpen}
|
||||
setPendingSudoOperation={setPendingSudoOperation}
|
||||
handleSudoPasswordSubmit={handleSudoPasswordSubmit}
|
||||
/>
|
||||
<ConnectionLog
|
||||
isConnecting={isReconnecting || isLoading}
|
||||
|
||||
@@ -18,10 +18,13 @@ import {
|
||||
Star,
|
||||
Bookmark,
|
||||
FileArchive,
|
||||
ArrowRightLeft,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Kbd, KbdKey, KbdSeparator } from "@/components/kbd.tsx";
|
||||
|
||||
const VIEWPORT_PADDING = 16;
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
@@ -64,6 +67,7 @@ interface ContextMenuProps {
|
||||
onExtractArchive?: (file: FileItem) => void;
|
||||
onCompress?: (files: FileItem[]) => void;
|
||||
onCopyPath?: (files: FileItem[]) => void;
|
||||
onTransferToHost?: (files: FileItem[], move: boolean) => void;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
@@ -76,29 +80,6 @@ interface MenuItem {
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
const VIEWPORT_PADDING = 10;
|
||||
|
||||
function getClampedMenuPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
menuWidth: number,
|
||||
menuHeight: number,
|
||||
) {
|
||||
const maxX = Math.max(
|
||||
VIEWPORT_PADDING,
|
||||
window.innerWidth - menuWidth - VIEWPORT_PADDING,
|
||||
);
|
||||
const maxY = Math.max(
|
||||
VIEWPORT_PADDING,
|
||||
window.innerHeight - menuHeight - VIEWPORT_PADDING,
|
||||
);
|
||||
|
||||
return {
|
||||
x: Math.min(Math.max(VIEWPORT_PADDING, x), maxX),
|
||||
y: Math.min(Math.max(VIEWPORT_PADDING, y), maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export function FileManagerContextMenu({
|
||||
x,
|
||||
y,
|
||||
@@ -129,6 +110,7 @@ export function FileManagerContextMenu({
|
||||
onExtractArchive,
|
||||
onCompress,
|
||||
onCopyPath,
|
||||
onTransferToHost,
|
||||
}: ContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -278,6 +260,29 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
if (isFileContext && onTransferToHost) {
|
||||
const isOnlyDirectories =
|
||||
files.length > 0 && files.every((f) => f.type === "directory");
|
||||
menuItems.push({
|
||||
icon: <ArrowRightLeft className="size-3.5" />,
|
||||
label: isMultipleFiles
|
||||
? t("transfer.copyItemsToHost", { count: files.length })
|
||||
: isOnlyDirectories && isSingleFile
|
||||
? t("transfer.copyFolderToHost")
|
||||
: t("transfer.copyToHost"),
|
||||
action: () => onTransferToHost(files, false),
|
||||
});
|
||||
menuItems.push({
|
||||
icon: <ArrowRightLeft className="size-3.5" />,
|
||||
label: isMultipleFiles
|
||||
? t("transfer.moveItemsToHost", { count: files.length })
|
||||
: isOnlyDirectories && isSingleFile
|
||||
? t("transfer.moveFolderToHost")
|
||||
: t("transfer.moveToHost"),
|
||||
action: () => onTransferToHost(files, true),
|
||||
});
|
||||
}
|
||||
|
||||
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
|
||||
const fileName = files[0].name.toLowerCase();
|
||||
const isArchive =
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { FileItem, SSHHost } from "@/types/index";
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
|
||||
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
|
||||
import { PermissionsDialog } from "./components/PermissionsDialog.tsx";
|
||||
import { CompressDialog } from "./components/CompressDialog.tsx";
|
||||
import { SudoPasswordDialog } from "./SudoPasswordDialog.tsx";
|
||||
import type { PendingSudoOperation } from "./file-manager-types.ts";
|
||||
|
||||
type FileManagerDialogsProps = {
|
||||
compressDialogFiles: FileItem[];
|
||||
setCompressDialogFiles: (files: FileItem[]) => void;
|
||||
handleCompress: (archiveName: string, format: string) => void | Promise<void>;
|
||||
totpRequired: boolean;
|
||||
totpPrompt: string;
|
||||
handleTotpSubmit: (code: string) => void | Promise<void>;
|
||||
handleTotpCancel: () => void;
|
||||
warpgateRequired: boolean;
|
||||
warpgateUrl: string;
|
||||
warpgateSecurityKey: string;
|
||||
handleWarpgateContinue: () => void | Promise<void>;
|
||||
handleWarpgateCancel: () => void;
|
||||
handleWarpgateOpenUrl: () => void;
|
||||
currentHost: SSHHost | null;
|
||||
showAuthDialog: boolean;
|
||||
authDialogReason: "no_keyboard" | "auth_failed" | "timeout";
|
||||
handleAuthDialogSubmit: (credentials: {
|
||||
password?: string;
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
}) => void | Promise<void>;
|
||||
handleAuthDialogCancel: () => void;
|
||||
permissionsDialogFile: FileItem | null;
|
||||
setPermissionsDialogFile: (file: FileItem | null) => void;
|
||||
handleSavePermissions: (
|
||||
file: FileItem,
|
||||
permissions: string,
|
||||
) => void | Promise<void>;
|
||||
sudoDialogOpen: boolean;
|
||||
setSudoDialogOpen: (open: boolean) => void;
|
||||
setPendingSudoOperation: (operation: PendingSudoOperation | null) => void;
|
||||
handleSudoPasswordSubmit: (password: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function FileManagerDialogs({
|
||||
compressDialogFiles,
|
||||
setCompressDialogFiles,
|
||||
handleCompress,
|
||||
totpRequired,
|
||||
totpPrompt,
|
||||
handleTotpSubmit,
|
||||
handleTotpCancel,
|
||||
warpgateRequired,
|
||||
warpgateUrl,
|
||||
warpgateSecurityKey,
|
||||
handleWarpgateContinue,
|
||||
handleWarpgateCancel,
|
||||
handleWarpgateOpenUrl,
|
||||
currentHost,
|
||||
showAuthDialog,
|
||||
authDialogReason,
|
||||
handleAuthDialogSubmit,
|
||||
handleAuthDialogCancel,
|
||||
permissionsDialogFile,
|
||||
setPermissionsDialogFile,
|
||||
handleSavePermissions,
|
||||
sudoDialogOpen,
|
||||
setSudoDialogOpen,
|
||||
setPendingSudoOperation,
|
||||
handleSudoPasswordSubmit,
|
||||
}: FileManagerDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
<CompressDialog
|
||||
open={compressDialogFiles.length > 0}
|
||||
onOpenChange={(open) => !open && setCompressDialogFiles([])}
|
||||
fileNames={compressDialogFiles.map((f) => f.name)}
|
||||
onCompress={handleCompress}
|
||||
/>
|
||||
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handleTotpCancel}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
|
||||
<WarpgateDialog
|
||||
isOpen={warpgateRequired}
|
||||
url={warpgateUrl}
|
||||
securityKey={warpgateSecurityKey}
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handleWarpgateCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
|
||||
{currentHost && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authDialogReason}
|
||||
onSubmit={handleAuthDialogSubmit}
|
||||
onCancel={handleAuthDialogCancel}
|
||||
hostInfo={{
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
name: currentHost.name,
|
||||
}}
|
||||
backgroundColor="var(--bg-canvas)"
|
||||
/>
|
||||
)}
|
||||
|
||||
<PermissionsDialog
|
||||
file={permissionsDialogFile}
|
||||
open={permissionsDialogFile !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPermissionsDialogFile(null);
|
||||
}}
|
||||
onSave={handleSavePermissions}
|
||||
/>
|
||||
|
||||
<SudoPasswordDialog
|
||||
open={sudoDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setSudoDialogOpen(open);
|
||||
if (!open) setPendingSudoOperation(null);
|
||||
}}
|
||||
onSubmit={handleSudoPasswordSubmit}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
@@ -21,33 +22,8 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem } from "@/types/index";
|
||||
|
||||
interface CreateIntent {
|
||||
id: string;
|
||||
type: "file" | "directory";
|
||||
defaultName: string;
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (bytes === undefined || bytes === null) return "-";
|
||||
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
const formattedSize =
|
||||
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
|
||||
|
||||
return `${formattedSize} ${units[unitIndex]}`;
|
||||
}
|
||||
import type { CreateIntent } from "./file-manager-types.ts";
|
||||
import { formatFileSize } from "./file-manager-utils.ts";
|
||||
|
||||
interface DragState {
|
||||
type: "none" | "internal" | "external";
|
||||
@@ -61,11 +37,8 @@ interface DragState {
|
||||
interface FileManagerGridProps {
|
||||
files: FileItem[];
|
||||
selectedFiles: FileItem[];
|
||||
onFileSelect: (file: FileItem, multiSelect?: boolean) => void;
|
||||
onFileOpen: (file: FileItem) => void;
|
||||
onSelectionChange: (files: FileItem[]) => void;
|
||||
currentPath: string;
|
||||
onPathChange: (path: string) => void;
|
||||
onRefresh: () => void;
|
||||
onUpload?: (files: FileList) => void;
|
||||
onDownload?: (files: FileItem[]) => void;
|
||||
@@ -182,8 +155,6 @@ export function FileManagerGrid({
|
||||
selectedFiles,
|
||||
onFileOpen,
|
||||
onSelectionChange,
|
||||
currentPath,
|
||||
onPathChange,
|
||||
onRefresh,
|
||||
onUpload,
|
||||
onDownload,
|
||||
@@ -368,92 +339,6 @@ export function FileManagerGrid({
|
||||
} | null>(null);
|
||||
const [justFinishedSelecting, setJustFinishedSelecting] = useState(false);
|
||||
|
||||
const [navigationHistory, setNavigationHistory] = useState<string[]>([
|
||||
currentPath,
|
||||
]);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
|
||||
const [isEditingPath, setIsEditingPath] = useState(false);
|
||||
const [editPathValue, setEditPathValue] = useState(currentPath);
|
||||
|
||||
useEffect(() => {
|
||||
const lastPath = navigationHistory[historyIndex];
|
||||
if (currentPath !== lastPath) {
|
||||
const newHistory = navigationHistory.slice(0, historyIndex + 1);
|
||||
newHistory.push(currentPath);
|
||||
setNavigationHistory(newHistory);
|
||||
setHistoryIndex(newHistory.length - 1);
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
const goBack = () => {
|
||||
if (historyIndex > 0) {
|
||||
const newIndex = historyIndex - 1;
|
||||
setHistoryIndex(newIndex);
|
||||
onPathChange(navigationHistory[newIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const goForward = () => {
|
||||
if (historyIndex < navigationHistory.length - 1) {
|
||||
const newIndex = historyIndex + 1;
|
||||
setHistoryIndex(newIndex);
|
||||
onPathChange(navigationHistory[newIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const goUp = () => {
|
||||
const parts = currentPath.split("/").filter(Boolean);
|
||||
if (parts.length > 0) {
|
||||
parts.pop();
|
||||
const parentPath = "/" + parts.join("/");
|
||||
onPathChange(parentPath);
|
||||
} else if (currentPath !== "/") {
|
||||
onPathChange("/");
|
||||
}
|
||||
};
|
||||
|
||||
const pathParts = currentPath.split("/").filter(Boolean);
|
||||
const navigateToPath = (index: number) => {
|
||||
if (index === -1) {
|
||||
onPathChange("/");
|
||||
} else {
|
||||
const newPath = "/" + pathParts.slice(0, index + 1).join("/");
|
||||
onPathChange(newPath);
|
||||
}
|
||||
};
|
||||
|
||||
const startEditingPath = () => {
|
||||
setEditPathValue(currentPath);
|
||||
setIsEditingPath(true);
|
||||
};
|
||||
|
||||
const cancelEditingPath = () => {
|
||||
setIsEditingPath(false);
|
||||
setEditPathValue(currentPath);
|
||||
};
|
||||
|
||||
const confirmEditingPath = () => {
|
||||
const trimmedPath = editPathValue.trim();
|
||||
if (trimmedPath) {
|
||||
const needsExpansion =
|
||||
trimmedPath.startsWith("~") || trimmedPath.includes("$");
|
||||
const normalizedPath = needsExpansion
|
||||
? trimmedPath
|
||||
: trimmedPath.startsWith("/")
|
||||
? trimmedPath
|
||||
: "/" + trimmedPath;
|
||||
onPathChange(normalizedPath);
|
||||
}
|
||||
setIsEditingPath(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingPath) {
|
||||
setEditPathValue(currentPath);
|
||||
}
|
||||
}, [currentPath, isEditingPath]);
|
||||
|
||||
const handleDragEnter = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -490,7 +375,7 @@ export function FileManagerGrid({
|
||||
});
|
||||
}
|
||||
},
|
||||
[dragState.type, dragState.counter],
|
||||
[dragState.type],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
@@ -691,7 +576,7 @@ export function FileManagerGrid({
|
||||
|
||||
setDragState({ type: "none", files: [], counter: 0 });
|
||||
},
|
||||
[onUpload, onDownload, dragState],
|
||||
[onUpload, dragState],
|
||||
);
|
||||
|
||||
const handleFileClick = (file: FileItem, event: React.MouseEvent) => {
|
||||
|
||||
@@ -67,6 +67,8 @@ interface FileManagerSidebarProps {
|
||||
currentPath: string;
|
||||
onPathChange: (path: string) => void;
|
||||
onFileOpen?: (file: SidebarItem) => void;
|
||||
/** Full file-manager context menu (same as main grid). */
|
||||
onItemContextMenu?: (event: React.MouseEvent, item: SidebarItem) => void;
|
||||
sshSessionId?: string;
|
||||
refreshTrigger?: number;
|
||||
diskInfo?: { usedHuman: string; totalHuman: string; percent: number };
|
||||
@@ -79,6 +81,7 @@ export function FileManagerSidebar({
|
||||
currentPath,
|
||||
onPathChange,
|
||||
onFileOpen,
|
||||
onItemContextMenu,
|
||||
sshSessionId,
|
||||
refreshTrigger,
|
||||
diskInfo,
|
||||
@@ -114,46 +117,9 @@ export function FileManagerSidebar({
|
||||
|
||||
// ─── Effects ──────────────────────────────────────────────────────────────────
|
||||
|
||||
useEffect(() => {
|
||||
loadQuickAccessData();
|
||||
}, [currentHost, refreshTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sshSessionId) {
|
||||
loadedFoldersRef.current = new Set(["/"]);
|
||||
loadDirectoryTree();
|
||||
}
|
||||
}, [sshSessionId]);
|
||||
|
||||
// When currentPath changes externally (grid navigation), ensure the parent
|
||||
// directory is loaded in the tree so the selection highlight can appear.
|
||||
useEffect(() => {
|
||||
if (!sshSessionId || currentPath === "/") return;
|
||||
|
||||
const parentPath =
|
||||
currentPath.substring(0, currentPath.lastIndexOf("/")) || "/";
|
||||
|
||||
const findByPath = (items: SidebarItem[]): SidebarItem | null => {
|
||||
for (const item of items) {
|
||||
if (item.path === parentPath) return item;
|
||||
if (item.children) {
|
||||
const found = findByPath(item.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parent = findByPath(directoryTree);
|
||||
if (parent && !loadedFoldersRef.current.has(parent.path)) {
|
||||
loadedFoldersRef.current.add(parent.path);
|
||||
loadSubdirectory(parent.id, parent.path);
|
||||
}
|
||||
}, [currentPath, sshSessionId]);
|
||||
|
||||
// ─── API: Quick access ────────────────────────────────────────────────────────
|
||||
|
||||
const loadQuickAccessData = async () => {
|
||||
const loadQuickAccessData = useCallback(async () => {
|
||||
if (!currentHost?.id) return;
|
||||
|
||||
try {
|
||||
@@ -196,61 +162,64 @@ export function FileManagerSidebar({
|
||||
setPinnedItems([]);
|
||||
setShortcuts([]);
|
||||
}
|
||||
};
|
||||
}, [currentHost?.id]);
|
||||
|
||||
// ─── API: Directory tree ──────────────────────────────────────────────────────
|
||||
|
||||
const loadDirectoryTree = async (attempt = 0) => {
|
||||
if (!sshSessionId) return;
|
||||
const loadDirectoryTree = useCallback(
|
||||
async (attempt = 0) => {
|
||||
if (!sshSessionId) return;
|
||||
|
||||
try {
|
||||
const response = await listSSHFiles(sshSessionId, "/");
|
||||
const rootFiles = (response.files || []) as DirectoryItemData[];
|
||||
const rootFolders = rootFiles.filter(
|
||||
(item: DirectoryItemData) => item.type === "directory",
|
||||
);
|
||||
try {
|
||||
const response = await listSSHFiles(sshSessionId, "/");
|
||||
const rootFiles = (response.files || []) as DirectoryItemData[];
|
||||
const rootFolders = rootFiles.filter(
|
||||
(item: DirectoryItemData) => item.type === "directory",
|
||||
);
|
||||
|
||||
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
|
||||
id: `folder-${folder.name}`,
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
type: "folder" as const,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
}));
|
||||
|
||||
setDirectoryTree([
|
||||
{
|
||||
id: "root",
|
||||
name: "/",
|
||||
path: "/",
|
||||
type: "folder" as const,
|
||||
isExpanded: true,
|
||||
children: rootTreeItems,
|
||||
},
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
const status =
|
||||
(error as { status?: number })?.status ||
|
||||
(error as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 409 && attempt < 3) {
|
||||
// Another request was already listing "/" — retry after a short delay
|
||||
setTimeout(() => loadDirectoryTree(attempt + 1), 600);
|
||||
return;
|
||||
}
|
||||
console.error("Failed to load directory tree:", error);
|
||||
setDirectoryTree([
|
||||
{
|
||||
id: "root",
|
||||
name: "/",
|
||||
path: "/",
|
||||
const rootTreeItems = rootFolders.map((folder: DirectoryItemData) => ({
|
||||
id: `folder-${folder.name}`,
|
||||
name: folder.name,
|
||||
path: folder.path,
|
||||
type: "folder" as const,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
};
|
||||
}));
|
||||
|
||||
setDirectoryTree([
|
||||
{
|
||||
id: "root",
|
||||
name: "/",
|
||||
path: "/",
|
||||
type: "folder" as const,
|
||||
isExpanded: true,
|
||||
children: rootTreeItems,
|
||||
},
|
||||
]);
|
||||
} catch (error: unknown) {
|
||||
const status =
|
||||
(error as { status?: number })?.status ||
|
||||
(error as { response?: { status?: number } })?.response?.status;
|
||||
if (status === 409 && attempt < 3) {
|
||||
// Another request was already listing "/" — retry after a short delay
|
||||
setTimeout(() => loadDirectoryTree(attempt + 1), 600);
|
||||
return;
|
||||
}
|
||||
console.error("Failed to load directory tree:", error);
|
||||
setDirectoryTree([
|
||||
{
|
||||
id: "root",
|
||||
name: "/",
|
||||
path: "/",
|
||||
type: "folder" as const,
|
||||
isExpanded: false,
|
||||
children: [],
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
/**
|
||||
* Lazily fetches subdirectory contents and patches them into the tree state.
|
||||
@@ -304,6 +273,43 @@ export function FileManagerSidebar({
|
||||
[sshSessionId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadQuickAccessData();
|
||||
}, [loadQuickAccessData, refreshTrigger]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sshSessionId) {
|
||||
loadedFoldersRef.current = new Set(["/"]);
|
||||
loadDirectoryTree();
|
||||
}
|
||||
}, [loadDirectoryTree, sshSessionId]);
|
||||
|
||||
// When currentPath changes externally (grid navigation), ensure the parent
|
||||
// directory is loaded in the tree so the selection highlight can appear.
|
||||
useEffect(() => {
|
||||
if (!sshSessionId || currentPath === "/") return;
|
||||
|
||||
const parentPath =
|
||||
currentPath.substring(0, currentPath.lastIndexOf("/")) || "/";
|
||||
|
||||
const findByPath = (items: SidebarItem[]): SidebarItem | null => {
|
||||
for (const item of items) {
|
||||
if (item.path === parentPath) return item;
|
||||
if (item.children) {
|
||||
const found = findByPath(item.children);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parent = findByPath(directoryTree);
|
||||
if (parent && !loadedFoldersRef.current.has(parent.path)) {
|
||||
loadedFoldersRef.current.add(parent.path);
|
||||
loadSubdirectory(parent.id, parent.path);
|
||||
}
|
||||
}, [currentPath, directoryTree, loadSubdirectory, sshSessionId]);
|
||||
|
||||
// ─── Quick-access mutation handlers ──────────────────────────────────────────
|
||||
|
||||
const handleRemoveRecentFile = async (item: SidebarItem) => {
|
||||
@@ -415,18 +421,47 @@ export function FileManagerSidebar({
|
||||
|
||||
// ─── Context menu ─────────────────────────────────────────────────────────────
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
|
||||
const findTreeItemById = useCallback(
|
||||
(items: SidebarItem[], id: string): SidebarItem | null => {
|
||||
for (const item of items) {
|
||||
if (item.id === id) return item;
|
||||
if (item.children) {
|
||||
const found = findTreeItemById(item.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleItemContextMenu = (e: React.MouseEvent, item: SidebarItem) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (onItemContextMenu) {
|
||||
onItemContextMenu(e, item);
|
||||
return;
|
||||
}
|
||||
setContextMenu({ x: e.clientX, y: e.clientY, isVisible: true, item });
|
||||
};
|
||||
|
||||
const handleTreeContextMenu = (e: React.MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const row = target.closest<HTMLElement>("[data-id]");
|
||||
if (!row) return;
|
||||
const id = row.getAttribute("data-id");
|
||||
if (!id) return;
|
||||
const item = findTreeItemById(directoryTree, id);
|
||||
if (!item || item.type !== "folder") return;
|
||||
handleItemContextMenu(e, item);
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!contextMenu.isVisible) return;
|
||||
if (!contextMenu.isVisible || onItemContextMenu) return;
|
||||
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
@@ -448,7 +483,7 @@ export function FileManagerSidebar({
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [contextMenu.isVisible]);
|
||||
}, [contextMenu.isVisible, onItemContextMenu]);
|
||||
|
||||
// ─── Derive selected tree node + ancestors from currentPath ──────────────────
|
||||
|
||||
@@ -515,7 +550,7 @@ export function FileManagerSidebar({
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-muted border-transparent",
|
||||
)}
|
||||
onClick={() => handleQuickAccessClick(item)}
|
||||
onContextMenu={(e) => handleContextMenu(e, item)}
|
||||
onContextMenu={(e) => handleItemContextMenu(e, item)}
|
||||
title={item.path}
|
||||
>
|
||||
<div className="shrink-0">{icon}</div>
|
||||
@@ -612,16 +647,18 @@ export function FileManagerSidebar({
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-1">
|
||||
<FolderTree.Root
|
||||
id="sidebar-directory-tree"
|
||||
defaultExpanded={["root"]}
|
||||
selectedId={selectedTreeId}
|
||||
expandedIds={ancestorIds}
|
||||
onSelect={(id) => handleDirectorySelect(id)}
|
||||
className="bg-transparent border-0 rounded-none shadow-none"
|
||||
>
|
||||
{directoryTree.map((item) => renderFolderTreeItem(item))}
|
||||
</FolderTree.Root>
|
||||
<div onContextMenu={handleTreeContextMenu}>
|
||||
<FolderTree.Root
|
||||
id="sidebar-directory-tree"
|
||||
defaultExpanded={["root"]}
|
||||
selectedId={selectedTreeId}
|
||||
expandedIds={ancestorIds}
|
||||
onSelect={(id) => handleDirectorySelect(id)}
|
||||
className="bg-transparent border-0 rounded-none shadow-none"
|
||||
>
|
||||
{directoryTree.map((item) => renderFolderTreeItem(item))}
|
||||
</FolderTree.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -679,8 +716,8 @@ export function FileManagerSidebar({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Context menu ─────────────────────────────────────────────── */}
|
||||
{contextMenu.isVisible && contextMenu.item && (
|
||||
{/* ── Context menu (fallback when parent does not supply onItemContextMenu) */}
|
||||
{!onItemContextMenu && contextMenu.isVisible && contextMenu.item && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" />
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
import React from "react";
|
||||
import {
|
||||
ArrowUp,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
FilePlus,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Grid3X3,
|
||||
Layout,
|
||||
List,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/dropdown-menu.tsx";
|
||||
import type { FileItem } from "@/types/index";
|
||||
|
||||
type SortBy = "name" | "modified" | "size";
|
||||
type SortOrder = "asc" | "desc";
|
||||
type ViewMode = "grid" | "list";
|
||||
|
||||
type FileManagerToolbarProps = {
|
||||
t: (key: string) => string;
|
||||
currentPath: string;
|
||||
navIndex: number;
|
||||
navHistoryLength: number;
|
||||
isLoading: boolean;
|
||||
sshSessionId: string | null;
|
||||
selectedFiles: FileItem[];
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
viewMode: ViewMode;
|
||||
setViewMode: (mode: ViewMode) => void;
|
||||
sortBy: SortBy;
|
||||
setSortBy: (sortBy: SortBy) => void;
|
||||
sortOrder: SortOrder;
|
||||
setSortOrder: (sortOrder: SortOrder) => void;
|
||||
setMobileSidebarOpen: (updater: (open: boolean) => boolean) => void;
|
||||
goBack: () => void;
|
||||
goForward: () => void;
|
||||
goUp: () => void;
|
||||
navigateTo: (path: string) => void;
|
||||
handleRefreshDirectory: () => void;
|
||||
handleDeleteFiles: (files: FileItem[]) => void;
|
||||
handleCopyFiles: (files: FileItem[]) => void;
|
||||
handleFilesDropped: (fileList: FileList) => void;
|
||||
handleCreateNewFolder: () => void;
|
||||
handleCreateNewFile: () => void;
|
||||
};
|
||||
|
||||
function Breadcrumb({
|
||||
currentPath,
|
||||
navigateTo,
|
||||
t,
|
||||
}: Pick<FileManagerToolbarProps, "currentPath" | "navigateTo" | "t">) {
|
||||
return (
|
||||
<>
|
||||
<Folder className="size-3.5 text-accent-brand shrink-0" />
|
||||
<div className="flex items-center gap-1 overflow-x-auto scrollbar-none text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">
|
||||
{currentPath.split("/").map((part, i, arr) => (
|
||||
<React.Fragment key={i}>
|
||||
{part === "" && i === 0 ? (
|
||||
<button
|
||||
onClick={() => navigateTo("/")}
|
||||
className="hover:text-accent-brand transition-colors"
|
||||
>
|
||||
{t("fileManager.root")}
|
||||
</button>
|
||||
) : part !== "" ? (
|
||||
<button
|
||||
onClick={() => navigateTo(arr.slice(0, i + 1).join("/") || "/")}
|
||||
className="hover:text-accent-brand transition-colors"
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
) : null}
|
||||
{i < arr.length - 1 && part !== "" && (
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
{i === 0 && arr.length > 1 && part === "" && (
|
||||
<ChevronRight className="size-3 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileManagerToolbar({
|
||||
t,
|
||||
currentPath,
|
||||
navIndex,
|
||||
navHistoryLength,
|
||||
isLoading,
|
||||
sshSessionId,
|
||||
selectedFiles,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
sortBy,
|
||||
setSortBy,
|
||||
sortOrder,
|
||||
setSortOrder,
|
||||
setMobileSidebarOpen,
|
||||
goBack,
|
||||
goForward,
|
||||
goUp,
|
||||
navigateTo,
|
||||
handleRefreshDirectory,
|
||||
handleDeleteFiles,
|
||||
handleCopyFiles,
|
||||
handleFilesDropped,
|
||||
handleCreateNewFolder,
|
||||
handleCreateNewFile,
|
||||
}: FileManagerToolbarProps) {
|
||||
return (
|
||||
<div className="flex flex-col shrink-0 mx-3 mt-3 border border-border bg-card">
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2 gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setMobileSidebarOpen((open) => !open)}
|
||||
className="md:hidden size-8 rounded-none"
|
||||
title={t("fileManager.toggleSidebar")}
|
||||
>
|
||||
<Layout className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={goBack}
|
||||
disabled={navIndex <= 0}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<ChevronLeft className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={goForward}
|
||||
disabled={navIndex >= navHistoryLength - 1}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={goUp}
|
||||
disabled={currentPath === "/"}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<ArrowUp className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRefreshDirectory}
|
||||
className="size-8 rounded-none"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-4 ${isLoading && !!sshSessionId ? "animate-spin [animation-duration:0.5s]" : ""}`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex flex-1 items-center px-3 h-8 bg-muted/50 border border-border rounded-none gap-2 overflow-hidden">
|
||||
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-accent-brand/10 border border-accent-brand/20 text-accent-brand text-[10px] font-black uppercase tracking-tighter">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
|
||||
onClick={() => handleDeleteFiles(selectedFiles)}
|
||||
>
|
||||
<Trash2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 text-accent-brand hover:bg-accent-brand/20 rounded-none"
|
||||
onClick={() => handleCopyFiles(selectedFiles)}
|
||||
>
|
||||
<Copy className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative w-28 md:w-48">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("fileManager.searchFiles")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8 pl-8 text-xs bg-muted/50 border-border rounded-none focus:ring-1 focus:ring-accent-brand/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center border border-border rounded-none overflow-hidden">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => setViewMode("grid")}
|
||||
className={`size-8 rounded-none border-y-0 border-l-0 border-r border-border ${viewMode === "grid" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
|
||||
>
|
||||
<Grid3X3 className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`size-8 rounded-none border-y-0 border-r-0 border-border ${viewMode === "list" ? "bg-accent-brand/10 text-accent-brand" : ""}`}
|
||||
>
|
||||
<List className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className="hidden md:block cursor-pointer"
|
||||
title={t("fileManager.upload")}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = e.target.files;
|
||||
if (files) handleFilesDropped(files);
|
||||
}}
|
||||
/>
|
||||
<div className="h-8 px-3 flex items-center gap-1.5 border border-border hover:bg-muted text-muted-foreground hover:text-foreground transition-colors text-[10px] font-bold uppercase tracking-widest">
|
||||
<Upload className="size-3.5" /> {t("fileManager.upload")}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 rounded-none font-bold uppercase tracking-widest text-[10px]"
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
{t("fileManager.new")}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-44 rounded-none border-border bg-card"
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setTimeout(() => handleCreateNewFolder(), 0);
|
||||
}}
|
||||
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
|
||||
>
|
||||
<FolderPlus className="size-4 text-accent-brand" />
|
||||
{t("fileManager.newFolder")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
setTimeout(() => handleCreateNewFile(), 0);
|
||||
}}
|
||||
className="rounded-none text-xs font-semibold gap-2 focus:bg-accent-brand/10 focus:text-accent-brand"
|
||||
>
|
||||
<FilePlus className="size-4 text-muted-foreground" />
|
||||
{t("fileManager.newFile")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-[10px] uppercase tracking-widest text-muted-foreground py-1">
|
||||
{t("fileManager.sortBy")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortBy}
|
||||
onValueChange={(value) => setSortBy(value as SortBy)}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value="name"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.sortByName")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="modified"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.sortByDate")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="size"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.sortBySize")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortOrder}
|
||||
onValueChange={(value) => setSortOrder(value as SortOrder)}
|
||||
>
|
||||
<DropdownMenuRadioItem
|
||||
value="asc"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.ascending")}
|
||||
</DropdownMenuRadioItem>
|
||||
<DropdownMenuRadioItem
|
||||
value="desc"
|
||||
className="rounded-none text-xs"
|
||||
>
|
||||
{t("fileManager.descending")}
|
||||
</DropdownMenuRadioItem>
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden flex items-center px-3 pb-2 gap-2">
|
||||
<div className="flex-1 flex items-center px-3 h-8 bg-muted/50 border border-border gap-2 overflow-hidden">
|
||||
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getTransferStatus, listActiveTransfers } from "@/main-axios.ts";
|
||||
import { createFormatTransferMetrics } from "./transferMetricsFormat.ts";
|
||||
import {
|
||||
beginTransferProgressMonitoring,
|
||||
isTransferBeingMonitored,
|
||||
showTransferCompletionToast,
|
||||
} from "./transferProgressMonitor.tsx";
|
||||
import {
|
||||
clearStalePendingTransfer,
|
||||
getPendingTransferIds,
|
||||
isTransferNotified,
|
||||
} from "./transferNotificationStore.ts";
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
export function TransferMonitor() {
|
||||
const { t } = useTranslation();
|
||||
const formatTransferMetrics = useMemo(
|
||||
() => createFormatTransferMetrics(t),
|
||||
[t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const reconcileTransfers = async () => {
|
||||
try {
|
||||
const { transfers } = await listActiveTransfers();
|
||||
for (const transfer of transfers) {
|
||||
if (isTransferBeingMonitored(transfer.transferId)) continue;
|
||||
beginTransferProgressMonitoring(transfer.transferId, t, {
|
||||
resumed: true,
|
||||
initialStatus: transfer,
|
||||
formatTransferMetrics,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: file-manager service may be unavailable briefly
|
||||
}
|
||||
|
||||
for (const transferId of getPendingTransferIds()) {
|
||||
if (
|
||||
isTransferBeingMonitored(transferId) ||
|
||||
isTransferNotified(transferId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await getTransferStatus(transferId);
|
||||
if (status.status === "running") {
|
||||
if (!isTransferBeingMonitored(transferId)) {
|
||||
beginTransferProgressMonitoring(transferId, t, {
|
||||
resumed: true,
|
||||
initialStatus: status,
|
||||
formatTransferMetrics,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
showTransferCompletionToast(
|
||||
status,
|
||||
t,
|
||||
undefined,
|
||||
formatTransferMetrics,
|
||||
);
|
||||
} catch {
|
||||
clearStalePendingTransfer(transferId);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void reconcileTransfers();
|
||||
const interval = setInterval(reconcileTransfers, POLL_INTERVAL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [t, formatTransferMetrics]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DiffEditor } from "@monaco-editor/react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { Suspense, lazy, useState, useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
SiDocker,
|
||||
} from "react-icons/si";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Kbd, KbdKey } from "@/components/kbd.tsx";
|
||||
import type { CodeEditorHandle } from "./CodeEditor.tsx";
|
||||
|
||||
const CodeEditor = lazy(() =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow.tsx";
|
||||
import { FileViewer } from "./FileViewer.tsx";
|
||||
@@ -126,7 +127,10 @@ export function FileWindow({
|
||||
|
||||
if (response.encoding === "base64") {
|
||||
try {
|
||||
const decoded = atob(fileContent);
|
||||
const bytes = Uint8Array.from(atob(fileContent), (c) =>
|
||||
c.charCodeAt(0),
|
||||
);
|
||||
const decoded = new TextDecoder("utf-8").decode(bytes);
|
||||
if (isDisplayableText(decoded)) {
|
||||
fileContent = decoded;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import React from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow.tsx";
|
||||
import { Terminal } from "@/features/terminal/Terminal.tsx";
|
||||
import {
|
||||
Terminal,
|
||||
type TerminalHandle,
|
||||
type TerminalHostConfig,
|
||||
} from "@/features/terminal/Terminal.tsx";
|
||||
import { useWindowManager } from "./WindowManager.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CommandHistoryProvider } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
@@ -26,7 +30,7 @@ export function TerminalWindow({
|
||||
const { t } = useTranslation();
|
||||
const { closeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
const terminalRef = React.useRef<{ fit?: () => void } | null>(null);
|
||||
const terminalRef = React.useRef<TerminalHandle | null>(null);
|
||||
const resizeTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -101,8 +105,8 @@ export function TerminalWindow({
|
||||
zIndex={currentWindow.zIndex}
|
||||
>
|
||||
<Terminal
|
||||
ref={terminalRef as any}
|
||||
hostConfig={hostConfig as any}
|
||||
ref={terminalRef}
|
||||
hostConfig={hostConfig as TerminalHostConfig}
|
||||
isVisible={!currentWindow.isMinimized}
|
||||
initialPath={initialPath}
|
||||
executeCommand={executeCommand}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
formatTransferMbPerSec,
|
||||
getTransferProgressPercent,
|
||||
type TransferProgressResponse,
|
||||
} from "@/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface TransferProgressToastProps {
|
||||
status: TransferProgressResponse;
|
||||
liveMbPerSec?: number;
|
||||
stalled?: boolean;
|
||||
formatSize: (bytes?: number) => string;
|
||||
onCancel?: () => void;
|
||||
cancelling?: boolean;
|
||||
}
|
||||
|
||||
function IndeterminateProgressBar() {
|
||||
return (
|
||||
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
|
||||
<div className="bg-primary/60 absolute inset-y-0 left-0 w-1/3 animate-pulse rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeterminateProgressBar({ value }: { value: number }) {
|
||||
const clamped = Math.min(100, Math.max(0, value));
|
||||
return (
|
||||
<div className="bg-primary/20 relative h-2 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className="bg-primary h-full rounded-full transition-[width]"
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransferProgressToast({
|
||||
status,
|
||||
liveMbPerSec,
|
||||
stalled = false,
|
||||
formatSize,
|
||||
onCancel,
|
||||
cancelling = false,
|
||||
}: TransferProgressToastProps) {
|
||||
const { t } = useTranslation();
|
||||
const percent = getTransferProgressPercent(status);
|
||||
|
||||
let title = t("transfer.progressTransferring");
|
||||
if (status.phase === "reconnecting") {
|
||||
title = t("transfer.progressReconnecting");
|
||||
} else if (status.phase === "compressing") {
|
||||
title = t("transfer.progressCompressing");
|
||||
} else if (status.phase === "extracting") {
|
||||
title = t("transfer.progressExtracting");
|
||||
} else if (
|
||||
status.method === "item_sftp" &&
|
||||
status.totalItems !== undefined &&
|
||||
status.itemsCompleted !== undefined
|
||||
) {
|
||||
title = t("transfer.progressTransferringItems", {
|
||||
current: status.itemsCompleted,
|
||||
total: status.totalItems,
|
||||
});
|
||||
}
|
||||
|
||||
const hasByteProgress =
|
||||
status.bytesTransferred !== undefined &&
|
||||
status.totalBytes !== undefined &&
|
||||
status.totalBytes > 0;
|
||||
|
||||
const detailLeft = hasByteProgress
|
||||
? t("transfer.progressBytes", {
|
||||
transferred: formatSize(status.bytesTransferred),
|
||||
total: formatSize(status.totalBytes),
|
||||
})
|
||||
: status.method === "item_sftp" &&
|
||||
status.totalItems !== undefined &&
|
||||
status.itemsCompleted !== undefined
|
||||
? t("transfer.progressItems", {
|
||||
current: status.itemsCompleted,
|
||||
total: status.totalItems,
|
||||
})
|
||||
: null;
|
||||
|
||||
const liveRate =
|
||||
status.phase === "reconnecting"
|
||||
? undefined
|
||||
: stalled
|
||||
? t("transfer.progressStalled")
|
||||
: liveMbPerSec !== undefined
|
||||
? status.parallelSegmentCount && status.parallelSegmentCount > 1
|
||||
? t("transfer.progressTotalSpeed", {
|
||||
speed: formatTransferMbPerSec(liveMbPerSec),
|
||||
lanes: status.parallelSegmentCount,
|
||||
})
|
||||
: formatTransferMbPerSec(liveMbPerSec)
|
||||
: undefined;
|
||||
|
||||
const showIndeterminate =
|
||||
status.phase === "reconnecting" || percent === undefined;
|
||||
|
||||
return (
|
||||
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium leading-tight">{title}</p>
|
||||
{onCancel && status.status === "running" && status.transferId && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2 text-xs"
|
||||
disabled={cancelling}
|
||||
onClick={onCancel}
|
||||
>
|
||||
{cancelling
|
||||
? t("transfer.progressCancelling")
|
||||
: t("transfer.progressCancel")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showIndeterminate ? (
|
||||
<IndeterminateProgressBar />
|
||||
) : (
|
||||
<DeterminateProgressBar value={percent} />
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3 pr-1 text-xs text-muted-foreground">
|
||||
<span className="min-w-0 truncate">{detailLeft ?? ""}</span>
|
||||
<span
|
||||
className={`shrink-0 tabular-nums ${stalled ? "text-amber-500" : liveRate ? "font-medium text-foreground" : "invisible"}`}
|
||||
aria-hidden={!liveRate}
|
||||
>
|
||||
{liveRate ?? "0 MB/s"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
|
||||
export interface WindowInstance {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { FileItem, SSHHost } from "@/types/index";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
|
||||
export interface FileManagerProps {
|
||||
initialHost?: SSHHost | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export type ConnectionLogPayload = Omit<LogEntry, "id" | "timestamp">;
|
||||
|
||||
export type SSHConnectionError = Error & {
|
||||
connectionLogs?: ConnectionLogPayload[];
|
||||
requires_totp?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
status?: string;
|
||||
reason?: "no_keyboard" | "auth_failed" | "timeout";
|
||||
};
|
||||
|
||||
export interface CreateIntent {
|
||||
id: string;
|
||||
type: "file" | "directory";
|
||||
defaultName: string;
|
||||
currentName: string;
|
||||
}
|
||||
|
||||
export type PendingSudoOperation =
|
||||
| { type: "delete"; files: FileItem[] }
|
||||
| { type: "navigate"; path: string };
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatFileSize } from "./file-manager-utils.js";
|
||||
|
||||
describe("formatFileSize", () => {
|
||||
it("returns a dash for undefined or null", () => {
|
||||
expect(formatFileSize(undefined)).toBe("-");
|
||||
expect(formatFileSize(null as unknown as number)).toBe("-");
|
||||
});
|
||||
|
||||
it("returns 0 B for zero", () => {
|
||||
expect(formatFileSize(0)).toBe("0 B");
|
||||
});
|
||||
|
||||
it("formats bytes without decimals", () => {
|
||||
expect(formatFileSize(512)).toBe("512 B");
|
||||
expect(formatFileSize(1023)).toBe("1023 B");
|
||||
});
|
||||
|
||||
it("formats kilobytes with one decimal under 10", () => {
|
||||
expect(formatFileSize(1024)).toBe("1.0 KB");
|
||||
expect(formatFileSize(1536)).toBe("1.5 KB");
|
||||
});
|
||||
|
||||
it("rounds to whole numbers at or above 10 units", () => {
|
||||
expect(formatFileSize(10 * 1024)).toBe("10 KB");
|
||||
expect(formatFileSize(1024 * 1024)).toBe("1.0 MB");
|
||||
});
|
||||
|
||||
it("scales up to larger units", () => {
|
||||
expect(formatFileSize(1024 * 1024 * 1024)).toBe("1.0 GB");
|
||||
expect(formatFileSize(1024 * 1024 * 1024 * 1024)).toBe("1.0 TB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export function formatFileSize(bytes?: number): string {
|
||||
if (bytes === undefined || bytes === null) return "-";
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
const formattedSize =
|
||||
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
|
||||
return `${formattedSize} ${units[unitIndex]}`;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { downloadSSHFile } from "@/main-axios";
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useFileSelection } from "./useFileSelection.js";
|
||||
|
||||
type FileItem = {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
};
|
||||
|
||||
const f = (name: string): FileItem => ({
|
||||
name,
|
||||
type: "file",
|
||||
path: `/dir/${name}`,
|
||||
});
|
||||
|
||||
describe("useFileSelection", () => {
|
||||
it("single-selects, replacing prior selection", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.selectFile(f("a")));
|
||||
act(() => result.current.selectFile(f("b")));
|
||||
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
|
||||
expect(result.current.getSelectedCount()).toBe(1);
|
||||
});
|
||||
|
||||
it("multi-selects and toggles off on repeat", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.selectFile(f("a"), true));
|
||||
act(() => result.current.selectFile(f("b"), true));
|
||||
expect(result.current.getSelectedCount()).toBe(2);
|
||||
act(() => result.current.selectFile(f("a"), true));
|
||||
expect(result.current.selectedFiles.map((x) => x.name)).toEqual(["b"]);
|
||||
});
|
||||
|
||||
it("reports isSelected by path", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.selectFile(f("a")));
|
||||
expect(result.current.isSelected(f("a"))).toBe(true);
|
||||
expect(result.current.isSelected(f("z"))).toBe(false);
|
||||
});
|
||||
|
||||
it("selects a contiguous range regardless of direction", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
const files = [f("a"), f("b"), f("c"), f("d")];
|
||||
act(() => result.current.selectRange(files, files[3], files[1]));
|
||||
expect(result.current.selectedFiles.map((x) => x.name)).toEqual([
|
||||
"b",
|
||||
"c",
|
||||
"d",
|
||||
]);
|
||||
});
|
||||
|
||||
it("selects all and clears", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
const files = [f("a"), f("b")];
|
||||
act(() => result.current.selectAll(files));
|
||||
expect(result.current.getSelectedCount()).toBe(2);
|
||||
act(() => result.current.clearSelection());
|
||||
expect(result.current.getSelectedCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("toggleSelection adds then removes", () => {
|
||||
const { result } = renderHook(() => useFileSelection());
|
||||
act(() => result.current.toggleSelection(f("a")));
|
||||
expect(result.current.isSelected(f("a"))).toBe(true);
|
||||
act(() => result.current.toggleSelection(f("a")));
|
||||
expect(result.current.isSelected(f("a"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
formatDurationMs,
|
||||
formatTransferMbPerSec,
|
||||
type TransferTimings,
|
||||
} from "@/main-axios.ts";
|
||||
|
||||
export function createFormatTransferMetrics(t: TFunction) {
|
||||
return (timings?: TransferTimings): string => {
|
||||
if (!timings) return "";
|
||||
const parts: string[] = [];
|
||||
if (timings.prepareDestMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsPrepare", {
|
||||
duration: formatDurationMs(timings.prepareDestMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.compressMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsCompress", {
|
||||
duration: formatDurationMs(timings.compressMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
for (const hop of timings.hops ?? []) {
|
||||
const hopKey =
|
||||
hop.id === "source_read"
|
||||
? "transfer.metricsHopSourceRead"
|
||||
: hop.id === "dest_local_write"
|
||||
? "transfer.metricsHopDestLocalWrite"
|
||||
: "transfer.metricsHopDestSftpWrite";
|
||||
parts.push(
|
||||
t(hopKey, {
|
||||
throughput: formatTransferMbPerSec(hop.mbPerSec),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.transferMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsTransfer", {
|
||||
duration: formatDurationMs(timings.transferMs),
|
||||
throughput: formatTransferMbPerSec(
|
||||
timings.endToEndMbPerSec,
|
||||
timings.transferBytes,
|
||||
timings.transferMs,
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.extractMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsExtract", {
|
||||
duration: formatDurationMs(timings.extractMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.sourceDeleteMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsSourceDelete", {
|
||||
duration: formatDurationMs(timings.sourceDeleteMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (timings.totalMs !== undefined) {
|
||||
parts.push(
|
||||
t("transfer.metricsTotal", {
|
||||
duration: formatDurationMs(timings.totalMs),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
const PENDING_KEY = "termix_pending_transfers";
|
||||
const NOTIFIED_KEY = "termix_notified_transfers";
|
||||
|
||||
function readJsonArray(key: string): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((id): id is string => typeof id === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function writeJsonArray(key: string, values: string[]): void {
|
||||
localStorage.setItem(key, JSON.stringify(values));
|
||||
}
|
||||
|
||||
export function registerPendingTransfer(transferId: string): void {
|
||||
const pending = readJsonArray(PENDING_KEY);
|
||||
if (!pending.includes(transferId)) {
|
||||
writeJsonArray(PENDING_KEY, [...pending, transferId]);
|
||||
}
|
||||
}
|
||||
|
||||
export function markTransferNotified(transferId: string): void {
|
||||
const notified = readJsonArray(NOTIFIED_KEY);
|
||||
if (!notified.includes(transferId)) {
|
||||
writeJsonArray(NOTIFIED_KEY, [...notified, transferId].slice(-200));
|
||||
}
|
||||
writeJsonArray(
|
||||
PENDING_KEY,
|
||||
readJsonArray(PENDING_KEY).filter((id) => id !== transferId),
|
||||
);
|
||||
}
|
||||
|
||||
export function isTransferNotified(transferId: string): boolean {
|
||||
return readJsonArray(NOTIFIED_KEY).includes(transferId);
|
||||
}
|
||||
|
||||
export function getPendingTransferIds(): string[] {
|
||||
return readJsonArray(PENDING_KEY);
|
||||
}
|
||||
|
||||
export function clearStalePendingTransfer(transferId: string): void {
|
||||
writeJsonArray(
|
||||
PENDING_KEY,
|
||||
readJsonArray(PENDING_KEY).filter((id) => id !== transferId),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { toast } from "sonner";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
pollTransferUntilComplete,
|
||||
cancelTransferToHost,
|
||||
cleanupCancelledTransfer,
|
||||
retryTransferToHost,
|
||||
createTransferProgressTracker,
|
||||
type TransferProgressResponse,
|
||||
type TransferTimings,
|
||||
} from "@/main-axios.ts";
|
||||
import { TransferProgressToast } from "./components/TransferProgressToast.tsx";
|
||||
import {
|
||||
markTransferNotified,
|
||||
registerPendingTransfer,
|
||||
} from "./transferNotificationStore.ts";
|
||||
|
||||
const monitoredTransferIds = new Set<string>();
|
||||
const TOAST_CLASS = "!pr-10 !pl-4 transfer-progress-toast";
|
||||
|
||||
function formatFileSize(bytes?: number): string {
|
||||
if (bytes === undefined || bytes === null) return "-";
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let size = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (size >= 1024 && unitIndex < units.length - 1) {
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
const formattedSize =
|
||||
size < 10 && unitIndex > 0 ? size.toFixed(1) : Math.round(size).toString();
|
||||
return `${formattedSize} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function renderTransferProgressToast(
|
||||
toastId: string | number,
|
||||
status: TransferProgressResponse,
|
||||
liveMbPerSec?: number,
|
||||
onCancel?: () => void,
|
||||
cancelling?: boolean,
|
||||
stalled?: boolean,
|
||||
): void {
|
||||
toast.loading(
|
||||
<TransferProgressToast
|
||||
status={status}
|
||||
liveMbPerSec={liveMbPerSec}
|
||||
stalled={stalled}
|
||||
formatSize={formatFileSize}
|
||||
onCancel={onCancel}
|
||||
cancelling={cancelling}
|
||||
/>,
|
||||
{ id: toastId, duration: Infinity, className: TOAST_CLASS },
|
||||
);
|
||||
}
|
||||
|
||||
export function showTransferCompletionToast(
|
||||
finalStatus: TransferProgressResponse,
|
||||
t: TFunction,
|
||||
toastId?: string | number,
|
||||
formatTransferMetrics?: (timings?: TransferTimings) => string,
|
||||
): void {
|
||||
showDefaultCompletionToast(
|
||||
finalStatus,
|
||||
toastId ?? `transfer-done-${finalStatus.transferId}`,
|
||||
t,
|
||||
formatTransferMetrics,
|
||||
);
|
||||
markTransferNotified(finalStatus.transferId);
|
||||
}
|
||||
|
||||
export function isTransferBeingMonitored(transferId: string): boolean {
|
||||
return monitoredTransferIds.has(transferId);
|
||||
}
|
||||
|
||||
export interface TransferMonitorHandle {
|
||||
toastId: string | number;
|
||||
waitForCompletion: Promise<TransferProgressResponse>;
|
||||
}
|
||||
|
||||
export interface BeginTransferMonitoringOptions {
|
||||
resumed?: boolean;
|
||||
initialStatus?: Partial<TransferProgressResponse>;
|
||||
onComplete?: (
|
||||
finalStatus: TransferProgressResponse,
|
||||
toastId: string | number,
|
||||
) => void;
|
||||
formatTransferMetrics?: (timings?: TransferTimings) => string;
|
||||
}
|
||||
|
||||
function showCancelledTransferToast(
|
||||
finalStatus: TransferProgressResponse,
|
||||
toastId: string | number,
|
||||
t: TFunction,
|
||||
): void {
|
||||
const hasPartialDest =
|
||||
(finalStatus.partialDestRemaining ??
|
||||
(finalStatus.bytesTransferred ?? 0) > 0) ||
|
||||
(finalStatus.itemsCompleted ?? 0) > 0;
|
||||
|
||||
let description: string | undefined;
|
||||
if (finalStatus.moveRequested && hasPartialDest) {
|
||||
description = t("transfer.transferCancelledMoveHint");
|
||||
} else if (hasPartialDest) {
|
||||
description = t("transfer.transferCancelledCopyHint");
|
||||
}
|
||||
|
||||
const showCleanupAction = hasPartialDest && !finalStatus.cleanupCompleted;
|
||||
|
||||
toast.info(t("transfer.transferCancelled"), {
|
||||
id: toastId,
|
||||
description,
|
||||
className: TOAST_CLASS,
|
||||
duration: showCleanupAction ? Infinity : undefined,
|
||||
action: showCleanupAction
|
||||
? {
|
||||
label: t("transfer.cleanupDestFiles"),
|
||||
onClick: () => {
|
||||
void cleanupCancelledTransfer(finalStatus.transferId)
|
||||
.then((result) => {
|
||||
toast.dismiss(toastId);
|
||||
if (result.failedPaths.length > 0) {
|
||||
toast.warning(t("transfer.cleanupDestFilesPartial"));
|
||||
} else if (result.removedPaths.length > 0) {
|
||||
toast.success(t("transfer.cleanupDestFilesSuccess"));
|
||||
} else {
|
||||
toast.info(t("transfer.cleanupDestFilesNothing"));
|
||||
}
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("fileManager.unknownError");
|
||||
toast.error(
|
||||
`${t("transfer.cleanupDestFilesError")}: ${message}`,
|
||||
);
|
||||
});
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function showFailedTransferToast(
|
||||
finalStatus: TransferProgressResponse,
|
||||
toastId: string | number,
|
||||
t: TFunction,
|
||||
formatTransferMetrics?: (timings?: TransferTimings) => string,
|
||||
): void {
|
||||
const hasPartial =
|
||||
(finalStatus.partialDestRemaining ??
|
||||
(finalStatus.bytesTransferred ?? 0) > 0) ||
|
||||
(finalStatus.itemsCompleted ?? 0) > 0;
|
||||
|
||||
const descriptionParts: string[] = [];
|
||||
if (finalStatus.message) {
|
||||
descriptionParts.push(finalStatus.message);
|
||||
}
|
||||
if (finalStatus.retryable && hasPartial) {
|
||||
descriptionParts.push(t("transfer.transferFailedRetryHint"));
|
||||
}
|
||||
|
||||
const showRetry = finalStatus.retryable === true;
|
||||
|
||||
toast.error(t("transfer.transferError"), {
|
||||
id: toastId,
|
||||
description:
|
||||
descriptionParts.length > 0 ? descriptionParts.join(" ") : undefined,
|
||||
className: TOAST_CLASS,
|
||||
duration: showRetry ? Infinity : undefined,
|
||||
action: showRetry
|
||||
? {
|
||||
label: t("transfer.retryTransfer"),
|
||||
onClick: () => {
|
||||
void retryTransferToHost(finalStatus.transferId)
|
||||
.then(() => {
|
||||
toast.dismiss(toastId);
|
||||
beginTransferProgressMonitoring(finalStatus.transferId, t, {
|
||||
resumed: true,
|
||||
formatTransferMetrics,
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("fileManager.unknownError");
|
||||
toast.error(`${t("transfer.retryTransferError")}: ${message}`);
|
||||
});
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
function showDefaultCompletionToast(
|
||||
finalStatus: TransferProgressResponse,
|
||||
toastId: string | number,
|
||||
t: TFunction,
|
||||
formatTransferMetrics?: (timings?: TransferTimings) => string,
|
||||
): void {
|
||||
if (finalStatus.status === "cancelled") {
|
||||
showCancelledTransferToast(finalStatus, toastId, t);
|
||||
return;
|
||||
}
|
||||
|
||||
if (finalStatus.status === "error") {
|
||||
showFailedTransferToast(finalStatus, toastId, t, formatTransferMetrics);
|
||||
return;
|
||||
}
|
||||
|
||||
if (finalStatus.status === "partial") {
|
||||
const failed = finalStatus.failedPaths?.join(", ") || "";
|
||||
const metrics = formatTransferMetrics?.(finalStatus.timings);
|
||||
toast.warning(
|
||||
t("transfer.transferPartialHint", {
|
||||
paths: failed,
|
||||
count: finalStatus.failedPaths?.length || 0,
|
||||
}),
|
||||
{
|
||||
id: toastId,
|
||||
description: metrics || undefined,
|
||||
className: TOAST_CLASS,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const metrics = formatTransferMetrics?.(finalStatus.timings);
|
||||
toast.success(t("transfer.transferSuccess"), {
|
||||
id: toastId,
|
||||
description: metrics || undefined,
|
||||
className: TOAST_CLASS,
|
||||
});
|
||||
}
|
||||
|
||||
export function beginTransferProgressMonitoring(
|
||||
transferId: string,
|
||||
t: TFunction,
|
||||
options: BeginTransferMonitoringOptions = {},
|
||||
): TransferMonitorHandle | null {
|
||||
if (monitoredTransferIds.has(transferId)) {
|
||||
return null;
|
||||
}
|
||||
monitoredTransferIds.add(transferId);
|
||||
registerPendingTransfer(transferId);
|
||||
|
||||
const progressTracker = createTransferProgressTracker();
|
||||
let cancelling = false;
|
||||
|
||||
const initialStatus: TransferProgressResponse = {
|
||||
transferId,
|
||||
status: "running",
|
||||
phase: "transferring",
|
||||
...options.initialStatus,
|
||||
};
|
||||
|
||||
const progressToast = toast.loading(
|
||||
<TransferProgressToast
|
||||
status={initialStatus}
|
||||
formatSize={formatFileSize}
|
||||
/>,
|
||||
{
|
||||
duration: Infinity,
|
||||
description: options.resumed ? t("transfer.resumedHint") : undefined,
|
||||
className: TOAST_CLASS,
|
||||
},
|
||||
);
|
||||
|
||||
const handleCancelTransfer = () => {
|
||||
if (cancelling) return;
|
||||
cancelling = true;
|
||||
renderTransferProgressToast(
|
||||
progressToast,
|
||||
{ ...initialStatus, transferId },
|
||||
undefined,
|
||||
handleCancelTransfer,
|
||||
true,
|
||||
);
|
||||
void cancelTransferToHost(transferId);
|
||||
};
|
||||
|
||||
const waitForCompletion = pollTransferUntilComplete(
|
||||
transferId,
|
||||
(status) => {
|
||||
const { rate, stalled } = progressTracker.update(status);
|
||||
renderTransferProgressToast(
|
||||
progressToast,
|
||||
status,
|
||||
rate,
|
||||
handleCancelTransfer,
|
||||
cancelling,
|
||||
stalled,
|
||||
);
|
||||
},
|
||||
250,
|
||||
)
|
||||
.then((finalStatus) => {
|
||||
markTransferNotified(transferId);
|
||||
if (options.onComplete) {
|
||||
options.onComplete(finalStatus, progressToast);
|
||||
} else {
|
||||
showDefaultCompletionToast(
|
||||
finalStatus,
|
||||
progressToast,
|
||||
t,
|
||||
options.formatTransferMetrics,
|
||||
);
|
||||
}
|
||||
return finalStatus;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error ? error.message : t("fileManager.unknownError");
|
||||
toast.error(`${t("transfer.transferError")}: ${message}`, {
|
||||
id: progressToast,
|
||||
className: TOAST_CLASS,
|
||||
});
|
||||
markTransferNotified(transferId);
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
monitoredTransferIds.delete(transferId);
|
||||
});
|
||||
|
||||
renderTransferProgressToast(
|
||||
progressToast,
|
||||
initialStatus,
|
||||
undefined,
|
||||
handleCancelTransfer,
|
||||
cancelling,
|
||||
);
|
||||
|
||||
return { toastId: progressToast, waitForCompletion };
|
||||
}
|
||||
@@ -124,7 +124,7 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
if (result) setToken(result.token);
|
||||
})
|
||||
.catch((err) => setError(err?.message || t("guacamole.failedToConnect")));
|
||||
}, [hostId, retryCount]);
|
||||
}, [hostId, protocol, retryCount, t]);
|
||||
|
||||
const handleReconnect = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
@@ -236,11 +236,7 @@ const GuacamoleAppInner: React.FC<GuacamoleAppInnerProps> = ({
|
||||
isVisible={true}
|
||||
onError={(err) => setConnectionError(err)}
|
||||
/>
|
||||
<GuacamoleToolbar
|
||||
displayRef={displayRef}
|
||||
protocol={resolvedProtocol}
|
||||
onReconnect={handleReconnect}
|
||||
/>
|
||||
<GuacamoleToolbar displayRef={displayRef} protocol={resolvedProtocol} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -437,6 +437,9 @@ export const GuacamoleDisplay = forwardRef<
|
||||
onError,
|
||||
refreshKeyboardHandlers,
|
||||
rescaleDisplay,
|
||||
connectionConfig.protocol,
|
||||
connectionConfig.type,
|
||||
t,
|
||||
]);
|
||||
|
||||
const hasInitiatedRef = useRef(false);
|
||||
@@ -520,7 +523,6 @@ export const GuacamoleDisplay = forwardRef<
|
||||
const h = Math.round(rect.height);
|
||||
if (w > 0 && h > 0) {
|
||||
clientRef.current.sendSize(w, h);
|
||||
rescaleDisplay(true);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
|
||||
@@ -27,7 +27,6 @@ import { cn } from "@/lib/utils";
|
||||
interface GuacamoleToolbarProps {
|
||||
displayRef: React.RefObject<GuacamoleDisplayHandle>;
|
||||
protocol: "rdp" | "vnc" | "telnet";
|
||||
onReconnect: () => void;
|
||||
}
|
||||
|
||||
const MODIFIER_KEYSYMS = {
|
||||
@@ -108,7 +107,6 @@ function TipIconBtn({
|
||||
export const GuacamoleToolbar: React.FC<GuacamoleToolbarProps> = ({
|
||||
displayRef,
|
||||
protocol,
|
||||
onReconnect,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [position, setPosition] = useState({ x: 0, y: 12 });
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Network, WifiOff } from "lucide-react";
|
||||
import { Cable, Container, Network, Wifi, WifiOff } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
@@ -24,6 +24,16 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
|
||||
};
|
||||
const interfaces = metricsWithNetwork?.network?.interfaces ?? [];
|
||||
|
||||
const ifaceIcon = (name: string) => {
|
||||
if (/^(wl|wlan|wifi|ath)/.test(name))
|
||||
return <Wifi className="size-3 text-muted-foreground/50" />;
|
||||
if (/^(eth|en|enp|eno|ens)/.test(name))
|
||||
return <Cable className="size-3 text-muted-foreground/50" />;
|
||||
if (/^(docker|br-|veth|virbr|vlan|bond|tun|tap|wg|lo)/.test(name))
|
||||
return <Container className="size-3 text-muted-foreground/50" />;
|
||||
return <Network className="size-3 text-muted-foreground/50" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.networkInterfaces")}
|
||||
@@ -46,6 +56,7 @@ export function NetworkWidget({ metrics }: NetworkWidgetProps) {
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{ifaceIcon(iface.name)}
|
||||
<div
|
||||
className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
@@ -33,11 +34,10 @@ import { OPKSSHDialog } from "@/ssh/dialogs/OPKSSHDialog.tsx";
|
||||
import { HostKeyVerificationDialog } from "@/ssh/dialogs/HostKeyVerificationDialog.tsx";
|
||||
import { TmuxSessionPicker } from "@/ssh/dialogs/TmuxSessionPicker.tsx";
|
||||
import {
|
||||
TERMINAL_THEMES,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
TERMINAL_FONTS,
|
||||
} from "@/lib/terminal-themes.ts";
|
||||
import type { TerminalConfig } from "@/types";
|
||||
import "./terminal-global-styles.ts";
|
||||
import { useTheme } from "@/components/theme-provider.tsx";
|
||||
import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts";
|
||||
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
|
||||
@@ -52,72 +52,9 @@ import {
|
||||
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;
|
||||
restoredSessionId?: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
terminalConfig?: TerminalConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface TerminalHandle {
|
||||
disconnect: () => void;
|
||||
reconnect: () => void;
|
||||
fit: () => void;
|
||||
sendInput: (data: string) => void;
|
||||
notifyResize: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
import { resolveTermixThemeColors } from "./terminal-theme.ts";
|
||||
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
|
||||
export type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
|
||||
|
||||
type HostKeyVerificationData = Omit<
|
||||
React.ComponentProps<typeof HostKeyVerificationDialog>,
|
||||
@@ -125,7 +62,7 @@ type HostKeyVerificationData = Omit<
|
||||
>;
|
||||
|
||||
interface SSHTerminalProps {
|
||||
hostConfig: HostConfig;
|
||||
hostConfig: TerminalHostConfig;
|
||||
isVisible: boolean;
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
@@ -203,8 +140,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
>("no_keyboard");
|
||||
const [showPassphraseDialog, setShowPassphraseDialog] = useState(false);
|
||||
const [keyboardInteractiveDetected, setKeyboardInteractiveDetected] =
|
||||
useState(false);
|
||||
const [, setKeyboardInteractiveDetected] = useState(false);
|
||||
const [warpgateAuthRequired, setWarpgateAuthRequired] = useState(false);
|
||||
const [warpgateAuthUrl, setWarpgateAuthUrl] = useState<string>("");
|
||||
const [warpgateSecurityKey, setWarpgateSecurityKey] = useState<string>("");
|
||||
@@ -222,7 +158,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const opksshFailedRef = useRef(false);
|
||||
const currentHostIdRef = useRef<number | null>(null);
|
||||
const currentHostConfigRef = useRef<HostConfig | null>(null);
|
||||
const currentHostConfigRef = useRef<TerminalHostConfig | null>(null);
|
||||
|
||||
const [hostKeyVerification, setHostKeyVerification] = useState<{
|
||||
isOpen: boolean;
|
||||
@@ -233,7 +169,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const isAttachingSessionRef = useRef<boolean>(false);
|
||||
// Consumed on first connectToHost call so retries don't re-attempt a stale session
|
||||
const pendingRestoredSessionIdRef = useRef<string | null>(hostConfig.restoredSessionId ?? null);
|
||||
const pendingRestoredSessionIdRef = useRef<string | null>(
|
||||
hostConfig.restoredSessionId ?? null,
|
||||
);
|
||||
const [tmuxSessionPicker, setTmuxSessionPicker] = useState<{
|
||||
sessions: Array<{
|
||||
name: string;
|
||||
@@ -1052,7 +990,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
terminal.write(outputData);
|
||||
const sudoPasswordPattern =
|
||||
/(?:\[sudo\][^\n]*:\s*$|sudo:[^\n]*password[^\n]*required)/i;
|
||||
/(?:\[sudo\][^\n]*:\s*$|sudo:[^\n]*password[^\n]*required|password for [^\n]*:\s*$|Password:\s*$)/i;
|
||||
const hasSudoPw =
|
||||
hostConfig.terminalConfig?.sudoPassword ||
|
||||
hostConfig.password ||
|
||||
@@ -1484,7 +1422,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
sessionIdRef.current = msg.sessionId;
|
||||
if (hostConfig.instanceId) {
|
||||
import("@/main-axios").then(({ patchOpenTab }) => {
|
||||
patchOpenTab(hostConfig.instanceId!, { backendSessionId: msg.sessionId }).catch(() => {});
|
||||
patchOpenTab(hostConfig.instanceId!, {
|
||||
backendSessionId: msg.sessionId,
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
} else if (msg.type === "sessionAttached") {
|
||||
@@ -1520,7 +1460,9 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
wasSessionExpiredRef.current = true;
|
||||
if (hostConfig.instanceId) {
|
||||
import("@/main-axios").then(({ patchOpenTab }) => {
|
||||
patchOpenTab(hostConfig.instanceId!, { backendSessionId: null }).catch(() => {});
|
||||
patchOpenTab(hostConfig.instanceId!, {
|
||||
backendSessionId: null,
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
if (webSocketRef.current) {
|
||||
@@ -1993,6 +1935,10 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
terminal.unicode.activeVersion = "11";
|
||||
|
||||
terminal.open(xtermRef.current);
|
||||
document.fonts.ready.then(() => {
|
||||
terminal.refresh(0, terminal.rows - 1);
|
||||
fitAddon.fit();
|
||||
});
|
||||
|
||||
terminal.attachCustomWheelEventHandler((ev) => {
|
||||
const cfg = {
|
||||
@@ -2135,8 +2081,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
isMountedRef.current = true;
|
||||
|
||||
const currentHostId = hostConfig.id;
|
||||
const currentInstanceId = hostConfig.instanceId;
|
||||
|
||||
return () => {
|
||||
if (!isMountedRef.current) {
|
||||
return;
|
||||
@@ -2359,20 +2303,29 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const autocompleteEnabled =
|
||||
localStorage.getItem("commandAutocomplete") === "true";
|
||||
|
||||
if (!autocompleteEnabled) {
|
||||
const sendTabToShell = () => {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: "\t" }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const autocompleteEnabled =
|
||||
localStorage.getItem("commandAutocomplete") === "true";
|
||||
|
||||
if (!autocompleteEnabled) {
|
||||
sendTabToShell();
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentCmd = getCurrentCommandRef.current().trim();
|
||||
if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
|
||||
if (currentCmd.length === 0) {
|
||||
sendTabToShell();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
const matches = autocompleteHistory.current
|
||||
.filter(
|
||||
(cmd) =>
|
||||
@@ -2432,6 +2385,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
|
||||
setShowAutocomplete(true);
|
||||
} else {
|
||||
sendTabToShell();
|
||||
}
|
||||
}
|
||||
return false;
|
||||
@@ -2474,7 +2429,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [terminal, hostConfig.id, isVisible, isConnected, isConnecting]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2769,85 +2723,3 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0,0,0,0.3) transparent;
|
||||
}
|
||||
|
||||
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
.dark .xterm .xterm-viewport {
|
||||
scrollbar-color: rgba(255,255,255,0.3) transparent;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char {
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Source+Code+Pro:ital,wght@0,400;0,700;1,400;1,700&display=swap');
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0,0,0,0.3) transparent;
|
||||
}
|
||||
|
||||
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
.dark .xterm .xterm-viewport {
|
||||
scrollbar-color: rgba(255,255,255,0.3) transparent;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono', 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace !important;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char {
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
@@ -0,0 +1,44 @@
|
||||
import { TERMINAL_THEMES } from "@/lib/terminal-themes.ts";
|
||||
|
||||
// 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" },
|
||||
};
|
||||
|
||||
export 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { TerminalConfig } from "@/types";
|
||||
|
||||
export interface TerminalHostConfig {
|
||||
id?: number;
|
||||
instanceId?: string;
|
||||
restoredSessionId?: string | null;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
authType?: string;
|
||||
credentialId?: number;
|
||||
terminalConfig?: TerminalConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface TerminalHandle {
|
||||
disconnect: () => void;
|
||||
reconnect: () => void;
|
||||
fit: () => void;
|
||||
sendInput: (data: string) => void;
|
||||
notifyResize: () => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
@@ -48,14 +48,12 @@ function statusLabel(status: TunnelStatus | undefined): string {
|
||||
|
||||
function TunnelCard({
|
||||
host,
|
||||
index,
|
||||
tunnel,
|
||||
status,
|
||||
isActing,
|
||||
onAction,
|
||||
}: {
|
||||
host: SSHHost;
|
||||
index: number;
|
||||
tunnel: TunnelConnection;
|
||||
status: TunnelStatus | undefined;
|
||||
isActing: boolean;
|
||||
@@ -261,7 +259,7 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [host?.id]);
|
||||
}, [host]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHost();
|
||||
@@ -284,7 +282,7 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
|
||||
logActivity("tunnel", sshHost.id, name).catch(() => {
|
||||
activityLoggedRef.current = false;
|
||||
});
|
||||
}, [sshHost?.id]);
|
||||
}, [sshHost]);
|
||||
|
||||
const handleAction = async (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
@@ -298,24 +296,31 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
|
||||
setTunnelActions((prev) => ({ ...prev, [name]: true }));
|
||||
try {
|
||||
if (action === "connect") {
|
||||
const allHosts = await getSSHHosts();
|
||||
const endpointSsh = allHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
const isDirect =
|
||||
!tunnel.endpointHost ||
|
||||
tunnel.endpointHost === "127.0.0.1" ||
|
||||
tunnel.endpointHost === "localhost";
|
||||
let endpointSsh: SSHHost | undefined;
|
||||
if (!isDirect) {
|
||||
const allHosts = await getSSHHosts();
|
||||
endpointSsh = allHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
}
|
||||
await connectTunnel({
|
||||
name,
|
||||
scope: tunnel.scope ?? "s2s",
|
||||
mode:
|
||||
tunnel.mode ??
|
||||
(tunnel.tunnelType as "local" | "remote" | "dynamic") ??
|
||||
"remote",
|
||||
"local",
|
||||
tunnelType:
|
||||
tunnel.tunnelType ??
|
||||
(tunnel.mode === "local" || tunnel.mode === "remote"
|
||||
? tunnel.mode
|
||||
: "remote"),
|
||||
: "local"),
|
||||
bindHost: tunnel.bindHost,
|
||||
targetHost: tunnel.targetHost,
|
||||
sourceHostId: sshHost.id,
|
||||
@@ -334,9 +339,13 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
|
||||
sshHost.authType === "key" ? sshHost.keyType : undefined,
|
||||
sourceCredentialId: sshHost.credentialId,
|
||||
endpointHost: tunnel.endpointHost ?? "",
|
||||
endpointIP: endpointSsh?.ip ?? tunnel.endpointHost ?? "",
|
||||
endpointSSHPort: endpointSsh?.port ?? 22,
|
||||
endpointUsername: endpointSsh?.username ?? "",
|
||||
endpointIP: isDirect
|
||||
? sshHost.ip
|
||||
: (endpointSsh?.ip ?? tunnel.endpointHost ?? ""),
|
||||
endpointSSHPort: isDirect ? sshHost.port : (endpointSsh?.port ?? 22),
|
||||
endpointUsername: isDirect
|
||||
? sshHost.username
|
||||
: (endpointSsh?.username ?? ""),
|
||||
endpointPassword:
|
||||
endpointSsh?.authType === "password"
|
||||
? endpointSsh.password
|
||||
@@ -428,7 +437,6 @@ export function TunnelTab({ host }: { label: string; host?: DemoHost }) {
|
||||
<TunnelCard
|
||||
key={name}
|
||||
host={sshHost}
|
||||
index={index}
|
||||
tunnel={tunnel}
|
||||
status={tunnelStatuses[name]}
|
||||
isActing={tunnelActions[name] ?? false}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
getTunnelTypeForMode,
|
||||
getTunnelPortLabels,
|
||||
getTunnelModeDescription,
|
||||
} from "./tunnel-form-utils.js";
|
||||
|
||||
// A fake translate that echoes the key plus any interpolation args so we can
|
||||
// assert which translation key + payload the helpers chose.
|
||||
const t = (key: string, opts?: Record<string, string | number>) =>
|
||||
opts ? `${key}:${JSON.stringify(opts)}` : key;
|
||||
|
||||
describe("getTunnelTypeForMode", () => {
|
||||
it("maps remote to remote and everything else to local", () => {
|
||||
expect(getTunnelTypeForMode("remote")).toBe("remote");
|
||||
expect(getTunnelTypeForMode("local")).toBe("local");
|
||||
expect(getTunnelTypeForMode("dynamic")).toBe("local");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTunnelPortLabels", () => {
|
||||
it("labels client local mode source/endpoint ports", () => {
|
||||
const labels = getTunnelPortLabels("client", "local", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.localPort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.remotePort");
|
||||
});
|
||||
|
||||
it("swaps labels for client remote mode", () => {
|
||||
const labels = getTunnelPortLabels("client", "remote", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.remotePort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.localPort");
|
||||
});
|
||||
|
||||
it("uses host/endpoint labels in server scope", () => {
|
||||
const labels = getTunnelPortLabels("server", "local", t);
|
||||
expect(labels.sourcePortLabel).toBe("tunnels.currentHostPort");
|
||||
expect(labels.endpointPortLabel).toBe("tunnels.endpointPort");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTunnelModeDescription", () => {
|
||||
const ports = { sourcePort: 8080, endpointPort: 9090 };
|
||||
|
||||
it("selects the client dynamic description with only the source port", () => {
|
||||
const desc = getTunnelModeDescription("client", "dynamic", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionClientDynamic");
|
||||
expect(desc).toContain("8080");
|
||||
expect(desc).not.toContain("9090");
|
||||
});
|
||||
|
||||
it("selects the client local description with both ports", () => {
|
||||
const desc = getTunnelModeDescription("client", "local", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionClientLocal");
|
||||
expect(desc).toContain("8080");
|
||||
expect(desc).toContain("9090");
|
||||
});
|
||||
|
||||
it("selects the server remote description", () => {
|
||||
const desc = getTunnelModeDescription("server", "remote", ports, t);
|
||||
expect(desc).toContain("tunnels.forwardDescriptionServerRemote");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user