release-2.7.1 (#1296)

* Add Helm and GitOps deployment setup

* fix: build better-sqlite3 from source in Docker (#1267)

* fix: preserve runtime SSL settings (#1268)

* fix: support forwarding from the memory SSH agent (#1269)

* fix: support forwarding from the memory agent

* style: format memory agent test

* fix: prompt for encrypted SFTP key passphrases (#1270)

* fix: prompt for SFTP key passphrases

* style: format SSH key utility test

* fix: include host context in automation notifications (#1271)

* fix: include host context in automation notifications

* style: format automation notification changes

* fix: reserve sidebar height for host tags (#1272)

* fix: keep host action rows stable at large font sizes (#1273)

* fix: honor certificate setting during server probe (#1274)

* fix: package standard Linux icon sizes (#1275)

* fix: avoid duplicate Docker HTTPS listener (#1276)

* Fix host status without metrics collection (#1277)

* fix: allow eight-digit secure auth codes (#1263)

Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts.

Generated with Codebuff 🤖

Co-authored-by: Chetan <chetan.development@gmail.com>
Co-authored-by: Codebuff <noreply@codebuff.com>

* Harden Helm deployment defaults

* Update Helm workflow action

* Exclude Helm templates from Prettier

* Fix browser RDP file drops (#1279)

* Fix Proxmox guest credential usernames (#1280)

* Add WSL local terminal option (#1281)

* refactor: split the transfer engine into focused modules (#1282)

* refactor: extract SFTP promisify helpers into sftp-promisify module

* refactor: extract transfer timing and rate stats into transfer-stats module

* refactor: extract transfer error classes and recovery checks into transfer-errors module

* refactor: extract host/path utility helpers into transfer-host-utils module

* refactor: extract SFTP directory tree helpers into transfer-sftp-dir module

* refactor: extract segment copy job builder into transfer-segment-copy module

* refactor: extract file scan and sample helpers into transfer-scan module

* refactor: move throttled progress helper into transfer-stats module

* style: format transfer modules

* perf: optimize tmux monitor aggregation (#1283)

* fix: reserve credential tag row height (#1284)

* feat: edit AI provider model settings (#1285)

* fix: clarify click-to-expand host setting (#1286)

* fix: allow portable imports on remote databases (#1287)

* fix: allow HTTPS to share the configured port (#1288)

* fix: resolve synced jump hosts on the server (#1289)

* fix: make terminal clipboard shortcuts layout independent (#1290)

* fix: use compatible fetch dispatcher for Tailscale (#1291)

* fix: add OIDC environment recovery override (#1292)

* fix: coalesce rapid mobile terminal input (#1293)

* fix: coalesce rapid mobile terminal input

* fix: support clean xterm patch installs

* fix: resolve synced remote desktop host IDs (#1295)

* feat: make the SFTP file manager path bar editable (#1294)

Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>

* feat: add passkey sign in to the login screen

* fix: remove rounded corners from the host list search bar

* fix: stop image storage settings text wrapping to one word per line

* fix: prevent malformed websocket messages from crashing the server

* chore: increment version

* fix: remove gaps between host rows in the sidebar list

Keep sub-pixel row measurements and stop wiping the size cache on hover.

* fix: Failed to connect through jump hosts (#1180)

https://github.com/Termix-SSH/Support/issues/1180

* feat: Progress bar for file downloads in the file manager (#1158)

https://github.com/Termix-SSH/Support/issues/1158

* feat: Allow setting Silent OIDC Login via ENV var (#1174)

https://github.com/Termix-SSH/Support/issues/1174

* feat: `IdentityFile` to limit the number of attempts by agents (#1165)

https://github.com/Termix-SSH/Support/issues/1165

* feat: Credentials clone (#1159)

https://github.com/Termix-SSH/Support/issues/1159

* chore: update release notes

* docs: move helm setup guide to the docs site

* fix: type errors in FilteredAgent agent identity handling

* fix: remove stale better-sqlite3 prebuilds so the source build is used

* fix: actually build better-sqlite3 from source so arm64 docker images work

* fix: credential edit pencil in host editor and add clone action to credential list

* fix: clear editingHost so the credential pencil actually opens the editor

* chore: run format and lint

* fix: folder drag and drop upload failing in the file manager

* chore: sync Crowdin translations for 2.7.1

---------

Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com>
Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com>
Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com>
Co-authored-by: Chetan <chetan.development@gmail.com>
Co-authored-by: Codebuff <noreply@codebuff.com>
Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com>
Co-authored-by: dropafterfree <maxime.bonillo@gmail.com>
Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
This commit is contained in:
Luke Gustafson
2026-08-22 19:47:40 -05:00
committed by GitHub
co-authored by Chetan Codebuff Maxime Bonillo ZacharyZcR alex-ctms Chetan Kumar ZacharyZcR dropafterfree
parent 566b908daf
commit 76fd9eedbf
165 changed files with 6778 additions and 2090 deletions
+214 -23
View File
@@ -2,7 +2,7 @@ import { getErrorMessage } from "../../lib/error-message.js";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Loader2, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Label } from "@/components/label";
@@ -16,7 +16,9 @@ import {
import {
createAiProvider,
deleteAiProvider,
getAiProviderModels,
probeAiModels,
updateAiProvider,
type AiProvider,
type AiProviderType,
} from "@/api/ai-api";
@@ -68,6 +70,159 @@ interface AiProviderSettingsProps {
onAdded?: () => void;
}
function AiProviderEditForm({
provider,
onSaved,
onCancel,
}: {
provider: AiProvider;
onSaved: () => void;
onCancel: () => void;
}) {
const { t } = useTranslation();
const [label, setLabel] = useState(provider.label);
const [defaultModel, setDefaultModel] = useState(provider.defaultModel ?? "");
const [models, setModels] = useState<string[]>([]);
const [customModel, setCustomModel] = useState(false);
const [detecting, setDetecting] = useState(false);
const [saving, setSaving] = useState(false);
const detectModels = useCallback(async () => {
setDetecting(true);
try {
const detected = await getAiProviderModels(provider.id);
setModels(detected);
setCustomModel(!!defaultModel && !detected.includes(defaultModel));
} catch {
setModels([]);
setCustomModel(true);
} finally {
setDetecting(false);
}
}, [provider.id, defaultModel]);
useEffect(() => {
void detectModels();
// The initial model value belongs to this provider. Subsequent edits must
// not trigger a provider model-list request on every keystroke.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [provider.id]);
async function handleSave() {
if (!label.trim()) {
toast.error(t("ai.labelRequired"));
return;
}
setSaving(true);
try {
await updateAiProvider(provider.id, {
label: label.trim(),
defaultModel: defaultModel.trim() || null,
});
toast.success(t("ai.providerUpdated"));
onSaved();
} catch (error) {
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
} finally {
setSaving(false);
}
}
return (
<div className="space-y-3 rounded-none border border-border p-3">
<div className="space-y-1.5">
<Label htmlFor={`ai-provider-label-${provider.id}`}>
{t("ai.providerLabel")}
</Label>
<Input
id={`ai-provider-label-${provider.id}`}
className="rounded-none"
value={label}
onChange={(event) => setLabel(event.target.value)}
autoFocus
/>
</div>
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Label
htmlFor={`ai-provider-model-${provider.id}`}
className="min-w-0 flex-1"
>
{t("ai.defaultModel")}
</Label>
<button
type="button"
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
onClick={() => void detectModels()}
disabled={detecting}
>
{detecting ? (
<Loader2 size={11} className="animate-spin" />
) : (
<RefreshCw size={11} />
)}
{t("ai.modelRefresh")}
</button>
</div>
{models.length > 0 && !customModel ? (
<Select
value={defaultModel || undefined}
onValueChange={(value) => {
if (value === "__custom__") {
setCustomModel(true);
setDefaultModel("");
return;
}
setDefaultModel(value);
}}
>
<SelectTrigger
id={`ai-provider-model-${provider.id}`}
className="rounded-none"
>
<SelectValue placeholder={t("ai.modelPlaceholder")} />
</SelectTrigger>
<SelectContent>
{models.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
<SelectItem value="__custom__">{t("ai.modelCustom")}</SelectItem>
</SelectContent>
</Select>
) : (
<Input
id={`ai-provider-model-${provider.id}`}
className="rounded-none"
value={defaultModel}
onChange={(event) => setDefaultModel(event.target.value)}
placeholder={t("ai.defaultModelPlaceholder")}
/>
)}
</div>
<div className="flex gap-2">
<Button size="sm" disabled={saving} onClick={() => void handleSave()}>
{saving && <Loader2 size={14} className="animate-spin" />}
{t("ai.save")}
</Button>
<Button
size="sm"
variant="outline"
disabled={saving}
onClick={onCancel}
>
{t("ai.cancel")}
</Button>
</div>
</div>
);
}
export function AiProviderSettings({
providers,
onChanged,
@@ -75,6 +230,7 @@ export function AiProviderSettings({
}: AiProviderSettingsProps) {
const { t } = useTranslation();
const [adding, setAdding] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [saving, setSaving] = useState(false);
const [providerType, setProviderType] = useState<AiProviderType>("ollama");
const [label, setLabel] = useState("");
@@ -173,32 +329,67 @@ export function AiProviderSettings({
return (
<div className="space-y-3">
{providers.map((provider) => (
<div
key={provider.id}
className="flex items-center justify-between gap-2 rounded-none border border-border bg-muted px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">{provider.label}</div>
<div className="truncate text-xs text-muted-foreground">
{provider.providerType}
{provider.baseUrl ? ` · ${provider.baseUrl}` : ""}
{provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}` : ""}
{providers.map((provider) =>
editingId === provider.id ? (
<AiProviderEditForm
key={provider.id}
provider={provider}
onSaved={() => {
setEditingId(null);
onChanged(provider.id);
}}
onCancel={() => setEditingId(null)}
/>
) : (
<div
key={provider.id}
className="flex items-center justify-between gap-2 rounded-none border border-border bg-muted px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm font-medium">
{provider.label}
</div>
<div className="truncate text-xs text-muted-foreground">
{provider.providerType}
{provider.defaultModel ? ` · ${provider.defaultModel}` : ""}
{provider.baseUrl ? ` · ${provider.baseUrl}` : ""}
{provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}` : ""}
</div>
</div>
<div className="flex shrink-0 items-center">
<Button
size="sm"
variant="ghost"
onClick={() => {
setAdding(false);
setEditingId(provider.id);
}}
aria-label={t("ai.editProvider")}
>
<Pencil size={14} />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(provider.id)}
aria-label={t("ai.removeProvider")}
>
<Trash2 size={14} />
</Button>
</div>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => handleDelete(provider.id)}
aria-label={t("ai.removeProvider")}
>
<Trash2 size={14} />
</Button>
</div>
))}
),
)}
{!adding && (
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
<Button
size="sm"
variant="outline"
onClick={() => {
setEditingId(null);
setAdding(true);
}}
>
<Plus size={14} />
{t("ai.addProvider")}
</Button>
+119 -45
View File
@@ -19,6 +19,7 @@ import {
useWindowManager,
} from "./components/WindowManager.tsx";
import { FileWindow } from "./components/FileWindow.tsx";
import { DownloadProgressToast } from "./components/DownloadProgressToast.tsx";
import { DiffWindow } from "./components/DiffWindow.tsx";
import { useDragToDesktop } from "@/features/file-manager/hooks/useDragToDesktop";
import { useDragToSystemDesktop } from "@/features/file-manager/hooks/useDragToSystemDesktop";
@@ -976,19 +977,14 @@ function FileManagerContent({
return () => document.removeEventListener("keydown", handleKeyDown);
}, [currentPath]);
async function handleItemsDropped(items: DataTransferItemList) {
async function handleItemsDropped(entries: FileSystemEntry[]) {
if (!sshSessionId) {
toast.error(t("fileManager.noSSHConnection"));
return;
}
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i].webkitGetAsEntry?.();
if (entry) entries.push(entry);
}
const files: { file: File; relativePath: string }[] = [];
const emptyDirs: string[] = [];
async function readEntry(
entry: FileSystemEntry,
@@ -999,51 +995,77 @@ function FileManagerContent({
(entry as FileSystemFileEntry).file(resolve, reject),
);
files.push({ file, relativePath: path });
} else if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
let batch: FileSystemEntry[];
do {
batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
reader.readEntries(resolve, reject),
);
for (const child of batch) {
await readEntry(child, `${path}/${child.name}`);
}
} while (batch.length > 0);
return;
}
if (!entry.isDirectory) return;
// readEntries only hands back a page at a time and signals the end with an
// empty batch, so drain it fully before walking into the children.
const reader = (entry as FileSystemDirectoryEntry).createReader();
const children: FileSystemEntry[] = [];
for (;;) {
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
reader.readEntries(resolve, reject),
);
if (batch.length === 0) break;
children.push(...batch);
}
if (children.length === 0) {
emptyDirs.push(path);
return;
}
for (const child of children) {
await readEntry(child, `${path}/${child.name}`);
}
}
for (const entry of entries) {
await readEntry(entry, entry.name);
try {
for (const entry of entries) {
await readEntry(entry, entry.name);
}
} catch (error) {
toast.error(t("fileManager.failedToUploadFile"));
console.error("Failed to read dropped folder:", error);
return;
}
if (files.length === 0) return;
if (files.length === 0 && emptyDirs.length === 0) return;
const progressToast = toast.loading(
`Uploading ${files.length} file(s)...`,
t("fileManager.uploadingFolderFiles", { count: files.length }),
{ duration: Infinity },
);
const failed: string[] = [];
try {
await ensureSSHConnection();
const base = currentPath.endsWith("/") ? currentPath : currentPath + "/";
const dirs = new Set<string>();
for (const { relativePath } of files) {
for (const relativePath of [
...files.map((f) => f.relativePath),
...emptyDirs.map((d) => `${d}/`),
]) {
const parts = relativePath.split("/");
for (let i = 1; i < parts.length; i++) {
dirs.add(parts.slice(0, i).join("/"));
}
}
const sortedDirs = Array.from(dirs).sort();
// Shallowest first so each parent exists before its children.
const sortedDirs = Array.from(dirs).sort(
(a, b) =>
a.split("/").length - b.split("/").length || a.localeCompare(b),
);
for (const dir of sortedDirs) {
const parentPath = currentPath.endsWith("/")
? currentPath + dir.split("/").slice(0, -1).join("/")
: currentPath + "/" + dir.split("/").slice(0, -1).join("/");
const parentDir = dir.split("/").slice(0, -1).join("/");
const targetPath = parentDir ? `${base}${parentDir}/` : base;
const folderName = dir.split("/").pop()!;
const targetPath = parentPath.endsWith("/")
? parentPath
: parentPath + "/";
try {
await createSSHFolder(
sshSessionId,
@@ -1060,23 +1082,37 @@ function FileManagerContent({
const dirPart = relativePath.includes("/")
? relativePath.substring(0, relativePath.lastIndexOf("/"))
: "";
const uploadPath = dirPart
? (currentPath.endsWith("/") ? currentPath : currentPath + "/") +
dirPart +
"/"
: currentPath;
const uploadPath = dirPart ? `${base}${dirPart}/` : currentPath;
await uploadSSHFile(
sshSessionId,
uploadPath,
file.name,
file,
currentHost?.id,
);
try {
await uploadSSHFile(
sshSessionId,
uploadPath,
file.name,
file,
currentHost?.id,
);
} catch (error) {
failed.push(relativePath);
console.error(`Failed to upload ${relativePath}:`, error);
}
}
toast.dismiss(progressToast);
toast.success(`Uploaded ${files.length} file(s) successfully`);
if (failed.length === 0) {
toast.success(
t("fileManager.uploadedFolderFiles", { count: files.length }),
);
} else if (failed.length === files.length) {
toast.error(t("fileManager.failedToUploadFile"));
} else {
toast.warning(
t("fileManager.uploadedFolderPartial", {
uploaded: files.length - failed.length,
failed: failed.length,
}),
);
}
handleRefreshDirectory();
} catch (error) {
toast.dismiss(progressToast);
@@ -1166,14 +1202,51 @@ function FileManagerContent({
async function handleDownloadFile(file: FileItem) {
if (!sshSessionId) return;
const toastId = `download-${file.path}-${Date.now()}`;
let lastLoaded = 0;
let lastTime = Date.now();
let mbPerSec: number | undefined;
try {
await ensureSSHConnection();
const { downloadSSHFileStream } = await import("@/main-axios.ts");
await downloadSSHFileStream(sshSessionId, file.path);
toast.loading(<DownloadProgressToast fileName={file.name} loaded={0} />, {
id: toastId,
duration: Infinity,
});
await downloadSSHFileStream(
sshSessionId,
file.path,
({ loaded, total }) => {
const now = Date.now();
const deltaMs = now - lastTime;
if (deltaMs > 200) {
const deltaBytes = loaded - lastLoaded;
if (deltaBytes >= 0) {
mbPerSec = (deltaBytes / deltaMs / 1024 / 1024) * 1000;
}
lastLoaded = loaded;
lastTime = now;
}
toast.loading(
<DownloadProgressToast
fileName={file.name}
loaded={loaded}
total={total}
mbPerSec={mbPerSec}
/>,
{ id: toastId, duration: Infinity },
);
},
);
toast.success(
t("fileManager.fileDownloadedSuccessfully", { name: file.name }),
{ id: toastId },
);
} catch (error: unknown) {
const err = error instanceof Error ? error : null;
@@ -1187,9 +1260,10 @@ function FileManagerContent({
ip: currentHost?.ip,
port: currentHost?.port,
}),
{ id: toastId },
);
} else {
toast.error(t("fileManager.failedToDownloadFile"));
toast.error(t("fileManager.failedToDownloadFile"), { id: toastId });
}
console.error("Download failed:", error);
}
@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useRef, useState } from "react";
import {
ArrowUp,
ChevronLeft,
@@ -76,14 +76,20 @@ function Breadcrumb({
<React.Fragment key={i}>
{part === "" && i === 0 ? (
<button
onClick={() => navigateTo("/")}
onClick={(e) => {
e.stopPropagation();
navigateTo("/");
}}
className="hover:text-accent-brand transition-colors"
>
{t("fileManager.root")}
</button>
) : part !== "" ? (
<button
onClick={() => navigateTo(arr.slice(0, i + 1).join("/") || "/")}
onClick={(e) => {
e.stopPropagation();
navigateTo(arr.slice(0, i + 1).join("/") || "/");
}}
className="hover:text-accent-brand transition-colors"
>
{part}
@@ -102,6 +108,83 @@ function Breadcrumb({
);
}
function PathBar({
currentPath,
navigateTo,
t,
className,
}: Pick<FileManagerToolbarProps, "currentPath" | "navigateTo" | "t"> & {
className: string;
}) {
const [isEditing, setIsEditing] = useState(false);
const [value, setValue] = useState(currentPath);
const inputRef = useRef<HTMLInputElement>(null);
const doneRef = useRef(false);
useEffect(() => {
if (!isEditing) return;
doneRef.current = false;
const timer = setTimeout(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, 0);
return () => clearTimeout(timer);
}, [isEditing]);
const commit = (path: string) => {
if (doneRef.current) return;
doneRef.current = true;
setIsEditing(false);
const trimmed = path.trim();
if (trimmed && trimmed !== currentPath) {
navigateTo(trimmed);
}
};
const cancel = () => {
if (doneRef.current) return;
doneRef.current = true;
setIsEditing(false);
};
if (isEditing) {
return (
<div className={className}>
<Folder className="size-3.5 text-accent-brand shrink-0" />
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commit(value);
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}}
onBlur={() => commit(value)}
className="flex-1 min-w-0 bg-transparent text-xs font-semibold tracking-wide outline-none text-foreground"
/>
</div>
);
}
return (
<div
className={`${className} cursor-text`}
onClick={() => {
setValue(currentPath);
setIsEditing(true);
}}
>
<Breadcrumb currentPath={currentPath} navigateTo={navigateTo} t={t} />
</div>
);
}
export function FileManagerToolbar({
t,
currentPath,
@@ -182,9 +265,12 @@ export function FileManagerToolbar({
</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>
<PathBar
currentPath={currentPath}
navigateTo={navigateTo}
t={t}
className="hidden md:flex flex-1 items-center px-3 h-8 bg-muted/50 border border-border rounded-none gap-2 overflow-hidden"
/>
<div className="flex items-center gap-2">
{selectedFiles.length > 0 && (
@@ -340,9 +426,12 @@ export function FileManagerToolbar({
</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>
<PathBar
currentPath={currentPath}
navigateTo={navigateTo}
t={t}
className="flex-1 flex items-center px-3 h-8 bg-muted/50 border border-border gap-2 overflow-hidden"
/>
</div>
</div>
);
@@ -0,0 +1,87 @@
import { formatTransferMbPerSec } from "@/main-axios.ts";
import { useTranslation } from "react-i18next";
interface DownloadProgressToastProps {
fileName: string;
loaded: number;
total?: number;
mbPerSec?: number;
}
function formatBytes(bytes: number): string {
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 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 DownloadProgressToast({
fileName,
loaded,
total,
mbPerSec,
}: DownloadProgressToastProps) {
const { t } = useTranslation();
const percent =
total !== undefined && total > 0
? Math.min(100, Math.round((loaded / total) * 100))
: undefined;
const speed = formatTransferMbPerSec(mbPerSec);
return (
<div className="flex w-[min(calc(100vw-5rem),288px)] max-w-full flex-col gap-2 pr-2">
<p className="text-sm font-medium leading-tight truncate">
{t("fileManager.downloadingFile", { name: fileName })}
</p>
{percent === undefined ? (
<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">
{total !== undefined
? t("fileManager.downloadProgressBytes", {
transferred: formatBytes(loaded),
total: formatBytes(total),
})
: formatBytes(loaded)}
</span>
<span
className={`shrink-0 tabular-nums ${speed ? "font-medium text-foreground" : "invisible"}`}
aria-hidden={!speed}
>
{speed || "0 MB/s"}
</span>
</div>
</div>
);
}
@@ -8,7 +8,7 @@ interface DragAndDropState {
interface UseDragAndDropProps {
onFilesDropped: (files: FileList) => void;
onItemsDropped?: (items: DataTransferItemList) => void;
onItemsDropped?: (entries: FileSystemEntry[]) => void;
onError?: (error: string) => void;
maxFileSize?: number;
allowedTypes?: string[];
@@ -119,24 +119,29 @@ export function useDragAndDrop({
e.preventDefault();
e.stopPropagation();
// Read the entries before touching state. Updating state flushes a render
// and the browser clears dataTransfer once the drop handler unwinds, so
// anything read later comes back empty (Firefox is strictest here).
const entries: FileSystemEntry[] = [];
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
for (const item of Array.from(e.dataTransfer.items)) {
const entry = item.webkitGetAsEntry?.();
if (entry) entries.push(entry);
}
}
const files = e.dataTransfer.files;
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
if (onItemsDropped && e.dataTransfer.items?.length > 0) {
const hasDirectory = Array.from(e.dataTransfer.items).some(
(item) => item.webkitGetAsEntry?.()?.isDirectory,
);
if (hasDirectory) {
onItemsDropped(e.dataTransfer.items);
return;
}
if (onItemsDropped && entries.some((entry) => entry.isDirectory)) {
onItemsDropped(entries);
return;
}
const files = e.dataTransfer.files;
if (files.length === 0) {
return;
}
+15 -1
View File
@@ -7,6 +7,7 @@ import React, {
useImperativeHandle,
} from "react";
import type Guacamole from "guacamole-common-js";
import { toast } from "sonner";
import {
GuacamoleDisplay,
type GuacamoleDisplayHandle,
@@ -133,7 +134,7 @@ interface GuacamoleAppInnerProps {
hostId: number;
hostConfig: Pick<
SSHHost,
"connectionType" | "domain" | "guacamoleConfig" | "rdpAuthType"
"connectionType" | "domain" | "guacamoleConfig" | "rdpAuthType" | "syncId"
>;
hostName: string;
tabId?: string;
@@ -180,6 +181,16 @@ const GuacamoleAppInner = React.forwardRef<
setFileBrowserOpen(true);
}, []);
const handleDropUnavailable = useCallback(() => {
toast.error(
t(
allowUpload
? "guacamole.files.driveUnavailable"
: "guacamole.files.uploadDisabled",
),
);
}, [allowUpload, t]);
const resolvedProtocolForConnect = (protocol ??
hostConfig.connectionType ??
"rdp") as "rdp" | "vnc" | "telnet";
@@ -245,6 +256,7 @@ const GuacamoleAppInner = React.forwardRef<
hostId,
protocol,
promptedCredentials ?? undefined,
hostConfig.syncId,
);
if (result) {
setToken(result.token);
@@ -257,6 +269,7 @@ const GuacamoleAppInner = React.forwardRef<
protocol,
promptedCredentials,
resolvedProtocolForConnect,
hostConfig.syncId,
addLog,
t,
]);
@@ -461,6 +474,7 @@ const GuacamoleAppInner = React.forwardRef<
}
onFilesystem={setFilesystem}
onDropFiles={handleDropFiles}
onDropUnavailable={handleDropUnavailable}
/>
{filesystem && fileBrowserOpen && (
<GuacamoleFileBrowser
+20 -5
View File
@@ -25,6 +25,10 @@ import {
} from "./guacamole-clipboard.ts";
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
import { bindPointerInput } from "./guacamole-pointer.ts";
import {
getFileDropDisposition,
hasDraggedFiles,
} from "./guacamole-file-drop.ts";
import { guacStateToStage } from "@/components/connection/connection-status.ts";
import type { ConnectionStage } from "@/types/connection-log.ts";
@@ -66,6 +70,7 @@ interface GuacamoleDisplayProps {
onError?: (error: string) => void;
onFilesystem?: (filesystem: Guacamole.Object | null) => void;
onDropFiles?: (files: File[]) => void;
onDropUnavailable?: () => void;
onStageChange?: (stage: ConnectionStage) => void;
}
@@ -85,6 +90,7 @@ export const GuacamoleDisplay = forwardRef<
onError,
onFilesystem,
onDropFiles,
onDropUnavailable,
onStageChange,
},
ref,
@@ -776,8 +782,9 @@ export const GuacamoleDisplay = forwardRef<
const handleDragEnter = useCallback(
(event: React.DragEvent) => {
if (!canDropFiles || !event.dataTransfer.types.includes("Files")) return;
if (!hasDraggedFiles(event.dataTransfer.types)) return;
event.preventDefault();
if (!canDropFiles) return;
dragDepthRef.current += 1;
setIsDraggingFiles(true);
},
@@ -791,15 +798,21 @@ export const GuacamoleDisplay = forwardRef<
const handleDrop = useCallback(
(event: React.DragEvent) => {
if (!canDropFiles) return;
if (!hasDraggedFiles(event.dataTransfer.types)) return;
event.preventDefault();
dragDepthRef.current = 0;
setIsDraggingFiles(false);
const files = Array.from(event.dataTransfer.files);
if (files.length > 0) onDropFiles?.(files);
const disposition = getFileDropDisposition(
event.dataTransfer.types,
files.length,
canDropFiles,
);
if (disposition === "upload") onDropFiles?.(files);
if (disposition === "reject") onDropUnavailable?.();
},
[canDropFiles, onDropFiles],
[canDropFiles, onDropFiles, onDropUnavailable],
);
return (
@@ -808,7 +821,9 @@ export const GuacamoleDisplay = forwardRef<
className="absolute inset-0 overflow-hidden"
style={{ backgroundColor: "var(--bg-base)" }}
onDragEnter={handleDragEnter}
onDragOver={canDropFiles ? (e) => e.preventDefault() : undefined}
onDragOver={(event) => {
if (hasDraggedFiles(event.dataTransfer.types)) event.preventDefault();
}}
onDragLeave={canDropFiles ? handleDragLeave : undefined}
onDrop={handleDrop}
>
@@ -0,0 +1,14 @@
export type FileDropDisposition = "ignore" | "reject" | "upload";
export function hasDraggedFiles(types: readonly string[]): boolean {
return types.includes("Files");
}
export function getFileDropDisposition(
types: readonly string[],
fileCount: number,
canUpload: boolean,
): FileDropDisposition {
if (!hasDraggedFiles(types) || fileCount === 0) return "ignore";
return canUpload ? "upload" : "reject";
}
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { useXTerm } from "react-xtermjs";
import { useTheme } from "@/components/theme-provider";
@@ -17,6 +17,14 @@ export function LocalTerminal({
const { instance: terminal, ref: xtermRef } = useXTerm();
const fitAddonRef = useRef<FitAddon | null>(null);
const sessionIdRef = useRef<string | null>(null);
const [isWindows, setIsWindows] = useState(false);
const [shell, setShell] = useState<"default" | "wsl">("default");
useEffect(() => {
window.electronAPI?.getPlatform().then((platform) => {
setIsWindows(platform === "win32");
});
}, []);
const fit = useCallback(() => {
const fitAddon = fitAddonRef.current;
@@ -60,7 +68,7 @@ export function LocalTerminal({
});
window.electronAPI
.startLocalTerminal({ cols: terminal.cols, rows: terminal.rows })
.startLocalTerminal({ cols: terminal.cols, rows: terminal.rows, shell })
.then(({ sessionId }) => {
if (disposed) {
window.electronAPI.closeLocalTerminal(sessionId);
@@ -100,11 +108,30 @@ export function LocalTerminal({
fitAddonRef.current = null;
fitAddon.dispose();
};
}, [fit, instanceId, terminal, xtermRef]);
}, [fit, instanceId, shell, terminal, xtermRef]);
useEffect(() => {
if (isVisible) requestAnimationFrame(fit);
}, [fit, isVisible]);
return <div ref={xtermRef} className="h-full w-full bg-background p-2" />;
return (
<div className="flex h-full w-full flex-col bg-background">
{isWindows && (
<div className="flex justify-end border-b border-border px-2 py-1">
<select
aria-label="Local terminal shell"
className="rounded border border-border bg-background px-2 py-1 text-xs text-foreground"
value={shell}
onChange={(event) =>
setShell(event.target.value === "wsl" ? "wsl" : "default")
}
>
<option value="default">PowerShell</option>
<option value="wsl">WSL</option>
</select>
</div>
)}
<div ref={xtermRef} className="min-h-0 flex-1 p-2" />
</div>
);
}
+15 -12
View File
@@ -80,7 +80,7 @@ import {
getNextTerminalFontSize,
getTerminalFontZoomDirection,
} from "./terminal-font-zoom.ts";
import { isTabKeyEvent } from "./terminal-key-event.ts";
import { isPhysicalShortcutKey, isTabKeyEvent } from "./terminal-key-event.ts";
import { installTouchWheelCoordinator } from "./touch-wheel-coordinator.ts";
import { loadTouchInputSettings } from "./touch-input-settings-store.ts";
import { quoteTerminalImagePath } from "./terminal-image-path.ts";
@@ -2869,7 +2869,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key.toLowerCase() === "c" &&
isPhysicalShortcutKey(e, "KeyC", "c") &&
terminal.hasSelection()
) {
const selection = terminal.getSelection();
@@ -2883,13 +2883,16 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}
if (
((e.metaKey && !e.shiftKey && !e.ctrlKey && !e.altKey) ||
(e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Insert")) &&
(e.key.toLowerCase() === "c" || e.key === "Insert")
(e.metaKey &&
!e.shiftKey &&
!e.ctrlKey &&
!e.altKey &&
isPhysicalShortcutKey(e, "KeyC", "c")) ||
(e.ctrlKey &&
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key === "Insert")
) {
const selection = terminal.getSelection();
if (selection) {
@@ -2905,7 +2908,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key.toLowerCase() === "c"
isPhysicalShortcutKey(e, "KeyC", "c")
) {
const selection = terminal.getSelection();
if (selection) {
@@ -2922,7 +2925,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key.toLowerCase() === "v"
isPhysicalShortcutKey(e, "KeyV", "v")
) {
e.preventDefault();
e.stopPropagation();
@@ -2937,7 +2940,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
!e.shiftKey &&
!e.altKey &&
!e.metaKey &&
e.key.toLowerCase() === "v"
isPhysicalShortcutKey(e, "KeyV", "v")
) {
// Let the browser handle Ctrl+V natively, the paste event
// listener will intercept the result without triggering the
@@ -1,3 +1,13 @@
export function isTabKeyEvent(event: KeyboardEvent): boolean {
return event.key === "Tab" || event.code === "Tab" || event.keyCode === 9;
}
export function isPhysicalShortcutKey(
event: Pick<KeyboardEvent, "code" | "key">,
code: string,
fallbackKey: string,
): boolean {
return event.code
? event.code === code
: event.key.toLowerCase() === fallbackKey;
}