mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
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:
co-authored by
Chetan
Codebuff
Maxime Bonillo
ZacharyZcR
alex-ctms
Chetan Kumar
ZacharyZcR
dropafterfree
parent
566b908daf
commit
76fd9eedbf
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user