Cleanup files and improve file manager.

This commit is contained in:
LukeGus
2025-09-18 00:32:56 -05:00
parent cb7bb3c864
commit 8afd84d96d
53 changed files with 6354 additions and 4736 deletions

View File

@@ -21,7 +21,18 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import { Shield, Trash2, Users, Database, Key, Lock, Download, Upload, HardDrive, FileArchive } from "lucide-react";
import {
Shield,
Trash2,
Users,
Database,
Key,
Lock,
Download,
Upload,
HardDrive,
FileArchive,
} from "lucide-react";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
@@ -280,9 +291,9 @@ export function AdminSettings({
const response = await fetch(apiUrl, {
headers: {
"Authorization": `Bearer ${jwt}`,
"Content-Type": "application/json"
}
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
});
if (response.ok) {
@@ -305,8 +316,8 @@ export function AdminSettings({
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${jwt}`,
"Content-Type": "application/json"
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
});
@@ -326,7 +337,9 @@ export function AdminSettings({
const handleMigrateData = async (dryRun: boolean = false) => {
setMigrationLoading(true);
setMigrationProgress(dryRun ? t("admin.runningVerification") : t("admin.startingMigration"));
setMigrationProgress(
dryRun ? t("admin.runningVerification") : t("admin.startingMigration"),
);
try {
const jwt = getCookie("jwt");
@@ -337,8 +350,8 @@ export function AdminSettings({
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${jwt}`,
"Content-Type": "application/json"
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ dryRun }),
});
@@ -357,7 +370,9 @@ export function AdminSettings({
throw new Error("Migration failed");
}
} catch (err) {
toast.error(dryRun ? t("admin.verificationFailed") : t("admin.migrationFailed"));
toast.error(
dryRun ? t("admin.verificationFailed") : t("admin.migrationFailed"),
);
setMigrationProgress("Failed");
} finally {
setMigrationLoading(false);
@@ -377,10 +392,10 @@ export function AdminSettings({
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${jwt}`,
"Content-Type": "application/json"
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({})
body: JSON.stringify({}),
});
if (response.ok) {
@@ -412,15 +427,15 @@ export function AdminSettings({
// Create FormData for file upload
const formData = new FormData();
formData.append('file', importFile);
formData.append('backupCurrent', 'true');
formData.append("file", importFile);
formData.append("backupCurrent", "true");
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${jwt}`,
Authorization: `Bearer ${jwt}`,
},
body: formData
body: formData,
});
if (response.ok) {
@@ -430,7 +445,9 @@ export function AdminSettings({
setImportFile(null);
await fetchEncryptionStatus(); // Refresh status
} else {
toast.error(`${t("admin.databaseImportFailed")}: ${result.errors?.join(", ") || "Unknown error"}`);
toast.error(
`${t("admin.databaseImportFailed")}: ${result.errors?.join(", ") || "Unknown error"}`,
);
}
} else {
throw new Error("Import failed");
@@ -453,10 +470,10 @@ export function AdminSettings({
const response = await fetch(apiUrl, {
method: "POST",
headers: {
"Authorization": `Bearer ${jwt}`,
"Content-Type": "application/json"
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/json",
},
body: JSON.stringify({})
body: JSON.stringify({}),
});
if (response.ok) {
@@ -519,7 +536,7 @@ export function AdminSettings({
</TabsTrigger>
<TabsTrigger value="security" className="flex items-center gap-2">
<Database className="h-4 w-4" />
{t("admin.databaseSecurity")}
{t("admin.databaseSecurity")}
</TabsTrigger>
</TabsList>
@@ -911,7 +928,9 @@ export function AdminSettings({
<div className="space-y-6">
<div className="flex items-center gap-3">
<Database className="h-5 w-5" />
<h3 className="text-lg font-semibold">{t("admin.databaseSecurity")}</h3>
<h3 className="text-lg font-semibold">
{t("admin.databaseSecurity")}
</h3>
</div>
{encryptionStatus && (
@@ -926,11 +945,19 @@ export function AdminSettings({
<Key className="h-4 w-4 text-yellow-500" />
)}
<div>
<div className="text-sm font-medium">{t("admin.encryptionStatus")}</div>
<div className={`text-xs ${
encryptionStatus.encryption?.enabled ? 'text-green-500' : 'text-yellow-500'
}`}>
{encryptionStatus.encryption?.enabled ? t("admin.enabled") : t("admin.disabled")}
<div className="text-sm font-medium">
{t("admin.encryptionStatus")}
</div>
<div
className={`text-xs ${
encryptionStatus.encryption?.enabled
? "text-green-500"
: "text-yellow-500"
}`}
>
{encryptionStatus.encryption?.enabled
? t("admin.enabled")
: t("admin.disabled")}
</div>
</div>
</div>
@@ -940,11 +967,19 @@ export function AdminSettings({
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-blue-500" />
<div>
<div className="text-sm font-medium">{t("admin.keyProtection")}</div>
<div className={`text-xs ${
encryptionStatus.encryption?.key?.kekProtected ? 'text-green-500' : 'text-yellow-500'
}`}>
{encryptionStatus.encryption?.key?.kekProtected ? t("admin.active") : t("admin.legacy")}
<div className="text-sm font-medium">
{t("admin.keyProtection")}
</div>
<div
className={`text-xs ${
encryptionStatus.encryption?.key?.kekProtected
? "text-green-500"
: "text-yellow-500"
}`}
>
{encryptionStatus.encryption?.key?.kekProtected
? t("admin.active")
: t("admin.legacy")}
</div>
</div>
</div>
@@ -954,14 +989,19 @@ export function AdminSettings({
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-purple-500" />
<div>
<div className="text-sm font-medium">{t("admin.dataStatus")}</div>
<div className={`text-xs ${
encryptionStatus.migration?.migrationCompleted
? 'text-green-500'
: encryptionStatus.migration?.migrationRequired
? 'text-yellow-500'
: 'text-muted-foreground'
}`}>
<div className="text-sm font-medium">
{t("admin.dataStatus")}
</div>
<div
className={`text-xs ${
encryptionStatus.migration?.migrationCompleted
? "text-green-500"
: encryptionStatus.migration
?.migrationRequired
? "text-yellow-500"
: "text-muted-foreground"
}`}
>
{encryptionStatus.migration?.migrationCompleted
? t("admin.encrypted")
: encryptionStatus.migration?.migrationRequired
@@ -980,14 +1020,18 @@ export function AdminSettings({
<div className="space-y-3">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-blue-500" />
<h4 className="font-medium">{t("admin.initializeEncryption")}</h4>
<h4 className="font-medium">
{t("admin.initializeEncryption")}
</h4>
</div>
<Button
onClick={handleInitializeEncryption}
disabled={encryptionLoading}
className="w-full"
>
{encryptionLoading ? t("admin.initializing") : t("admin.initialize")}
{encryptionLoading
? t("admin.initializing")
: t("admin.initialize")}
</Button>
</div>
</div>
@@ -998,10 +1042,14 @@ export function AdminSettings({
<div className="space-y-3">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-yellow-500" />
<h4 className="font-medium">{t("admin.migrateData")}</h4>
<h4 className="font-medium">
{t("admin.migrateData")}
</h4>
</div>
{migrationProgress && (
<div className="text-sm text-blue-600">{migrationProgress}</div>
<div className="text-sm text-blue-600">
{migrationProgress}
</div>
)}
<div className="flex gap-2">
<Button
@@ -1019,7 +1067,9 @@ export function AdminSettings({
size="sm"
className="flex-1"
>
{migrationLoading ? t("admin.migrating") : t("admin.migrate")}
{migrationLoading
? t("admin.migrating")
: t("admin.migrate")}
</Button>
</div>
</div>
@@ -1030,7 +1080,9 @@ export function AdminSettings({
<div className="space-y-3">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-blue-500" />
<h4 className="font-medium">{t("admin.backup")}</h4>
<h4 className="font-medium">
{t("admin.backup")}
</h4>
</div>
<Button
onClick={handleCreateBackup}
@@ -1038,11 +1090,15 @@ export function AdminSettings({
variant="outline"
className="w-full"
>
{backupLoading ? t("admin.creatingBackup") : t("admin.createBackup")}
{backupLoading
? t("admin.creatingBackup")
: t("admin.createBackup")}
</Button>
{backupPath && (
<div className="p-2 bg-muted rounded border">
<div className="text-xs font-mono break-all">{backupPath}</div>
<div className="text-xs font-mono break-all">
{backupPath}
</div>
</div>
)}
</div>
@@ -1054,7 +1110,9 @@ export function AdminSettings({
<div className="space-y-3">
<div className="flex items-center gap-2">
<Upload className="h-4 w-4 text-green-500" />
<h4 className="font-medium">{t("admin.exportImport")}</h4>
<h4 className="font-medium">
{t("admin.exportImport")}
</h4>
</div>
<div className="space-y-2">
<Button
@@ -1064,11 +1122,15 @@ export function AdminSettings({
size="sm"
className="w-full"
>
{exportLoading ? t("admin.exporting") : t("admin.export")}
{exportLoading
? t("admin.exporting")
: t("admin.export")}
</Button>
{exportPath && (
<div className="p-2 bg-muted rounded border">
<div className="text-xs font-mono break-all">{exportPath}</div>
<div className="text-xs font-mono break-all">
{exportPath}
</div>
</div>
)}
</div>
@@ -1076,7 +1138,9 @@ export function AdminSettings({
<input
type="file"
accept=".sqlite,.termix-export.sqlite,.db"
onChange={(e) => setImportFile(e.target.files?.[0] || null)}
onChange={(e) =>
setImportFile(e.target.files?.[0] || null)
}
className="block w-full text-xs file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-muted file:text-foreground"
/>
<Button
@@ -1086,7 +1150,9 @@ export function AdminSettings({
size="sm"
className="w-full"
>
{importLoading ? t("admin.importing") : t("admin.import")}
{importLoading
? t("admin.importing")
: t("admin.import")}
</Button>
</div>
</div>
@@ -1097,7 +1163,9 @@ export function AdminSettings({
{!encryptionStatus && (
<div className="text-center py-8">
<div className="text-muted-foreground">{t("admin.loadingEncryptionStatus")}</div>
<div className="text-muted-foreground">
{t("admin.loadingEncryptionStatus")}
</div>
</div>
)}
</div>

View File

@@ -50,11 +50,13 @@ export function CredentialEditor({
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<string | null>(null);
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] = useState(false);
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
string | null
>(null);
const [publicKeyDetectionLoading, setPublicKeyDetectionLoading] =
useState(false);
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
@@ -230,8 +232,11 @@ export function CredentialEditor({
}, []);
// Detect key type function
const handleKeyTypeDetection = async (keyValue: string, keyPassword?: string) => {
if (!keyValue || keyValue.trim() === '') {
const handleKeyTypeDetection = async (
keyValue: string,
keyPassword?: string,
) => {
if (!keyValue || keyValue.trim() === "") {
setDetectedKeyType(null);
return;
}
@@ -242,12 +247,12 @@ export function CredentialEditor({
if (result.success) {
setDetectedKeyType(result.keyType);
} else {
setDetectedKeyType('invalid');
console.warn('Key detection failed:', result.error);
setDetectedKeyType("invalid");
console.warn("Key detection failed:", result.error);
}
} catch (error) {
setDetectedKeyType('error');
console.error('Key type detection error:', error);
setDetectedKeyType("error");
console.error("Key type detection error:", error);
} finally {
setKeyDetectionLoading(false);
}
@@ -265,7 +270,7 @@ export function CredentialEditor({
// Detect public key type function
const handlePublicKeyTypeDetection = async (publicKeyValue: string) => {
if (!publicKeyValue || publicKeyValue.trim() === '') {
if (!publicKeyValue || publicKeyValue.trim() === "") {
setDetectedPublicKeyType(null);
return;
}
@@ -276,12 +281,12 @@ export function CredentialEditor({
if (result.success) {
setDetectedPublicKeyType(result.keyType);
} else {
setDetectedPublicKeyType('invalid');
console.warn('Public key detection failed:', result.error);
setDetectedPublicKeyType("invalid");
console.warn("Public key detection failed:", result.error);
}
} catch (error) {
setDetectedPublicKeyType('error');
console.error('Public key type detection error:', error);
setDetectedPublicKeyType("error");
console.error("Public key type detection error:", error);
} finally {
setPublicKeyDetectionLoading(false);
}
@@ -297,20 +302,19 @@ export function CredentialEditor({
}, 1000);
};
const getFriendlyKeyTypeName = (keyType: string): string => {
const keyTypeMap: Record<string, string> = {
'ssh-rsa': 'RSA (SSH)',
'ssh-ed25519': 'Ed25519 (SSH)',
'ecdsa-sha2-nistp256': 'ECDSA P-256 (SSH)',
'ecdsa-sha2-nistp384': 'ECDSA P-384 (SSH)',
'ecdsa-sha2-nistp521': 'ECDSA P-521 (SSH)',
'ssh-dss': 'DSA (SSH)',
'rsa-sha2-256': 'RSA-SHA2-256',
'rsa-sha2-512': 'RSA-SHA2-512',
'invalid': 'Invalid Key',
'error': 'Detection Error',
'unknown': 'Unknown'
"ssh-rsa": "RSA (SSH)",
"ssh-ed25519": "Ed25519 (SSH)",
"ecdsa-sha2-nistp256": "ECDSA P-256 (SSH)",
"ecdsa-sha2-nistp384": "ECDSA P-384 (SSH)",
"ecdsa-sha2-nistp521": "ECDSA P-521 (SSH)",
"ssh-dss": "DSA (SSH)",
"rsa-sha2-256": "RSA-SHA2-256",
"rsa-sha2-512": "RSA-SHA2-512",
invalid: "Invalid Key",
error: "Detection Error",
unknown: "Unknown",
};
return keyTypeMap[keyType] || keyType;
};
@@ -418,7 +422,6 @@ export function CredentialEditor({
};
}, [folderDropdownOpen]);
return (
<div
className="flex-1 flex flex-col h-full min-h-0 w-full"
@@ -680,12 +683,15 @@ export function CredentialEditor({
{/* Key Generation Passphrase Input */}
<div className="mb-3">
<FormLabel className="text-sm mb-2 block">
{t("credentials.keyPassword")} ({t("credentials.optional")})
{t("credentials.keyPassword")} (
{t("credentials.optional")})
</FormLabel>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
value={keyGenerationPassphrase}
onChange={(e) => setKeyGenerationPassphrase(e.target.value)}
onChange={(e) =>
setKeyGenerationPassphrase(e.target.value)
}
className="max-w-xs"
/>
<div className="text-xs text-muted-foreground mt-1">
@@ -700,24 +706,47 @@ export function CredentialEditor({
size="sm"
onClick={async () => {
try {
const result = await generateKeyPair('ssh-ed25519', undefined, keyGenerationPassphrase);
const result = await generateKeyPair(
"ssh-ed25519",
undefined,
keyGenerationPassphrase,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) {
form.setValue("keyPassword", keyGenerationPassphrase);
form.setValue(
"keyPassword",
keyGenerationPassphrase,
);
}
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
debouncedKeyDetection(
result.privateKey,
keyGenerationPassphrase,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "Ed25519" }));
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "Ed25519" },
),
);
} else {
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error('Failed to generate Ed25519 key pair:', error);
toast.error(t("credentials.failedToGenerateKeyPair"));
console.error(
"Failed to generate Ed25519 key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
@@ -729,24 +758,47 @@ export function CredentialEditor({
size="sm"
onClick={async () => {
try {
const result = await generateKeyPair('ecdsa-sha2-nistp256', undefined, keyGenerationPassphrase);
const result = await generateKeyPair(
"ecdsa-sha2-nistp256",
undefined,
keyGenerationPassphrase,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) {
form.setValue("keyPassword", keyGenerationPassphrase);
form.setValue(
"keyPassword",
keyGenerationPassphrase,
);
}
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
debouncedKeyDetection(
result.privateKey,
keyGenerationPassphrase,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "ECDSA" }));
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "ECDSA" },
),
);
} else {
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error('Failed to generate ECDSA key pair:', error);
toast.error(t("credentials.failedToGenerateKeyPair"));
console.error(
"Failed to generate ECDSA key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
@@ -758,24 +810,47 @@ export function CredentialEditor({
size="sm"
onClick={async () => {
try {
const result = await generateKeyPair('ssh-rsa', 2048, keyGenerationPassphrase);
const result = await generateKeyPair(
"ssh-rsa",
2048,
keyGenerationPassphrase,
);
if (result.success) {
form.setValue("key", result.privateKey);
form.setValue("publicKey", result.publicKey);
// Auto-fill the key password field if passphrase was used
if (keyGenerationPassphrase) {
form.setValue("keyPassword", keyGenerationPassphrase);
form.setValue(
"keyPassword",
keyGenerationPassphrase,
);
}
debouncedKeyDetection(result.privateKey, keyGenerationPassphrase);
debouncedKeyDetection(
result.privateKey,
keyGenerationPassphrase,
);
debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.keyPairGeneratedSuccessfully", { keyType: "RSA" }));
toast.success(
t(
"credentials.keyPairGeneratedSuccessfully",
{ keyType: "RSA" },
),
);
} else {
toast.error(result.error || t("credentials.failedToGenerateKeyPair"));
toast.error(
result.error ||
t("credentials.failedToGenerateKeyPair"),
);
}
} catch (error) {
console.error('Failed to generate RSA key pair:', error);
toast.error(t("credentials.failedToGenerateKeyPair"));
console.error(
"Failed to generate RSA key pair:",
error,
);
toast.error(
t("credentials.failedToGenerateKeyPair"),
);
}
}}
>
@@ -786,207 +861,267 @@ export function CredentialEditor({
{t("credentials.generateKeyPairNote")}
</div>
</div>
<div className="grid grid-cols-2 gap-4 items-start">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4 flex flex-col">
<FormLabel className="mb-2 min-h-[20px]">
{t("credentials.sshPrivateKey")}
</FormLabel>
<div className="mb-2">
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedKeyDetection(fileContent, form.watch("keyPassword"));
} catch (error) {
console.error('Failed to read uploaded file:', error);
}
<div className="grid grid-cols-2 gap-4 items-start">
<Controller
control={form.control}
name="key"
render={({ field }) => (
<FormItem className="mb-4 flex flex-col">
<FormLabel className="mb-2 min-h-[20px]">
{t("credentials.sshPrivateKey")}
</FormLabel>
<div className="mb-2">
<div className="relative inline-block w-full">
<input
id="key-upload"
type="file"
accept="*,.pem,.key,.txt,.ppk"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedKeyDetection(
fileContent,
form.watch("keyPassword"),
);
} catch (error) {
console.error(
"Failed to read uploaded file:",
error,
);
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPrivateKeyFile")}
</span>
</Button>
</div>
</div>
<FormControl>
<textarea
placeholder={t(
"placeholders.pastePrivateKey",
)}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={
typeof field.value === "string"
? field.value
: ""
}
onChange={(e) => {
field.onChange(e.target.value);
debouncedKeyDetection(e.target.value, form.watch("keyPassword"));
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</FormControl>
{detectedKeyType && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
<span className={`font-medium ${
detectedKeyType === 'invalid' || detectedKeyType === 'error'
? 'text-destructive'
: 'text-green-600'
}`}>
{getFriendlyKeyTypeName(detectedKeyType)}
</span>
{keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">({t("credentials.detectingKeyType")})</span>
)}
</div>
)}
</FormItem>
)}
/>
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="mb-4 flex flex-col">
<FormLabel className="mb-2 min-h-[20px]">
{t("credentials.sshPublicKey")} ({t("credentials.optional")})
</FormLabel>
<div className="mb-2 flex gap-2">
<div className="relative inline-block flex-1">
<input
id="public-key-upload"
type="file"
accept="*,.pub,.txt"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedPublicKeyDetection(fileContent);
} catch (error) {
console.error('Failed to read uploaded public key file:', error);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPublicKeyFile")}
</span>
</Button>
</div>
<Button
type="button"
variant="outline"
className="flex-shrink-0"
onClick={async () => {
const privateKey = form.watch("key");
if (!privateKey || typeof privateKey !== "string" || !privateKey.trim()) {
toast.error(t("credentials.privateKeyRequiredForGeneration"));
return;
}
try {
const keyPassword = form.watch("keyPassword");
const result = await generatePublicKeyFromPrivate(privateKey, keyPassword);
if (result.success && result.publicKey) {
// Set the generated public key
field.onChange(result.publicKey);
// Trigger public key detection
debouncedPublicKeyDetection(result.publicKey);
toast.success(t("credentials.publicKeyGeneratedSuccessfully"));
} else {
toast.error(result.error || t("credentials.failedToGeneratePublicKey"));
}
} catch (error) {
console.error('Failed to generate public key:', error);
toast.error(t("credentials.failedToGeneratePublicKey"));
}
}}
className="w-full justify-start text-left"
>
{t("credentials.generatePublicKey")}
<span className="truncate">
{t("credentials.uploadPrivateKeyFile")}
</span>
</Button>
</div>
<FormControl>
<textarea
placeholder={t(
"placeholders.pastePublicKey",
)}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value || ""}
onChange={(e) => {
field.onChange(e.target.value);
debouncedPublicKeyDetection(e.target.value);
}}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.publicKeyNote")}
</div>
{detectedPublicKeyType && field.value && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">{t("credentials.detectedKeyType")}: </span>
<span className={`font-medium ${
detectedPublicKeyType === 'invalid' || detectedPublicKeyType === 'error'
? 'text-destructive'
: 'text-green-600'
}`}>
{getFriendlyKeyTypeName(detectedPublicKeyType)}
</div>
<FormControl>
<textarea
placeholder={t(
"placeholders.pastePrivateKey",
)}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={
typeof field.value === "string"
? field.value
: ""
}
onChange={(e) => {
field.onChange(e.target.value);
debouncedKeyDetection(
e.target.value,
form.watch("keyPassword"),
);
}}
/>
</FormControl>
{detectedKeyType && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedKeyType === "invalid" ||
detectedKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(detectedKeyType)}
</span>
{keyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
{publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">({t("credentials.detectingKeyType")})</span>
)}
</div>
)}
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-8 gap-4 mt-4">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
)}
</div>
)}
</FormItem>
)}
/>
<Controller
control={form.control}
name="publicKey"
render={({ field }) => (
<FormItem className="mb-4 flex flex-col">
<FormLabel className="mb-2 min-h-[20px]">
{t("credentials.sshPublicKey")} (
{t("credentials.optional")})
</FormLabel>
<div className="mb-2 flex gap-2">
<div className="relative inline-block flex-1">
<input
id="public-key-upload"
type="file"
accept="*,.pub,.txt"
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
try {
const fileContent = await file.text();
field.onChange(fileContent);
debouncedPublicKeyDetection(
fileContent,
);
} catch (error) {
console.error(
"Failed to read uploaded public key file:",
error,
);
}
}
}}
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
/>
</FormControl>
</FormItem>
)}
/>
</div>
<Button
type="button"
variant="outline"
className="w-full justify-start text-left"
>
<span className="truncate">
{t("credentials.uploadPublicKeyFile")}
</span>
</Button>
</div>
<Button
type="button"
variant="outline"
className="flex-shrink-0"
onClick={async () => {
const privateKey = form.watch("key");
if (
!privateKey ||
typeof privateKey !== "string" ||
!privateKey.trim()
) {
toast.error(
t(
"credentials.privateKeyRequiredForGeneration",
),
);
return;
}
try {
const keyPassword =
form.watch("keyPassword");
const result =
await generatePublicKeyFromPrivate(
privateKey,
keyPassword,
);
if (result.success && result.publicKey) {
// Set the generated public key
field.onChange(result.publicKey);
// Trigger public key detection
debouncedPublicKeyDetection(
result.publicKey,
);
toast.success(
t(
"credentials.publicKeyGeneratedSuccessfully",
),
);
} else {
toast.error(
result.error ||
t(
"credentials.failedToGeneratePublicKey",
),
);
}
} catch (error) {
console.error(
"Failed to generate public key:",
error,
);
toast.error(
t(
"credentials.failedToGeneratePublicKey",
),
);
}
}}
>
{t("credentials.generatePublicKey")}
</Button>
</div>
<FormControl>
<textarea
placeholder={t("placeholders.pastePublicKey")}
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
value={field.value || ""}
onChange={(e) => {
field.onChange(e.target.value);
debouncedPublicKeyDetection(e.target.value);
}}
/>
</FormControl>
<div className="text-xs text-muted-foreground mt-1">
{t("credentials.publicKeyNote")}
</div>
{detectedPublicKeyType && field.value && (
<div className="text-sm mt-2">
<span className="text-muted-foreground">
{t("credentials.detectedKeyType")}:{" "}
</span>
<span
className={`font-medium ${
detectedPublicKeyType === "invalid" ||
detectedPublicKeyType === "error"
? "text-destructive"
: "text-green-600"
}`}
>
{getFriendlyKeyTypeName(
detectedPublicKeyType,
)}
</span>
{publicKeyDetectionLoading && (
<span className="ml-2 text-muted-foreground">
({t("credentials.detectingKeyType")})
</span>
)}
</div>
)}
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-8 gap-4 mt-4">
<FormField
control={form.control}
name="keyPassword"
render={({ field }) => (
<FormItem className="col-span-8">
<FormLabel>
{t("credentials.keyPassword")}
</FormLabel>
<FormControl>
<PasswordInput
placeholder={t("placeholders.keyPassword")}
{...field}
/>
</FormControl>
</FormItem>
)}
/>
</div>
</div>
</TabsContent>
</Tabs>

View File

@@ -86,7 +86,8 @@ export function CredentialsManager({
const [editingFolderName, setEditingFolderName] = useState("");
const [operationLoading, setOperationLoading] = useState(false);
const [showDeployDialog, setShowDeployDialog] = useState(false);
const [deployingCredential, setDeployingCredential] = useState<Credential | null>(null);
const [deployingCredential, setDeployingCredential] =
useState<Credential | null>(null);
const [availableHosts, setAvailableHosts] = useState<any[]>([]);
const [selectedHostId, setSelectedHostId] = useState<string>("");
const [deployLoading, setDeployLoading] = useState(false);
@@ -102,7 +103,7 @@ export function CredentialsManager({
const hosts = await getSSHHosts();
setAvailableHosts(hosts);
} catch (err) {
console.error('Failed to fetch hosts:', err);
console.error("Failed to fetch hosts:", err);
}
};
@@ -126,7 +127,7 @@ export function CredentialsManager({
};
const handleDeploy = (credential: Credential) => {
if (credential.authType !== 'key') {
if (credential.authType !== "key") {
toast.error("Only SSH key-based credentials can be deployed");
return;
}
@@ -149,7 +150,7 @@ export function CredentialsManager({
try {
const result = await deployCredentialToHost(
deployingCredential.id,
parseInt(selectedHostId)
parseInt(selectedHostId),
);
if (result.success) {
@@ -161,7 +162,7 @@ export function CredentialsManager({
toast.error(result.error || "Deployment failed");
}
} catch (error) {
console.error('Deployment error:', error);
console.error("Deployment error:", error);
toast.error("Failed to deploy SSH key");
} finally {
setDeployLoading(false);
@@ -655,7 +656,7 @@ export function CredentialsManager({
<p>Edit credential</p>
</TooltipContent>
</Tooltip>
{credential.authType === 'key' && (
{credential.authType === "key" && (
<Tooltip>
<TooltipTrigger asChild>
<Button
@@ -816,9 +817,12 @@ export function CredentialsManager({
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">Name</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
Name
</div>
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
{deployingCredential.name || deployingCredential.username}
{deployingCredential.name ||
deployingCredential.username}
</div>
</div>
</div>
@@ -827,7 +831,9 @@ export function CredentialsManager({
<User className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">Username</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
Username
</div>
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
{deployingCredential.username}
</div>
@@ -838,9 +844,11 @@ export function CredentialsManager({
<Key className="h-3 w-3 text-zinc-500 dark:text-zinc-400" />
</div>
<div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">Key Type</div>
<div className="text-xs text-zinc-500 dark:text-zinc-400">
Key Type
</div>
<div className="text-sm font-medium text-zinc-800 dark:text-zinc-200">
{deployingCredential.keyType || 'SSH Key'}
{deployingCredential.keyType || "SSH Key"}
</div>
</div>
</div>
@@ -887,8 +895,9 @@ export function CredentialsManager({
<div className="text-sm text-blue-800 dark:text-blue-200">
<p className="font-medium mb-1">Deployment Process</p>
<p className="text-blue-700 dark:text-blue-300">
This will safely add the public key to the target host's ~/.ssh/authorized_keys file
without overwriting existing keys. The operation is reversible.
This will safely add the public key to the target host's
~/.ssh/authorized_keys file without overwriting existing
keys. The operation is reversible.
</p>
</div>
</div>

View File

@@ -11,10 +11,5 @@ export function FileManager({
initialHost?: SSHHost | null;
onClose?: () => void;
}): React.ReactElement {
return (
<FileManagerModern
initialHost={initialHost}
onClose={onClose}
/>
);
}
return <FileManagerModern initialHost={initialHost} onClose={onClose} />;
}

View File

@@ -18,7 +18,7 @@ import {
Terminal,
Play,
Star,
Bookmark
Bookmark,
} from "lucide-react";
import { useTranslation } from "react-i18next";
@@ -99,7 +99,7 @@ export function FileManagerContextMenu({
onUnpinFile,
onAddShortcut,
isPinned,
currentPath
currentPath,
}: ContextMenuProps) {
const { t } = useTranslation();
const [menuPosition, setMenuPosition] = useState({ x, y });
@@ -138,7 +138,7 @@ export function FileManagerContextMenu({
const handleClickOutside = (event: MouseEvent) => {
// 检查点击是否在菜单内部
const target = event.target as Element;
const menuElement = document.querySelector('[data-context-menu]');
const menuElement = document.querySelector("[data-context-menu]");
if (!menuElement?.contains(target)) {
onClose();
@@ -153,7 +153,7 @@ export function FileManagerContextMenu({
// 键盘支持
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
@@ -169,19 +169,19 @@ export function FileManagerContextMenu({
onClose();
};
document.addEventListener('mousedown', handleClickOutside, true);
document.addEventListener('contextmenu', handleRightClick);
document.addEventListener('keydown', handleKeyDown);
window.addEventListener('blur', handleBlur);
window.addEventListener('scroll', handleScroll, true);
document.addEventListener("mousedown", handleClickOutside, true);
document.addEventListener("contextmenu", handleRightClick);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("blur", handleBlur);
window.addEventListener("scroll", handleScroll, true);
// 设置清理函数
cleanupFn = () => {
document.removeEventListener('mousedown', handleClickOutside, true);
document.removeEventListener('contextmenu', handleRightClick);
document.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('blur', handleBlur);
window.removeEventListener('scroll', handleScroll, true);
document.removeEventListener("mousedown", handleClickOutside, true);
document.removeEventListener("contextmenu", handleRightClick);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("blur", handleBlur);
window.removeEventListener("scroll", handleScroll, true);
};
}, 50); // 50ms延迟确保不会捕获到触发菜单的点击
@@ -198,9 +198,11 @@ export function FileManagerContextMenu({
const isFileContext = files.length > 0;
const isSingleFile = files.length === 1;
const isMultipleFiles = files.length > 1;
const hasFiles = files.some(f => f.type === 'file');
const hasDirectories = files.some(f => f.type === 'directory');
const hasExecutableFiles = files.some(f => f.type === 'file' && f.executable);
const hasFiles = files.some((f) => f.type === "file");
const hasDirectories = files.some((f) => f.type === "directory");
const hasExecutableFiles = files.some(
(f) => f.type === "file" && f.executable,
);
// 构建菜单项
const menuItems: MenuItem[] = [];
@@ -211,14 +213,19 @@ export function FileManagerContextMenu({
// 打开终端功能 - 支持文件和文件夹
if (onOpenTerminal) {
const targetPath = isSingleFile
? (files[0].type === 'directory' ? files[0].path : files[0].path.substring(0, files[0].path.lastIndexOf('/')))
: files[0].path.substring(0, files[0].path.lastIndexOf('/'));
? files[0].type === "directory"
? files[0].path
: files[0].path.substring(0, files[0].path.lastIndexOf("/"))
: files[0].path.substring(0, files[0].path.lastIndexOf("/"));
menuItems.push({
icon: <Terminal className="w-4 h-4" />,
label: files[0].type === 'directory' ? t("fileManager.openTerminalInFolder") : t("fileManager.openTerminalInFileLocation"),
label:
files[0].type === "directory"
? t("fileManager.openTerminalInFolder")
: t("fileManager.openTerminalInFileLocation"),
action: () => onOpenTerminal(targetPath),
shortcut: "Ctrl+T"
shortcut: "Ctrl+T",
});
}
@@ -228,23 +235,29 @@ export function FileManagerContextMenu({
icon: <Play className="w-4 h-4" />,
label: t("fileManager.run"),
action: () => onRunExecutable(files[0]),
shortcut: "Enter"
shortcut: "Enter",
});
}
if ((onOpenTerminal || (isSingleFile && hasExecutableFiles && onRunExecutable))) {
// 添加分隔符(如果有上述功能)
if (
onOpenTerminal ||
(isSingleFile && hasExecutableFiles && onRunExecutable)
) {
menuItems.push({ separator: true } as MenuItem);
}
// 预览功能
if (hasFiles && onPreview) {
menuItems.push({
icon: <Eye className="w-4 h-4" />,
label: t("fileManager.preview"),
action: () => onPreview(files[0]),
disabled: !isSingleFile || files[0].type !== 'file'
disabled: !isSingleFile || files[0].type !== "file",
});
}
// 下载功能
if (hasFiles && onDownload) {
menuItems.push({
icon: <Download className="w-4 h-4" />,
@@ -252,62 +265,75 @@ export function FileManagerContextMenu({
? t("fileManager.downloadFiles", { count: files.length })
: t("fileManager.downloadFile"),
action: () => onDownload(files),
shortcut: "Ctrl+D"
shortcut: "Ctrl+D",
});
}
// 拖拽到桌面菜单项(支持浏览器和桌面应用)
if (hasFiles && onDragToDesktop) {
const isModernBrowser = 'showSaveFilePicker' in window;
const isModernBrowser = "showSaveFilePicker" in window;
menuItems.push({
icon: <ExternalLink className="w-4 h-4" />,
label: isMultipleFiles
? t("fileManager.saveFilesToSystem", { count: files.length })
: t("fileManager.saveToSystem"),
action: () => onDragToDesktop(),
shortcut: isModernBrowser ? t("fileManager.selectLocationToSave") : t("fileManager.downloadToDefaultLocation")
shortcut: isModernBrowser
? t("fileManager.selectLocationToSave")
: t("fileManager.downloadToDefaultLocation"),
});
}
// PIN/UNPIN 功能 - 仅对单个文件显示
if (isSingleFile && files[0].type === 'file') {
if (isSingleFile && files[0].type === "file") {
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
if (isCurrentlyPinned && onUnpinFile) {
menuItems.push({
icon: <Star className="w-4 h-4 fill-yellow-400" />,
label: t("fileManager.unpinFile"),
action: () => onUnpinFile(files[0])
action: () => onUnpinFile(files[0]),
});
} else if (!isCurrentlyPinned && onPinFile) {
menuItems.push({
icon: <Star className="w-4 h-4" />,
label: t("fileManager.pinFile"),
action: () => onPinFile(files[0])
action: () => onPinFile(files[0]),
});
}
}
// 添加文件夹快捷方式 - 仅对单个文件夹显示
if (isSingleFile && files[0].type === 'directory' && onAddShortcut) {
if (isSingleFile && files[0].type === "directory" && onAddShortcut) {
menuItems.push({
icon: <Bookmark className="w-4 h-4" />,
label: t("fileManager.addToShortcuts"),
action: () => onAddShortcut(files[0].path)
action: () => onAddShortcut(files[0].path),
});
}
menuItems.push({ separator: true } as MenuItem);
// 添加分隔符(如果有上述功能)
if (
(hasFiles && (onPreview || onDownload || onDragToDesktop)) ||
(isSingleFile &&
files[0].type === "file" &&
(onPinFile || onUnpinFile)) ||
(isSingleFile && files[0].type === "directory" && onAddShortcut)
) {
menuItems.push({ separator: true } as MenuItem);
}
// 重命名功能
if (isSingleFile && onRename) {
menuItems.push({
icon: <Edit3 className="w-4 h-4" />,
label: t("fileManager.rename"),
action: () => onRename(files[0]),
shortcut: "F2"
shortcut: "F2",
});
}
// 复制功能
if (onCopy) {
menuItems.push({
icon: <Copy className="w-4 h-4" />,
@@ -315,10 +341,11 @@ export function FileManagerContextMenu({
? t("fileManager.copyFiles", { count: files.length })
: t("fileManager.copy"),
action: () => onCopy(files),
shortcut: "Ctrl+C"
shortcut: "Ctrl+C",
});
}
// 剪切功能
if (onCut) {
menuItems.push({
icon: <Scissors className="w-4 h-4" />,
@@ -326,12 +353,16 @@ export function FileManagerContextMenu({
? t("fileManager.cutFiles", { count: files.length })
: t("fileManager.cut"),
action: () => onCut(files),
shortcut: "Ctrl+X"
shortcut: "Ctrl+X",
});
}
menuItems.push({ separator: true } as MenuItem);
// 添加分隔符(如果有编辑功能)
if ((isSingleFile && onRename) || onCopy || onCut) {
menuItems.push({ separator: true } as MenuItem);
}
// 删除功能
if (onDelete) {
menuItems.push({
icon: <Trash2 className="w-4 h-4" />,
@@ -340,17 +371,21 @@ export function FileManagerContextMenu({
: t("fileManager.delete"),
action: () => onDelete(files),
shortcut: "Delete",
danger: true
danger: true,
});
}
menuItems.push({ separator: true } as MenuItem);
// 添加分隔符(如果有删除功能)
if (onDelete) {
menuItems.push({ separator: true } as MenuItem);
}
// 属性功能
if (isSingleFile && onProperties) {
menuItems.push({
icon: <Info className="w-4 h-4" />,
label: t("fileManager.properties"),
action: () => onProperties(files[0])
action: () => onProperties(files[0]),
});
}
} else {
@@ -362,62 +397,93 @@ export function FileManagerContextMenu({
icon: <Terminal className="w-4 h-4" />,
label: t("fileManager.openTerminalHere"),
action: () => onOpenTerminal(currentPath),
shortcut: "Ctrl+T"
shortcut: "Ctrl+T",
});
menuItems.push({ separator: true } as MenuItem);
}
// 上传功能
if (onUpload) {
menuItems.push({
icon: <Upload className="w-4 h-4" />,
label: t("fileManager.uploadFile"),
action: onUpload,
shortcut: "Ctrl+U"
shortcut: "Ctrl+U",
});
}
menuItems.push({ separator: true } as MenuItem);
// 添加分隔符(如果有终端或上传功能)
if ((onOpenTerminal && currentPath) || onUpload) {
menuItems.push({ separator: true } as MenuItem);
}
// 新建文件夹
if (onNewFolder) {
menuItems.push({
icon: <FolderPlus className="w-4 h-4" />,
label: t("fileManager.newFolder"),
action: onNewFolder,
shortcut: "Ctrl+Shift+N"
shortcut: "Ctrl+Shift+N",
});
}
// 新建文件
if (onNewFile) {
menuItems.push({
icon: <FilePlus className="w-4 h-4" />,
label: t("fileManager.newFile"),
action: onNewFile,
shortcut: "Ctrl+N"
shortcut: "Ctrl+N",
});
}
menuItems.push({ separator: true } as MenuItem);
// 添加分隔符(如果有新建功能)
if (onNewFolder || onNewFile) {
menuItems.push({ separator: true } as MenuItem);
}
// 刷新功能
if (onRefresh) {
menuItems.push({
icon: <RefreshCw className="w-4 h-4" />,
label: t("fileManager.refresh"),
action: onRefresh,
shortcut: "F5"
shortcut: "F5",
});
}
// 粘贴功能
if (hasClipboard && onPaste) {
menuItems.push({
icon: <Clipboard className="w-4 h-4" />,
label: t("fileManager.paste"),
action: onPaste,
shortcut: "Ctrl+V"
shortcut: "Ctrl+V",
});
}
}
// 过滤掉连续的分隔符
const filteredMenuItems = menuItems.filter((item, index) => {
if (!item.separator) return true;
// 如果是分隔符,检查前一个和后一个是否也是分隔符
const prevItem = index > 0 ? menuItems[index - 1] : null;
const nextItem = index < menuItems.length - 1 ? menuItems[index + 1] : null;
// 如果前一个或后一个是分隔符,则过滤掉当前分隔符
if (prevItem?.separator || nextItem?.separator) {
return false;
}
return true;
});
// 移除开头和结尾的分隔符
const finalMenuItems = filteredMenuItems.filter((item, index) => {
if (!item.separator) return true;
return index > 0 && index < filteredMenuItems.length - 1;
});
return (
<>
{/* 透明遮罩层用于捕获点击事件 */}
@@ -426,18 +492,18 @@ export function FileManagerContextMenu({
{/* 菜单本体 */}
<div
data-context-menu
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl py-1 min-w-[180px] max-w-[250px] z-50"
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-50 overflow-hidden"
style={{
left: menuPosition.x,
top: menuPosition.y
top: menuPosition.y,
}}
>
{menuItems.map((item, index) => {
{finalMenuItems.map((item, index) => {
if (item.separator) {
return (
<div
key={`separator-${index}`}
className="border-t border-dark-border my-1"
className="border-t border-dark-border"
/>
);
}
@@ -448,8 +514,9 @@ export function FileManagerContextMenu({
className={cn(
"w-full px-3 py-2 text-left text-sm flex items-center justify-between",
"hover:bg-dark-hover transition-colors",
"first:rounded-t-lg last:rounded-b-lg",
item.disabled && "opacity-50 cursor-not-allowed",
item.danger && "text-red-400 hover:bg-red-500/10"
item.danger && "text-red-400 hover:bg-red-500/10",
)}
onClick={() => {
if (!item.disabled) {
@@ -459,12 +526,12 @@ export function FileManagerContextMenu({
}}
disabled={item.disabled}
>
<div className="flex items-center gap-3">
{item.icon}
<span>{item.label}</span>
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex-shrink-0">{item.icon}</div>
<span className="flex-1">{item.label}</span>
</div>
{item.shortcut && (
<span className="text-xs text-muted-foreground">
<span className="text-xs text-muted-foreground ml-2 flex-shrink-0">
{item.shortcut}
</span>
)}
@@ -474,4 +541,4 @@ export function FileManagerContextMenu({
</div>
</>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -383,12 +383,12 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
try {
// Extract just the symlink path (before the " -> " if present)
const symlinkPath = item.path.includes(" -> ")
? item.path.split(" -> ")[0]
const symlinkPath = item.path.includes(" -> ")
? item.path.split(" -> ")[0]
: item.path;
let currentSessionId = sshSessionId;
// Check SSH connection status and reconnect if needed
if (currentSessionId) {
try {
@@ -421,9 +421,12 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
throw new Error(t("fileManager.failedToConnectSSH"));
}
}
const symlinkInfo = await identifySSHSymlink(currentSessionId, symlinkPath);
const symlinkInfo = await identifySSHSymlink(
currentSessionId,
symlinkPath,
);
if (symlinkInfo.type === "directory") {
// If symlink points to a directory, navigate to it
handlePathChange(symlinkInfo.target);
@@ -438,9 +441,9 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
}
} catch (error: any) {
toast.error(
error?.response?.data?.error ||
error?.message ||
t("fileManager.failedToResolveSymlink"),
error?.response?.data?.error ||
error?.message ||
t("fileManager.failedToResolveSymlink"),
);
}
};
@@ -569,13 +572,13 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
(item.type === "directory"
? handlePathChange(item.path)
: item.type === "link"
? handleSymlinkClick(item)
: onOpenFile({
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId,
}))
? handleSymlinkClick(item)
: onOpenFile({
name: item.name,
path: item.path,
isSSH: item.isSSH,
sshSessionId: item.sshSessionId,
}))
}
>
{item.type === "directory" ? (
@@ -590,7 +593,7 @@ const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
</span>
</div>
<div className="flex items-center gap-1">
{(item.type === "file") && (
{item.type === "file" && (
<Button
size="icon"
variant="ghost"

File diff suppressed because it is too large Load Diff

View File

@@ -86,18 +86,21 @@ export function FileManagerOperations({
reader.onerror = () => reject(reader.error);
// 检查文件类型,决定读取方式
const isTextFile = uploadFile.type.startsWith('text/') ||
uploadFile.type === 'application/json' ||
uploadFile.type === 'application/javascript' ||
uploadFile.type === 'application/xml' ||
uploadFile.name.match(/\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i);
const isTextFile =
uploadFile.type.startsWith("text/") ||
uploadFile.type === "application/json" ||
uploadFile.type === "application/javascript" ||
uploadFile.type === "application/xml" ||
uploadFile.name.match(
/\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i,
);
if (isTextFile) {
reader.onload = () => {
if (reader.result) {
resolve(reader.result as string);
} else {
reject(new Error('Failed to read text file content'));
reject(new Error("Failed to read text file content"));
}
};
reader.readAsText(uploadFile);
@@ -105,14 +108,14 @@ export function FileManagerOperations({
reader.onload = () => {
if (reader.result instanceof ArrayBuffer) {
const bytes = new Uint8Array(reader.result);
let binary = '';
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
const base64 = btoa(binary);
resolve(base64);
} else {
reject(new Error('Failed to read binary file'));
reject(new Error("Failed to read binary file"));
}
};
reader.readAsArrayBuffer(uploadFile);
@@ -201,7 +204,7 @@ export function FileManagerOperations({
setIsLoading(true);
const { toast } = await import("sonner");
const fileName = downloadPath.split('/').pop() || 'download';
const fileName = downloadPath.split("/").pop() || "download";
const loadingToast = toast.loading(
t("fileManager.downloadingFile", { name: fileName }),
);
@@ -209,10 +212,7 @@ export function FileManagerOperations({
try {
const { downloadSSHFile } = await import("@/ui/main-axios.ts");
const response = await downloadSSHFile(
sshSessionId,
downloadPath.trim(),
);
const response = await downloadSSHFile(sshSessionId, downloadPath.trim());
toast.dismiss(loadingToast);
@@ -224,11 +224,13 @@ export function FileManagerOperations({
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: response.mimeType || 'application/octet-stream' });
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
// Create download link
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || fileName;
document.body.appendChild(link);
@@ -237,7 +239,9 @@ export function FileManagerOperations({
URL.revokeObjectURL(url);
onSuccess(
t("fileManager.fileDownloadedSuccessfully", { name: response.fileName || fileName }),
t("fileManager.fileDownloadedSuccessfully", {
name: response.fileName || fileName,
}),
);
} else {
onError(t("fileManager.noFileContent"));

View File

@@ -8,10 +8,10 @@ import {
Star,
Clock,
Bookmark,
FolderOpen
FolderOpen,
} from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SSHHost } from "../../../types/index.js";
import type { SSHHost } from "@/types/index";
import {
getRecentFiles,
getPinnedFiles,
@@ -19,7 +19,7 @@ import {
listSSHFiles,
removeRecentFile,
removePinnedFile,
removeFolderShortcut
removeFolderShortcut,
} from "@/ui/main-axios.ts";
import { toast } from "sonner";
@@ -27,7 +27,7 @@ export interface SidebarItem {
id: string;
name: string;
path: string;
type: 'recent' | 'pinned' | 'shortcut' | 'folder';
type: "recent" | "pinned" | "shortcut" | "folder";
lastAccessed?: string;
isExpanded?: boolean;
children?: SidebarItem[];
@@ -50,14 +50,16 @@ export function FileManagerSidebar({
onLoadDirectory,
onFileOpen,
sshSessionId,
refreshTrigger
refreshTrigger,
}: FileManagerSidebarProps) {
const { t } = useTranslation();
const [recentItems, setRecentItems] = useState<SidebarItem[]>([]);
const [pinnedItems, setPinnedItems] = useState<SidebarItem[]>([]);
const [shortcuts, setShortcuts] = useState<SidebarItem[]>([]);
const [directoryTree, setDirectoryTree] = useState<SidebarItem[]>([]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['root']));
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(["root"]),
);
// 右键菜单状态
const [contextMenu, setContextMenu] = useState<{
@@ -69,7 +71,7 @@ export function FileManagerSidebar({
x: 0,
y: 0,
isVisible: false,
item: null
item: null,
});
// 加载快捷功能数据
@@ -94,8 +96,8 @@ export function FileManagerSidebar({
id: `recent-${item.id}`,
name: item.name,
path: item.path,
type: 'recent' as const,
lastAccessed: item.lastOpened
type: "recent" as const,
lastAccessed: item.lastOpened,
}));
setRecentItems(recentItems);
@@ -105,7 +107,7 @@ export function FileManagerSidebar({
id: `pinned-${item.id}`,
name: item.name,
path: item.path,
type: 'pinned' as const
type: "pinned" as const,
}));
setPinnedItems(pinnedItems);
@@ -115,11 +117,11 @@ export function FileManagerSidebar({
id: `shortcut-${item.id}`,
name: item.name,
path: item.path,
type: 'shortcut' as const
type: "shortcut" as const,
}));
setShortcuts(shortcutItems);
} catch (error) {
console.error('Failed to load quick access data:', error);
console.error("Failed to load quick access data:", error);
// 如果加载失败,保持空数组
setRecentItems([]);
setPinnedItems([]);
@@ -134,9 +136,11 @@ export function FileManagerSidebar({
try {
await removeRecentFile(currentHost.id, item.path);
loadQuickAccessData(); // 重新加载数据
toast.success(t("fileManager.removedFromRecentFiles", { name: item.name }));
toast.success(
t("fileManager.removedFromRecentFiles", { name: item.name }),
);
} catch (error) {
console.error('Failed to remove recent file:', error);
console.error("Failed to remove recent file:", error);
toast.error(t("fileManager.removeFailed"));
}
};
@@ -149,7 +153,7 @@ export function FileManagerSidebar({
loadQuickAccessData(); // 重新加载数据
toast.success(t("fileManager.unpinnedSuccessfully", { name: item.name }));
} catch (error) {
console.error('Failed to unpin file:', error);
console.error("Failed to unpin file:", error);
toast.error(t("fileManager.unpinFailed"));
}
};
@@ -162,7 +166,7 @@ export function FileManagerSidebar({
loadQuickAccessData(); // 重新加载数据
toast.success(t("fileManager.removedShortcut", { name: item.name }));
} catch (error) {
console.error('Failed to remove shortcut:', error);
console.error("Failed to remove shortcut:", error);
toast.error(t("fileManager.removeShortcutFailed"));
}
};
@@ -173,12 +177,12 @@ export function FileManagerSidebar({
try {
// 批量删除所有recent文件
await Promise.all(
recentItems.map(item => removeRecentFile(currentHost.id, item.path))
recentItems.map((item) => removeRecentFile(currentHost.id, item.path)),
);
loadQuickAccessData(); // 重新加载数据
toast.success(t("fileManager.clearedAllRecentFiles"));
} catch (error) {
console.error('Failed to clear recent files:', error);
console.error("Failed to clear recent files:", error);
toast.error(t("fileManager.clearFailed"));
}
};
@@ -192,12 +196,12 @@ export function FileManagerSidebar({
x: e.clientX,
y: e.clientY,
isVisible: true,
item
item,
});
};
const closeContextMenu = () => {
setContextMenu(prev => ({ ...prev, isVisible: false, item: null }));
setContextMenu((prev) => ({ ...prev, isVisible: false, item: null }));
};
// 点击外部关闭菜单
@@ -206,7 +210,7 @@ export function FileManagerSidebar({
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Element;
const menuElement = document.querySelector('[data-sidebar-context-menu]');
const menuElement = document.querySelector("[data-sidebar-context-menu]");
if (!menuElement?.contains(target)) {
closeContextMenu();
@@ -214,21 +218,21 @@ export function FileManagerSidebar({
};
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
if (event.key === "Escape") {
closeContextMenu();
}
};
// 延迟添加监听器,避免立即触发
const timeoutId = setTimeout(() => {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleKeyDown);
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleKeyDown);
}, 50);
return () => {
clearTimeout(timeoutId);
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleKeyDown);
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleKeyDown);
};
}, [contextMenu.isVisible]);
@@ -237,61 +241,64 @@ export function FileManagerSidebar({
try {
// 加载根目录
const response = await listSSHFiles(sshSessionId, '/');
const response = await listSSHFiles(sshSessionId, "/");
// listSSHFiles 现在总是返回 {files: Array, path: string} 格式
const rootFiles = response.files || [];
const rootFolders = rootFiles.filter((item: any) => item.type === 'directory');
const rootFolders = rootFiles.filter(
(item: any) => item.type === "directory",
);
const rootTreeItems = rootFolders.map((folder: any) => ({
id: `folder-${folder.name}`,
name: folder.name,
path: folder.path,
type: 'folder' as const,
type: "folder" as const,
isExpanded: false,
children: [] // 子目录将按需加载
children: [], // 子目录将按需加载
}));
setDirectoryTree([
{
id: 'root',
name: '/',
path: '/',
type: 'folder' as const,
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: true,
children: rootTreeItems
}
children: rootTreeItems,
},
]);
} catch (error) {
console.error('Failed to load directory tree:', error);
console.error("Failed to load directory tree:", error);
// 如果加载失败,显示简单的根目录
setDirectoryTree([
{
id: 'root',
name: '/',
path: '/',
type: 'folder' as const,
id: "root",
name: "/",
path: "/",
type: "folder" as const,
isExpanded: false,
children: []
}
children: [],
},
]);
}
};
const handleItemClick = (item: SidebarItem) => {
if (item.type === 'folder') {
if (item.type === "folder") {
toggleFolder(item.id, item.path);
onPathChange(item.path);
} else if (item.type === 'recent' || item.type === 'pinned') {
} else if (item.type === "recent" || item.type === "pinned") {
// 对于文件类型,调用文件打开回调
if (onFileOpen) {
onFileOpen(item);
} else {
// 如果没有文件打开回调,切换到文件所在目录
const directory = item.path.substring(0, item.path.lastIndexOf('/')) || '/';
const directory =
item.path.substring(0, item.path.lastIndexOf("/")) || "/";
onPathChange(directory);
}
} else if (item.type === 'shortcut') {
} else if (item.type === "shortcut") {
// 文件夹快捷方式直接切换到目录
onPathChange(item.path);
}
@@ -306,27 +313,29 @@ export function FileManagerSidebar({
newExpanded.add(folderId);
// 按需加载子目录
if (sshSessionId && folderPath && folderPath !== '/') {
if (sshSessionId && folderPath && folderPath !== "/") {
try {
const subResponse = await listSSHFiles(sshSessionId, folderPath);
// listSSHFiles 现在总是返回 {files: Array, path: string} 格式
const subFiles = subResponse.files || [];
const subFolders = subFiles.filter((item: any) => item.type === 'directory');
const subFolders = subFiles.filter(
(item: any) => item.type === "directory",
);
const subTreeItems = subFolders.map((folder: any) => ({
id: `folder-${folder.path.replace(/\//g, '-')}`,
id: `folder-${folder.path.replace(/\//g, "-")}`,
name: folder.name,
path: folder.path,
type: 'folder' as const,
type: "folder" as const,
isExpanded: false,
children: []
children: [],
}));
// 更新目录树,为当前文件夹添加子目录
setDirectoryTree(prevTree => {
setDirectoryTree((prevTree) => {
const updateChildren = (items: SidebarItem[]): SidebarItem[] => {
return items.map(item => {
return items.map((item) => {
if (item.id === folderId) {
return { ...item, children: subTreeItems };
} else if (item.children) {
@@ -338,7 +347,7 @@ export function FileManagerSidebar({
return updateChildren(prevTree);
});
} catch (error) {
console.error('Failed to load subdirectory:', error);
console.error("Failed to load subdirectory:", error);
}
}
}
@@ -354,20 +363,24 @@ export function FileManagerSidebar({
<div key={item.id}>
<div
className={cn(
"flex items-center gap-2 px-2 py-1 text-sm cursor-pointer hover:bg-dark-hover rounded",
"flex items-center gap-2 py-1.5 text-sm cursor-pointer hover:bg-dark-hover rounded",
isActive && "bg-primary/20 text-primary",
"text-white"
"text-white",
)}
style={{ paddingLeft: `${8 + level * 16}px` }}
style={{ paddingLeft: `${12 + level * 16}px`, paddingRight: "12px" }}
onClick={() => handleItemClick(item)}
onContextMenu={(e) => {
// 只有快捷功能项才需要右键菜单
if (item.type === 'recent' || item.type === 'pinned' || item.type === 'shortcut') {
if (
item.type === "recent" ||
item.type === "pinned" ||
item.type === "shortcut"
) {
handleContextMenu(e, item);
}
}}
>
{item.type === 'folder' && (
{item.type === "folder" && (
<button
onClick={(e) => {
e.stopPropagation();
@@ -383,8 +396,12 @@ export function FileManagerSidebar({
</button>
)}
{item.type === 'folder' ? (
isExpanded ? <FolderOpen className="w-4 h-4" /> : <Folder className="w-4 h-4" />
{item.type === "folder" ? (
isExpanded ? (
<FolderOpen className="w-4 h-4" />
) : (
<Folder className="w-4 h-4" />
)
) : (
<File className="w-4 h-4" />
)}
@@ -392,7 +409,7 @@ export function FileManagerSidebar({
<span className="truncate">{item.name}</span>
</div>
{item.type === 'folder' && isExpanded && item.children && (
{item.type === "folder" && isExpanded && item.children && (
<div>
{item.children.map((child) => renderSidebarItem(child, level + 1))}
</div>
@@ -401,12 +418,16 @@ export function FileManagerSidebar({
);
};
const renderSection = (title: string, icon: React.ReactNode, items: SidebarItem[]) => {
const renderSection = (
title: string,
icon: React.ReactNode,
items: SidebarItem[],
) => {
if (items.length === 0) return null;
return (
<div className="mb-4">
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
<div className="mb-5">
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
{icon}
{title}
</div>
@@ -417,26 +438,46 @@ export function FileManagerSidebar({
);
};
// Check if there are any quick access items
const hasQuickAccessItems =
recentItems.length > 0 || pinnedItems.length > 0 || shortcuts.length > 0;
return (
<>
<div className="h-full flex flex-col bg-dark-bg border-r border-dark-border">
<div className="flex-1 relative overflow-hidden">
<div className="absolute inset-0 overflow-y-auto thin-scrollbar p-2 space-y-4">
{/* 快捷功能区域 */}
{renderSection(t("fileManager.recent"), <Clock className="w-3 h-3" />, recentItems)}
{renderSection(t("fileManager.pinned"), <Star className="w-3 h-3" />, pinnedItems)}
{renderSection(t("fileManager.folderShortcuts"), <Bookmark className="w-3 h-3" />, shortcuts)}
<div className="absolute inset-1.5 overflow-y-auto thin-scrollbar space-y-4">
{/* 快捷功能区域 */}
{renderSection(
t("fileManager.recent"),
<Clock className="w-3 h-3" />,
recentItems,
)}
{renderSection(
t("fileManager.pinned"),
<Star className="w-3 h-3" />,
pinnedItems,
)}
{renderSection(
t("fileManager.folderShortcuts"),
<Bookmark className="w-3 h-3" />,
shortcuts,
)}
{/* 目录树 */}
<div className="border-t border-dark-border pt-4">
<div className="flex items-center gap-2 px-2 py-1 text-xs font-medium text-muted-foreground uppercase tracking-wider">
<Folder className="w-3 h-3" />
{t("fileManager.directories")}
{/* 目录树 */}
<div
className={cn(
hasQuickAccessItems && "pt-4 border-t border-dark-border",
)}
>
<div className="flex items-center gap-2 px-3 py-2 text-xs font-medium text-muted-foreground uppercase tracking-wider">
<Folder className="w-3 h-3" />
{t("fileManager.directories")}
</div>
<div className="mt-2">
{directoryTree.map((item) => renderSidebarItem(item))}
</div>
</div>
<div className="mt-2">
{directoryTree.map((item) => renderSidebarItem(item))}
</div>
</div>
</div>
</div>
</div>
@@ -447,65 +488,79 @@ export function FileManagerSidebar({
<div className="fixed inset-0 z-40" />
<div
data-sidebar-context-menu
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl py-1 min-w-[160px] z-50"
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[160px] z-50 overflow-hidden"
style={{
left: contextMenu.x,
top: contextMenu.y
top: contextMenu.y,
}}
>
{contextMenu.item.type === 'recent' && (
{contextMenu.item.type === "recent" && (
<>
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white"
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleRemoveRecentFile(contextMenu.item!);
closeContextMenu();
}}
>
<Clock className="w-4 h-4" />
<span>{t("fileManager.removeFromRecentFiles")}</span>
<div className="flex-shrink-0">
<Clock className="w-4 h-4" />
</div>
<span className="flex-1">
{t("fileManager.removeFromRecentFiles")}
</span>
</button>
{recentItems.length > 1 && (
<>
<div className="border-t border-dark-border my-1" />
<div className="border-t border-dark-border" />
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-red-400 hover:bg-red-500/10"
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-red-400 hover:bg-red-500/10 first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleClearAllRecent();
closeContextMenu();
}}
>
<Clock className="w-4 h-4" />
<span>{t("fileManager.clearAllRecentFiles")}</span>
<div className="flex-shrink-0">
<Clock className="w-4 h-4" />
</div>
<span className="flex-1">
{t("fileManager.clearAllRecentFiles")}
</span>
</button>
</>
)}
</>
)}
{contextMenu.item.type === 'pinned' && (
{contextMenu.item.type === "pinned" && (
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white"
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleUnpinFile(contextMenu.item!);
closeContextMenu();
}}
>
<Star className="w-4 h-4" />
<span>{t("fileManager.unpinFile")}</span>
<div className="flex-shrink-0">
<Star className="w-4 h-4" />
</div>
<span className="flex-1">{t("fileManager.unpinFile")}</span>
</button>
)}
{contextMenu.item.type === 'shortcut' && (
{contextMenu.item.type === "shortcut" && (
<button
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white"
className="w-full px-3 py-2 text-left text-sm flex items-center gap-3 hover:bg-dark-hover text-white first:rounded-t-lg last:rounded-b-lg"
onClick={() => {
handleRemoveShortcut(contextMenu.item!);
closeContextMenu();
}}
>
<Bookmark className="w-4 h-4" />
<span>{t("fileManager.removeShortcut")}</span>
<div className="flex-shrink-0">
<Bookmark className="w-4 h-4" />
</div>
<span className="flex-1">
{t("fileManager.removeShortcut")}
</span>
</button>
)}
</div>
@@ -513,4 +568,4 @@ export function FileManagerSidebar({
)}
</>
);
}
}

View File

@@ -1,17 +1,22 @@
import React, { useState, useEffect } from 'react';
import { DiffEditor } from '@monaco-editor/react';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import React, { useState, useEffect } from "react";
import { DiffEditor } from "@monaco-editor/react";
import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import {
Download,
RefreshCw,
Eye,
EyeOff,
ArrowLeftRight,
FileText
} from 'lucide-react';
import { readSSHFile, downloadSSHFile, getSSHStatus, connectSSH } from '@/ui/main-axios';
import type { FileItem, SSHHost } from '../../../../types/index.js';
FileText,
} from "lucide-react";
import {
readSSHFile,
downloadSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import type { FileItem, SSHHost } from "../../../../types/index.js";
interface DiffViewerProps {
file1: FileItem;
@@ -28,13 +33,15 @@ export function DiffViewer({
sshSessionId,
sshHost,
onDownload1,
onDownload2
onDownload2,
}: DiffViewerProps) {
const [content1, setContent1] = useState<string>('');
const [content2, setContent2] = useState<string>('');
const [content1, setContent1] = useState<string>("");
const [content2, setContent2] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [diffMode, setDiffMode] = useState<'side-by-side' | 'inline'>('side-by-side');
const [diffMode, setDiffMode] = useState<"side-by-side" | "inline">(
"side-by-side",
);
const [showLineNumbers, setShowLineNumbers] = useState(true);
// 确保SSH连接有效
@@ -52,19 +59,19 @@ export function DiffViewer({
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId
userId: sshHost.userId,
});
}
} catch (error) {
console.error('SSH connection check/reconnect failed:', error);
console.error("SSH connection check/reconnect failed:", error);
throw error;
}
};
// 加载文件内容
const loadFileContents = async () => {
if (file1.type !== 'file' || file2.type !== 'file') {
setError('只能对比文件类型的项目');
if (file1.type !== "file" || file2.type !== "file") {
setError("只能对比文件类型的项目");
return;
}
@@ -78,21 +85,28 @@ export function DiffViewer({
// 并行加载两个文件
const [response1, response2] = await Promise.all([
readSSHFile(sshSessionId, file1.path),
readSSHFile(sshSessionId, file2.path)
readSSHFile(sshSessionId, file2.path),
]);
setContent1(response1.content || '');
setContent2(response2.content || '');
setContent1(response1.content || "");
setContent2(response2.content || "");
} catch (error: any) {
console.error('Failed to load files for diff:', error);
console.error("Failed to load files for diff:", error);
const errorData = error?.response?.data;
if (errorData?.tooLarge) {
setError(`文件过大: ${errorData.error}`);
} else if (error.message?.includes('connection') || error.message?.includes('established')) {
setError(`SSH连接失败。请检查与 ${sshHost.name} (${sshHost.ip}:${sshHost.port}) 的连接`);
} else if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
setError(
`SSH连接失败。请检查与 ${sshHost.name} (${sshHost.ip}:${sshHost.port}) 的连接`,
);
} else {
setError(`加载文件失败: ${error.message || errorData?.error || '未知错误'}`);
setError(
`加载文件失败: ${error.message || errorData?.error || "未知错误"}`,
);
}
} finally {
setIsLoading(false);
@@ -112,10 +126,12 @@ export function DiffViewer({
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: response.mimeType || 'application/octet-stream' });
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
@@ -126,44 +142,44 @@ export function DiffViewer({
toast.success(`文件下载成功: ${file.name}`);
}
} catch (error: any) {
console.error('Failed to download file:', error);
toast.error(`下载失败: ${error.message || '未知错误'}`);
console.error("Failed to download file:", error);
toast.error(`下载失败: ${error.message || "未知错误"}`);
}
};
// 获取文件语言类型
const getFileLanguage = (fileName: string): string => {
const ext = fileName.split('.').pop()?.toLowerCase();
const ext = fileName.split(".").pop()?.toLowerCase();
const languageMap: Record<string, string> = {
'js': 'javascript',
'jsx': 'javascript',
'ts': 'typescript',
'tsx': 'typescript',
'py': 'python',
'java': 'java',
'c': 'c',
'cpp': 'cpp',
'cs': 'csharp',
'php': 'php',
'rb': 'ruby',
'go': 'go',
'rs': 'rust',
'html': 'html',
'css': 'css',
'scss': 'scss',
'less': 'less',
'json': 'json',
'xml': 'xml',
'yaml': 'yaml',
'yml': 'yaml',
'md': 'markdown',
'sql': 'sql',
'sh': 'shell',
'bash': 'shell',
'ps1': 'powershell',
'dockerfile': 'dockerfile'
js: "javascript",
jsx: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
java: "java",
c: "c",
cpp: "cpp",
cs: "csharp",
php: "php",
rb: "ruby",
go: "go",
rs: "rust",
html: "html",
css: "css",
scss: "scss",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
md: "markdown",
sql: "sql",
sh: "shell",
bash: "shell",
ps1: "powershell",
dockerfile: "dockerfile",
};
return languageMap[ext || ''] || 'plaintext';
return languageMap[ext || ""] || "plaintext";
};
// 初始加载
@@ -205,7 +221,9 @@ export function DiffViewer({
<div className="flex items-center gap-4">
<div className="text-sm">
<span className="text-muted-foreground"></span>
<span className="font-medium text-green-400 mx-2">{file1.name}</span>
<span className="font-medium text-green-400 mx-2">
{file1.name}
</span>
<ArrowLeftRight className="w-4 h-4 inline mx-1" />
<span className="font-medium text-blue-400">{file2.name}</span>
</div>
@@ -216,9 +234,13 @@ export function DiffViewer({
<Button
variant="outline"
size="sm"
onClick={() => setDiffMode(diffMode === 'side-by-side' ? 'inline' : 'side-by-side')}
onClick={() =>
setDiffMode(
diffMode === "side-by-side" ? "inline" : "side-by-side",
)
}
>
{diffMode === 'side-by-side' ? '并排' : '内联'}
{diffMode === "side-by-side" ? "并排" : "内联"}
</Button>
{/* 行号切换 */}
@@ -227,7 +249,11 @@ export function DiffViewer({
size="sm"
onClick={() => setShowLineNumbers(!showLineNumbers)}
>
{showLineNumbers ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
{showLineNumbers ? (
<Eye className="w-4 h-4" />
) : (
<EyeOff className="w-4 h-4" />
)}
</Button>
{/* 下载按钮 */}
@@ -252,11 +278,7 @@ export function DiffViewer({
</Button>
{/* 刷新按钮 */}
<Button
variant="outline"
size="sm"
onClick={loadFileContents}
>
<Button variant="outline" size="sm" onClick={loadFileContents}>
<RefreshCw className="w-4 h-4" />
</Button>
</div>
@@ -271,22 +293,22 @@ export function DiffViewer({
language={getFileLanguage(file1.name)}
theme="vs-dark"
options={{
renderSideBySide: diffMode === 'side-by-side',
lineNumbers: showLineNumbers ? 'on' : 'off',
renderSideBySide: diffMode === "side-by-side",
lineNumbers: showLineNumbers ? "on" : "off",
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
wordWrap: 'off',
wordWrap: "off",
automaticLayout: true,
readOnly: true,
originalEditable: false,
modifiedEditable: false,
scrollbar: {
vertical: 'visible',
horizontal: 'visible'
vertical: "visible",
horizontal: "visible",
},
diffWordWrap: 'off',
ignoreTrimWhitespace: false
diffWordWrap: "off",
ignoreTrimWhitespace: false,
}}
loading={
<div className="h-full flex items-center justify-center">
@@ -300,4 +322,4 @@ export function DiffViewer({
</div>
</div>
);
}
}

View File

@@ -1,8 +1,8 @@
import React from 'react';
import { DraggableWindow } from './DraggableWindow';
import { DiffViewer } from './DiffViewer';
import { useWindowManager } from './WindowManager';
import type { FileItem, SSHHost } from '../../../../types/index.js';
import React from "react";
import { DraggableWindow } from "./DraggableWindow";
import { DiffViewer } from "./DiffViewer";
import { useWindowManager } from "./WindowManager";
import type { FileItem, SSHHost } from "../../../../types/index.js";
interface DiffWindowProps {
windowId: string;
@@ -21,11 +21,12 @@ export function DiffWindow({
sshSessionId,
sshHost,
initialX = 150,
initialY = 100
initialY = 100,
}: DiffWindowProps) {
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = useWindowManager();
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
const currentWindow = windows.find(w => w.id === windowId);
const currentWindow = windows.find((w) => w.id === windowId);
// 窗口操作处理
const handleClose = () => {
@@ -72,4 +73,4 @@ export function DiffWindow({
/>
</DraggableWindow>
);
}
}

View File

@@ -1,6 +1,6 @@
import React, { useState, useRef, useCallback, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Minus, Square, X, Maximize2, Minimize2 } from 'lucide-react';
import React, { useState, useRef, useCallback, useEffect } from "react";
import { cn } from "@/lib/utils";
import { Minus, Square, X, Maximize2, Minimize2 } from "lucide-react";
interface DraggableWindowProps {
title: string;
@@ -33,14 +33,17 @@ export function DraggableWindow({
onMaximize,
isMaximized = false,
zIndex = 1000,
onFocus
onFocus,
}: DraggableWindowProps) {
// 窗口状态
const [position, setPosition] = useState({ x: initialX, y: initialY });
const [size, setSize] = useState({ width: initialWidth, height: initialHeight });
const [size, setSize] = useState({
width: initialWidth,
height: initialHeight,
});
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [resizeDirection, setResizeDirection] = useState<string>('');
const [resizeDirection, setResizeDirection] = useState<string>("");
// 拖拽开始位置
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
@@ -55,88 +58,120 @@ export function DraggableWindow({
}, [onFocus]);
// 拖拽处理
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (isMaximized) return;
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (isMaximized) return;
e.preventDefault();
setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
onFocus?.();
}, [isMaximized, position, onFocus]);
e.preventDefault();
setIsDragging(true);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: position.x, y: position.y });
onFocus?.();
},
[isMaximized, position, onFocus],
);
const handleMouseMove = useCallback((e: MouseEvent) => {
if (isDragging && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (isDragging && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
setPosition({
x: Math.max(0, Math.min(window.innerWidth - size.width, windowStart.x + deltaX)),
y: Math.max(0, Math.min(window.innerHeight - 40, windowStart.y + deltaY)) // 保持标题栏可见
});
}
if (isResizing && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
let newWidth = size.width;
let newHeight = size.height;
let newX = position.x;
let newY = position.y;
if (resizeDirection.includes('right')) {
newWidth = Math.max(minWidth, windowStart.x + deltaX);
}
if (resizeDirection.includes('left')) {
newWidth = Math.max(minWidth, size.width - deltaX);
newX = Math.min(windowStart.x + deltaX, position.x + size.width - minWidth);
}
if (resizeDirection.includes('bottom')) {
newHeight = Math.max(minHeight, windowStart.y + deltaY);
}
if (resizeDirection.includes('top')) {
newHeight = Math.max(minHeight, size.height - deltaY);
newY = Math.min(windowStart.y + deltaY, position.y + size.height - minHeight);
setPosition({
x: Math.max(
0,
Math.min(window.innerWidth - size.width, windowStart.x + deltaX),
),
y: Math.max(
0,
Math.min(window.innerHeight - 40, windowStart.y + deltaY),
), // 保持标题栏可见
});
}
setSize({ width: newWidth, height: newHeight });
setPosition({ x: newX, y: newY });
}
}, [isDragging, isResizing, isMaximized, dragStart, windowStart, size, position, minWidth, minHeight, resizeDirection]);
if (isResizing && !isMaximized) {
const deltaX = e.clientX - dragStart.x;
const deltaY = e.clientY - dragStart.y;
let newWidth = size.width;
let newHeight = size.height;
let newX = position.x;
let newY = position.y;
if (resizeDirection.includes("right")) {
newWidth = Math.max(minWidth, windowStart.x + deltaX);
}
if (resizeDirection.includes("left")) {
newWidth = Math.max(minWidth, size.width - deltaX);
newX = Math.min(
windowStart.x + deltaX,
position.x + size.width - minWidth,
);
}
if (resizeDirection.includes("bottom")) {
newHeight = Math.max(minHeight, windowStart.y + deltaY);
}
if (resizeDirection.includes("top")) {
newHeight = Math.max(minHeight, size.height - deltaY);
newY = Math.min(
windowStart.y + deltaY,
position.y + size.height - minHeight,
);
}
setSize({ width: newWidth, height: newHeight });
setPosition({ x: newX, y: newY });
}
},
[
isDragging,
isResizing,
isMaximized,
dragStart,
windowStart,
size,
position,
minWidth,
minHeight,
resizeDirection,
],
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
setIsResizing(false);
setResizeDirection('');
setResizeDirection("");
}, []);
// 调整大小处理
const handleResizeStart = useCallback((e: React.MouseEvent, direction: string) => {
if (isMaximized) return;
const handleResizeStart = useCallback(
(e: React.MouseEvent, direction: string) => {
if (isMaximized) return;
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeDirection(direction);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: size.width, y: size.height });
onFocus?.();
}, [isMaximized, size, onFocus]);
e.preventDefault();
e.stopPropagation();
setIsResizing(true);
setResizeDirection(direction);
setDragStart({ x: e.clientX, y: e.clientY });
setWindowStart({ x: size.width, y: size.height });
onFocus?.();
},
[isMaximized, size, onFocus],
);
// 全局事件监听
useEffect(() => {
if (isDragging || isResizing) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = 'none';
document.body.style.cursor = isDragging ? 'grabbing' : 'resizing';
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "none";
document.body.style.cursor = isDragging ? "grabbing" : "resizing";
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
document.body.style.cursor = '';
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
document.body.style.userSelect = "";
document.body.style.cursor = "";
};
}
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
@@ -152,14 +187,14 @@ export function DraggableWindow({
className={cn(
"absolute bg-card border border-border rounded-lg shadow-2xl",
"select-none overflow-hidden",
isMaximized ? "inset-0" : ""
isMaximized ? "inset-0" : "",
)}
style={{
left: isMaximized ? 0 : position.x,
top: isMaximized ? 0 : position.y,
width: isMaximized ? '100%' : size.width,
height: isMaximized ? '100%' : size.height,
zIndex
width: isMaximized ? "100%" : size.width,
height: isMaximized ? "100%" : size.height,
zIndex,
}}
onClick={handleWindowClick}
>
@@ -169,7 +204,7 @@ export function DraggableWindow({
className={cn(
"flex items-center justify-between px-3 py-2",
"bg-muted/50 text-foreground border-b border-border",
"cursor-grab active:cursor-grabbing"
"cursor-grab active:cursor-grabbing",
)}
onMouseDown={handleMouseDown}
onDoubleClick={handleTitleDoubleClick}
@@ -223,7 +258,10 @@ export function DraggableWindow({
</div>
{/* 窗口内容 */}
<div className="flex-1 overflow-auto" style={{ height: 'calc(100% - 40px)' }}>
<div
className="flex-1 overflow-auto"
style={{ height: "calc(100% - 40px)" }}
>
{children}
</div>
@@ -233,40 +271,40 @@ export function DraggableWindow({
{/* 边缘调整 */}
<div
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
onMouseDown={(e) => handleResizeStart(e, 'top')}
onMouseDown={(e) => handleResizeStart(e, "top")}
/>
<div
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize"
onMouseDown={(e) => handleResizeStart(e, 'bottom')}
onMouseDown={(e) => handleResizeStart(e, "bottom")}
/>
<div
className="absolute top-0 bottom-0 left-0 w-1 cursor-w-resize"
onMouseDown={(e) => handleResizeStart(e, 'left')}
onMouseDown={(e) => handleResizeStart(e, "left")}
/>
<div
className="absolute top-0 bottom-0 right-0 w-1 cursor-e-resize"
onMouseDown={(e) => handleResizeStart(e, 'right')}
onMouseDown={(e) => handleResizeStart(e, "right")}
/>
{/* 角落调整 */}
<div
className="absolute top-0 left-0 w-2 h-2 cursor-nw-resize"
onMouseDown={(e) => handleResizeStart(e, 'top-left')}
onMouseDown={(e) => handleResizeStart(e, "top-left")}
/>
<div
className="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
onMouseDown={(e) => handleResizeStart(e, 'top-right')}
onMouseDown={(e) => handleResizeStart(e, "top-right")}
/>
<div
className="absolute bottom-0 left-0 w-2 h-2 cursor-sw-resize"
onMouseDown={(e) => handleResizeStart(e, 'bottom-left')}
onMouseDown={(e) => handleResizeStart(e, "bottom-left")}
/>
<div
className="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
onMouseDown={(e) => handleResizeStart(e, 'bottom-right')}
onMouseDown={(e) => handleResizeStart(e, "bottom-right")}
/>
</>
)}
</div>
);
}
}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { cn } from '@/lib/utils';
import React, { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import {
FileText,
Image as ImageIcon,
@@ -15,8 +15,8 @@ import {
X,
ChevronUp,
ChevronDown,
Replace
} from 'lucide-react';
Replace,
} from "lucide-react";
import {
SiJavascript,
SiTypescript,
@@ -43,13 +43,13 @@ import {
SiMarkdown,
SiGnubash,
SiMysql,
SiDocker
} from 'react-icons/si';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import CodeMirror from '@uiw/react-codemirror';
import { oneDark } from '@uiw/codemirror-themes';
import { languages, loadLanguage } from '@uiw/codemirror-extensions-langs';
SiDocker,
} from "react-icons/si";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import CodeMirror from "@uiw/react-codemirror";
import { oneDark } from "@uiw/codemirror-themes";
import { languages, loadLanguage } from "@uiw/codemirror-extensions-langs";
interface FileItem {
name: string;
@@ -75,123 +75,183 @@ interface FileViewerProps {
// 获取编程语言的官方图标
function getLanguageIcon(filename: string): React.ReactNode {
const ext = filename.split('.').pop()?.toLowerCase() || '';
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
// 特殊文件名处理
if (['dockerfile'].includes(baseName)) {
if (["dockerfile"].includes(baseName)) {
return <SiDocker className="w-6 h-6 text-blue-400" />;
}
if (['makefile', 'rakefile', 'gemfile'].includes(baseName)) {
if (["makefile", "rakefile", "gemfile"].includes(baseName)) {
return <SiRuby className="w-6 h-6 text-red-500" />;
}
const iconMap: Record<string, React.ReactNode> = {
'js': <SiJavascript className="w-6 h-6 text-yellow-400" />,
'jsx': <SiJavascript className="w-6 h-6 text-yellow-400" />,
'ts': <SiTypescript className="w-6 h-6 text-blue-500" />,
'tsx': <SiTypescript className="w-6 h-6 text-blue-500" />,
'py': <SiPython className="w-6 h-6 text-blue-400" />,
'java': <SiOracle className="w-6 h-6 text-red-500" />,
'cpp': <SiCplusplus className="w-6 h-6 text-blue-600" />,
'c': <SiC className="w-6 h-6 text-blue-700" />,
'cs': <SiDotnet className="w-6 h-6 text-purple-600" />,
'php': <SiPhp className="w-6 h-6 text-indigo-500" />,
'rb': <SiRuby className="w-6 h-6 text-red-500" />,
'go': <SiGo className="w-6 h-6 text-cyan-500" />,
'rs': <SiRust className="w-6 h-6 text-orange-600" />,
'html': <SiHtml5 className="w-6 h-6 text-orange-500" />,
'css': <SiCss3 className="w-6 h-6 text-blue-500" />,
'scss': <SiSass className="w-6 h-6 text-pink-500" />,
'sass': <SiSass className="w-6 h-6 text-pink-500" />,
'less': <SiLess className="w-6 h-6 text-blue-600" />,
'json': <SiJson className="w-6 h-6 text-yellow-500" />,
'xml': <SiXml className="w-6 h-6 text-orange-500" />,
'yaml': <SiYaml className="w-6 h-6 text-red-400" />,
'yml': <SiYaml className="w-6 h-6 text-red-400" />,
'toml': <SiToml className="w-6 h-6 text-orange-400" />,
'sql': <SiMysql className="w-6 h-6 text-blue-500" />,
'sh': <SiGnubash className="w-6 h-6 text-gray-700" />,
'bash': <SiGnubash className="w-6 h-6 text-gray-700" />,
'zsh': <SiShell className="w-6 h-6 text-gray-700" />,
'vue': <SiVuedotjs className="w-6 h-6 text-green-500" />,
'svelte': <SiSvelte className="w-6 h-6 text-orange-500" />,
'md': <SiMarkdown className="w-6 h-6 text-gray-600" />,
'conf': <SiShell className="w-6 h-6 text-gray-600" />,
'ini': <Code className="w-6 h-6 text-gray-600" />
js: <SiJavascript className="w-6 h-6 text-yellow-400" />,
jsx: <SiJavascript className="w-6 h-6 text-yellow-400" />,
ts: <SiTypescript className="w-6 h-6 text-blue-500" />,
tsx: <SiTypescript className="w-6 h-6 text-blue-500" />,
py: <SiPython className="w-6 h-6 text-blue-400" />,
java: <SiOracle className="w-6 h-6 text-red-500" />,
cpp: <SiCplusplus className="w-6 h-6 text-blue-600" />,
c: <SiC className="w-6 h-6 text-blue-700" />,
cs: <SiDotnet className="w-6 h-6 text-purple-600" />,
php: <SiPhp className="w-6 h-6 text-indigo-500" />,
rb: <SiRuby className="w-6 h-6 text-red-500" />,
go: <SiGo className="w-6 h-6 text-cyan-500" />,
rs: <SiRust className="w-6 h-6 text-orange-600" />,
html: <SiHtml5 className="w-6 h-6 text-orange-500" />,
css: <SiCss3 className="w-6 h-6 text-blue-500" />,
scss: <SiSass className="w-6 h-6 text-pink-500" />,
sass: <SiSass className="w-6 h-6 text-pink-500" />,
less: <SiLess className="w-6 h-6 text-blue-600" />,
json: <SiJson className="w-6 h-6 text-yellow-500" />,
xml: <SiXml className="w-6 h-6 text-orange-500" />,
yaml: <SiYaml className="w-6 h-6 text-red-400" />,
yml: <SiYaml className="w-6 h-6 text-red-400" />,
toml: <SiToml className="w-6 h-6 text-orange-400" />,
sql: <SiMysql className="w-6 h-6 text-blue-500" />,
sh: <SiGnubash className="w-6 h-6 text-gray-700" />,
bash: <SiGnubash className="w-6 h-6 text-gray-700" />,
zsh: <SiShell className="w-6 h-6 text-gray-700" />,
vue: <SiVuedotjs className="w-6 h-6 text-green-500" />,
svelte: <SiSvelte className="w-6 h-6 text-orange-500" />,
md: <SiMarkdown className="w-6 h-6 text-gray-600" />,
conf: <SiShell className="w-6 h-6 text-gray-600" />,
ini: <Code className="w-6 h-6 text-gray-600" />,
};
return iconMap[ext] || <Code className="w-6 h-6 text-yellow-500" />;
}
// 获取文件类型和图标
function getFileType(filename: string): { type: string; icon: React.ReactNode; color: string } {
const ext = filename.split('.').pop()?.toLowerCase() || '';
function getFileType(filename: string): {
type: string;
icon: React.ReactNode;
color: string;
} {
const ext = filename.split(".").pop()?.toLowerCase() || "";
const imageExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'svg', 'webp'];
const videoExts = ['mp4', 'avi', 'mkv', 'mov', 'wmv', 'flv', 'webm'];
const audioExts = ['mp3', 'wav', 'flac', 'ogg', 'aac', 'm4a'];
const textExts = ['txt', 'readme'];
const codeExts = ['js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'cs', 'php', 'rb', 'go', 'rs', 'html', 'css', 'scss', 'less', 'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'conf', 'sh', 'bash', 'zsh', 'sql', 'vue', 'svelte', 'md'];
const imageExts = ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"];
const videoExts = ["mp4", "avi", "mkv", "mov", "wmv", "flv", "webm"];
const audioExts = ["mp3", "wav", "flac", "ogg", "aac", "m4a"];
const textExts = ["txt", "readme"];
const codeExts = [
"js",
"ts",
"jsx",
"tsx",
"py",
"java",
"cpp",
"c",
"cs",
"php",
"rb",
"go",
"rs",
"html",
"css",
"scss",
"less",
"json",
"xml",
"yaml",
"yml",
"toml",
"ini",
"conf",
"sh",
"bash",
"zsh",
"sql",
"vue",
"svelte",
"md",
];
if (imageExts.includes(ext)) {
return { type: 'image', icon: <ImageIcon className="w-6 h-6" />, color: 'text-green-500' };
return {
type: "image",
icon: <ImageIcon className="w-6 h-6" />,
color: "text-green-500",
};
} else if (videoExts.includes(ext)) {
return { type: 'video', icon: <Film className="w-6 h-6" />, color: 'text-purple-500' };
return {
type: "video",
icon: <Film className="w-6 h-6" />,
color: "text-purple-500",
};
} else if (audioExts.includes(ext)) {
return { type: 'audio', icon: <Music className="w-6 h-6" />, color: 'text-pink-500' };
return {
type: "audio",
icon: <Music className="w-6 h-6" />,
color: "text-pink-500",
};
} else if (textExts.includes(ext)) {
return { type: 'text', icon: <FileText className="w-6 h-6" />, color: 'text-blue-500' };
return {
type: "text",
icon: <FileText className="w-6 h-6" />,
color: "text-blue-500",
};
} else if (codeExts.includes(ext)) {
return { type: 'code', icon: getLanguageIcon(filename), color: 'text-yellow-500' };
return {
type: "code",
icon: getLanguageIcon(filename),
color: "text-yellow-500",
};
} else {
return { type: 'unknown', icon: <FileIcon className="w-6 h-6" />, color: 'text-gray-500' };
return {
type: "unknown",
icon: <FileIcon className="w-6 h-6" />,
color: "text-gray-500",
};
}
}
// 获取CodeMirror语言扩展
function getLanguageExtension(filename: string) {
const ext = filename.split('.').pop()?.toLowerCase() || '';
const ext = filename.split(".").pop()?.toLowerCase() || "";
const baseName = filename.toLowerCase();
// 特殊文件名处理
if (['dockerfile', 'makefile', 'rakefile', 'gemfile'].includes(baseName)) {
if (["dockerfile", "makefile", "rakefile", "gemfile"].includes(baseName)) {
return loadLanguage(baseName);
}
// 根据扩展名映射
const langMap: Record<string, string> = {
'js': 'javascript',
'jsx': 'jsx',
'ts': 'typescript',
'tsx': 'tsx',
'py': 'python',
'java': 'java',
'cpp': 'cpp',
'c': 'c',
'cs': 'csharp',
'php': 'php',
'rb': 'ruby',
'go': 'go',
'rs': 'rust',
'html': 'html',
'css': 'css',
'scss': 'sass',
'less': 'less',
'json': 'json',
'xml': 'xml',
'yaml': 'yaml',
'yml': 'yaml',
'toml': 'toml',
'sql': 'sql',
'sh': 'shell',
'bash': 'shell',
'zsh': 'shell',
'vue': 'vue',
'svelte': 'svelte',
'md': 'markdown',
'conf': 'shell',
'ini': 'properties'
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "tsx",
py: "python",
java: "java",
cpp: "cpp",
c: "c",
cs: "csharp",
php: "php",
rb: "ruby",
go: "go",
rs: "rust",
html: "html",
css: "css",
scss: "sass",
less: "less",
json: "json",
xml: "xml",
yaml: "yaml",
yml: "yaml",
toml: "toml",
sql: "sql",
sh: "shell",
bash: "shell",
zsh: "shell",
vue: "vue",
svelte: "svelte",
md: "markdown",
conf: "shell",
ini: "properties",
};
const language = langMap[ext];
@@ -200,32 +260,36 @@ function getLanguageExtension(filename: string) {
// 格式化文件大小
function formatFileSize(bytes?: number): string {
if (!bytes) return 'Unknown size';
const sizes = ['B', 'KB', 'MB', 'GB'];
if (!bytes) return "Unknown size";
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${sizes[i]}`;
}
export function FileViewer({
file,
content = '',
savedContent = '',
content = "",
savedContent = "",
isLoading = false,
isEditable = false,
onContentChange,
onSave,
onDownload
onDownload,
}: FileViewerProps) {
const [editedContent, setEditedContent] = useState(content);
const [originalContent, setOriginalContent] = useState(savedContent || content);
const [originalContent, setOriginalContent] = useState(
savedContent || content,
);
const [hasChanges, setHasChanges] = useState(false);
const [showLargeFileWarning, setShowLargeFileWarning] = useState(false);
const [forceShowAsText, setForceShowAsText] = useState(false);
const [showSearchPanel, setShowSearchPanel] = useState(false);
const [searchText, setSearchText] = useState('');
const [replaceText, setReplaceText] = useState('');
const [searchText, setSearchText] = useState("");
const [replaceText, setReplaceText] = useState("");
const [showReplacePanel, setShowReplacePanel] = useState(false);
const [searchMatches, setSearchMatches] = useState<{ start: number; end: number }[]>([]);
const [searchMatches, setSearchMatches] = useState<
{ start: number; end: number }[]
>([]);
const [currentMatchIndex, setCurrentMatchIndex] = useState(-1);
const fileTypeInfo = getFileType(file.name);
@@ -236,9 +300,10 @@ export function FileViewer({
// 检查是否应该显示为文本
const shouldShowAsText =
fileTypeInfo.type === 'text' ||
fileTypeInfo.type === 'code' ||
(fileTypeInfo.type === 'unknown' && (forceShowAsText || !file.size || file.size <= WARNING_SIZE));
fileTypeInfo.type === "text" ||
fileTypeInfo.type === "code" ||
(fileTypeInfo.type === "unknown" &&
(forceShowAsText || !file.size || file.size <= WARNING_SIZE));
// 检查文件是否过大
const isLargeFile = file.size && file.size > WARNING_SIZE;
@@ -254,7 +319,7 @@ export function FileViewer({
setHasChanges(content !== (savedContent || content));
// 如果是未知文件类型且文件较大,显示警告
if (fileTypeInfo.type === 'unknown' && isLargeFile && !forceShowAsText) {
if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) {
setShowLargeFileWarning(true);
} else {
setShowLargeFileWarning(false);
@@ -290,13 +355,13 @@ export function FileViewer({
}
const matches: { start: number; end: number }[] = [];
const regex = new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
const regex = new RegExp(text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi");
let match;
while ((match = regex.exec(editedContent)) !== null) {
matches.push({
start: match.index,
end: match.index + match[0].length
end: match.index + match[0].length,
});
// 避免无限循环
if (match.index === regex.lastIndex) regex.lastIndex++;
@@ -314,22 +379,32 @@ export function FileViewer({
const goToPrevMatch = () => {
if (searchMatches.length === 0) return;
setCurrentMatchIndex((prev) => (prev - 1 + searchMatches.length) % searchMatches.length);
setCurrentMatchIndex(
(prev) => (prev - 1 + searchMatches.length) % searchMatches.length,
);
};
// 替换功能
const handleFindReplace = (findText: string, replaceWithText: string, replaceAll: boolean = false) => {
const handleFindReplace = (
findText: string,
replaceWithText: string,
replaceAll: boolean = false,
) => {
if (!findText) return;
let newContent = editedContent;
if (replaceAll) {
newContent = newContent.replace(new RegExp(findText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), replaceWithText);
newContent = newContent.replace(
new RegExp(findText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"),
replaceWithText,
);
} else if (currentMatchIndex >= 0 && searchMatches[currentMatchIndex]) {
// 替换当前匹配项
const match = searchMatches[currentMatchIndex];
newContent = editedContent.substring(0, match.start) +
replaceWithText +
editedContent.substring(match.end);
newContent =
editedContent.substring(0, match.start) +
replaceWithText +
editedContent.substring(match.end);
}
setEditedContent(newContent);
@@ -374,11 +449,11 @@ export function FileViewer({
"font-bold",
isCurrentMatch
? "text-red-600 bg-yellow-200"
: "text-blue-800 bg-blue-100"
: "text-blue-800 bg-blue-100",
)}
>
{text.substring(match.start, match.end)}
</span>
</span>,
);
lastIndex = match.end;
@@ -428,7 +503,13 @@ export function FileViewer({
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span>{formatFileSize(file.size)}</span>
{file.modified && <span>Modified: {file.modified}</span>}
<span className={cn("px-2 py-1 rounded-full text-xs", fileTypeInfo.color, "bg-muted")}>
<span
className={cn(
"px-2 py-1 rounded-full text-xs",
fileTypeInfo.color,
"bg-muted",
)}
>
{fileTypeInfo.type.toUpperCase()}
</span>
</div>
@@ -446,7 +527,6 @@ export function FileViewer({
className="flex items-center gap-2"
>
<Search className="w-4 h-4" />
Find
</Button>
<Button
variant="ghost"
@@ -455,7 +535,6 @@ export function FileViewer({
className="flex items-center gap-2"
>
<Replace className="w-4 h-4" />
Replace
</Button>
</>
)}
@@ -529,8 +608,9 @@ export function FileViewer({
<span className="text-xs text-muted-foreground min-w-[3rem]">
{searchMatches.length > 0
? `${currentMatchIndex + 1}/${searchMatches.length}`
: searchText ? '0/0' : ''
}
: searchText
? "0/0"
: ""}
</span>
</div>
<Button
@@ -538,7 +618,7 @@ export function FileViewer({
size="sm"
onClick={() => {
setShowSearchPanel(false);
setSearchText('');
setSearchText("");
setSearchMatches([]);
setCurrentMatchIndex(-1);
}}
@@ -557,7 +637,9 @@ export function FileViewer({
<Button
variant="outline"
size="sm"
onClick={() => handleFindReplace(searchText, replaceText, false)}
onClick={() =>
handleFindReplace(searchText, replaceText, false)
}
disabled={!searchText}
>
Replace
@@ -584,19 +666,24 @@ export function FileViewer({
<div className="flex items-start gap-3 mb-4">
<AlertCircle className="w-6 h-6 text-destructive flex-shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-foreground mb-2">Large File Warning</h3>
<h3 className="font-medium text-foreground mb-2">
Large File Warning
</h3>
<p className="text-sm text-muted-foreground mb-3">
This file is {formatFileSize(file.size)} in size, which may cause performance issues when opened as text.
This file is {formatFileSize(file.size)} in size, which may
cause performance issues when opened as text.
</p>
{isTooLarge ? (
<div className="bg-destructive/10 border border-destructive/30 rounded p-3 mb-4">
<p className="text-sm text-destructive font-medium">
File is too large (&gt; 10MB) and cannot be opened as text for security reasons.
File is too large (&gt; 10MB) and cannot be opened as
text for security reasons.
</p>
</div>
) : (
<p className="text-sm text-muted-foreground mb-4">
Do you want to continue opening this file as text? This may slow down your browser.
Do you want to continue opening this file as text? This
may slow down your browser.
</p>
)}
</div>
@@ -636,14 +723,14 @@ export function FileViewer({
)}
{/* 图片预览 */}
{fileTypeInfo.type === 'image' && !showLargeFileWarning && (
{fileTypeInfo.type === "image" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full">
<img
src={`data:image/*;base64,${content}`}
alt={file.name}
className="max-w-full max-h-full object-contain rounded-lg shadow-sm"
onError={(e) => {
(e.target as HTMLElement).style.display = 'none';
(e.target as HTMLElement).style.display = "none";
// Show error message instead
}}
/>
@@ -653,7 +740,7 @@ export function FileViewer({
{/* 文本和代码文件预览 */}
{shouldShowAsText && !showLargeFileWarning && (
<div className="h-full flex flex-col">
{fileTypeInfo.type === 'code' ? (
{fileTypeInfo.type === "code" ? (
// 代码文件使用CodeMirror
<div className="h-full">
{searchText && searchMatches.length > 0 ? (
@@ -661,8 +748,11 @@ export function FileViewer({
<div className="h-full flex bg-muted">
{/* 行号列 */}
<div className="flex-shrink-0 bg-muted border-r border-border px-2 py-4 text-xs text-muted-foreground font-mono select-none">
{editedContent.split('\n').map((_, index) => (
<div key={index + 1} className="text-right leading-5 min-w-[2rem]">
{editedContent.split("\n").map((_, index) => (
<div
key={index + 1}
className="text-right leading-5 min-w-[2rem]"
>
{index + 1}
</div>
))}
@@ -677,7 +767,11 @@ export function FileViewer({
<CodeMirror
value={editedContent}
onChange={(value) => handleContentChange(value)}
extensions={getLanguageExtension(file.name) ? [getLanguageExtension(file.name)!] : []}
extensions={
getLanguageExtension(file.name)
? [getLanguageExtension(file.name)!]
: []
}
theme="dark"
basicSetup={{
lineNumbers: true,
@@ -688,7 +782,7 @@ export function FileViewer({
bracketMatching: true,
closeBrackets: true,
autocompletion: true,
highlightSelectionMatches: false
highlightSelectionMatches: false,
}}
className="h-full overflow-auto"
readOnly={!isEditable}
@@ -719,7 +813,7 @@ export function FileViewer({
) : (
// 只有非可编辑文件(媒体文件)才显示为只读
<div className="h-full p-4 font-mono text-sm whitespace-pre-wrap overflow-auto bg-background text-foreground">
{editedContent || content || 'File is empty'}
{editedContent || content || "File is empty"}
</div>
)}
</div>
@@ -728,7 +822,7 @@ export function FileViewer({
)}
{/* 视频文件预览 */}
{fileTypeInfo.type === 'video' && !showLargeFileWarning && (
{fileTypeInfo.type === "video" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full">
<video
controls
@@ -741,10 +835,15 @@ export function FileViewer({
)}
{/* 音频文件预览 */}
{fileTypeInfo.type === 'audio' && !showLargeFileWarning && (
{fileTypeInfo.type === "audio" && !showLargeFileWarning && (
<div className="p-6 flex items-center justify-center h-full">
<div className="text-center">
<div className={cn("w-24 h-24 mx-auto mb-4 rounded-full bg-pink-100 flex items-center justify-center", fileTypeInfo.color)}>
<div
className={cn(
"w-24 h-24 mx-auto mb-4 rounded-full bg-pink-100 flex items-center justify-center",
fileTypeInfo.color,
)}
>
<Music className="w-12 h-12" />
</div>
<audio
@@ -759,27 +858,32 @@ export function FileViewer({
)}
{/* 未知文件类型 - 只在不能显示为文本且没有警告时显示 */}
{fileTypeInfo.type === 'unknown' && !shouldShowAsText && !showLargeFileWarning && (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Cannot preview this file type</h3>
<p className="text-sm mb-4">
This file type is not supported for preview. You can download it to view in an external application.
</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
Download File
</Button>
)}
{fileTypeInfo.type === "unknown" &&
!shouldShowAsText &&
!showLargeFileWarning && (
<div className="h-full flex items-center justify-center">
<div className="text-center text-muted-foreground">
<AlertCircle className="w-16 h-16 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">
Cannot preview this file type
</h3>
<p className="text-sm mb-4">
This file type is not supported for preview. You can download
it to view in an external application.
</p>
{onDownload && (
<Button
variant="outline"
onClick={onDownload}
className="flex items-center gap-2 mx-auto"
>
<Download className="w-4 h-4" />
Download File
</Button>
)}
</div>
</div>
</div>
)}
)}
</div>
{/* 底部状态栏 */}
@@ -787,10 +891,12 @@ export function FileViewer({
<div className="flex justify-between items-center">
<span>{file.path}</span>
{hasChanges && (
<span className="text-orange-600 font-medium"> Unsaved changes</span>
<span className="text-orange-600 font-medium">
Unsaved changes
</span>
)}
</div>
</div>
</div>
);
}
}

View File

@@ -1,9 +1,15 @@
import React, { useState, useEffect, useRef } from 'react';
import { DraggableWindow } from './DraggableWindow';
import { FileViewer } from './FileViewer';
import { useWindowManager } from './WindowManager';
import { downloadSSHFile, readSSHFile, writeSSHFile, getSSHStatus, connectSSH } from '@/ui/main-axios';
import { toast } from 'sonner';
import React, { useState, useEffect, useRef } from "react";
import { DraggableWindow } from "./DraggableWindow";
import { FileViewer } from "./FileViewer";
import { useWindowManager } from "./WindowManager";
import {
downloadSSHFile,
readSSHFile,
writeSSHFile,
getSSHStatus,
connectSSH,
} from "@/ui/main-axios";
import { toast } from "sonner";
interface FileItem {
name: string;
@@ -25,7 +31,7 @@ interface SSHHost {
password?: string;
key?: string;
keyPassword?: string;
authType: 'password' | 'key';
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
@@ -46,27 +52,34 @@ export function FileWindow({
sshSessionId,
sshHost,
initialX = 100,
initialY = 100
initialY = 100,
}: FileWindowProps) {
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, updateWindow, windows } = useWindowManager();
const {
closeWindow,
minimizeWindow,
maximizeWindow,
focusWindow,
updateWindow,
windows,
} = useWindowManager();
const [content, setContent] = useState<string>('');
const [content, setContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false);
const [isEditable, setIsEditable] = useState(false);
const [pendingContent, setPendingContent] = useState<string>('');
const [pendingContent, setPendingContent] = useState<string>("");
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
const currentWindow = windows.find(w => w.id === windowId);
const currentWindow = windows.find((w) => w.id === windowId);
// 确保SSH连接有效
const ensureSSHConnection = async () => {
try {
// 首先检查SSH连接状态
const status = await getSSHStatus(sshSessionId);
console.log('SSH connection status:', status);
console.log("SSH connection status:", status);
if (!status.connected) {
console.log('SSH not connected, attempting to reconnect...');
console.log("SSH not connected, attempting to reconnect...");
// 重新建立连接
await connectSSH(sshSessionId, {
@@ -79,13 +92,13 @@ export function FileWindow({
keyPassword: sshHost.keyPassword,
authType: sshHost.authType,
credentialId: sshHost.credentialId,
userId: sshHost.userId
userId: sshHost.userId,
});
console.log('SSH reconnection successful');
console.log("SSH reconnection successful");
}
} catch (error) {
console.log('SSH connection check/reconnect failed:', error);
console.log("SSH connection check/reconnect failed:", error);
// 即使连接失败也尝试继续让具体的API调用报错
throw error;
}
@@ -94,7 +107,7 @@ export function FileWindow({
// 加载文件内容
useEffect(() => {
const loadFileContent = async () => {
if (file.type !== 'file') return;
if (file.type !== "file") return;
try {
setIsLoading(true);
@@ -103,7 +116,7 @@ export function FileWindow({
await ensureSSHConnection();
const response = await readSSHFile(sshSessionId, file.path);
const fileContent = response.content || '';
const fileContent = response.content || "";
setContent(fileContent);
setPendingContent(fileContent); // 初始化待保存内容
@@ -116,22 +129,54 @@ export function FileWindow({
// 根据文件类型决定是否可编辑:除了媒体文件,其他都可编辑
const mediaExtensions = [
// 图片文件
'jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'tiff', 'ico',
"jpg",
"jpeg",
"png",
"gif",
"bmp",
"svg",
"webp",
"tiff",
"ico",
// 音频文件
'mp3', 'wav', 'ogg', 'aac', 'flac', 'm4a', 'wma',
"mp3",
"wav",
"ogg",
"aac",
"flac",
"m4a",
"wma",
// 视频文件
'mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v',
"mp4",
"avi",
"mov",
"wmv",
"flv",
"mkv",
"webm",
"m4v",
// 压缩文件
'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz',
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"xz",
// 二进制文件
'exe', 'dll', 'so', 'dylib', 'bin', 'iso'
"exe",
"dll",
"so",
"dylib",
"bin",
"iso",
];
const extension = file.name.split('.').pop()?.toLowerCase();
const extension = file.name.split(".").pop()?.toLowerCase();
// 只有媒体文件和二进制文件不可编辑,其他所有文件都可编辑
setIsEditable(!mediaExtensions.includes(extension || ''));
setIsEditable(!mediaExtensions.includes(extension || ""));
} catch (error: any) {
console.error('Failed to load file:', error);
console.error("Failed to load file:", error);
// 检查是否是大文件错误
const errorData = error?.response?.data;
@@ -139,11 +184,18 @@ export function FileWindow({
toast.error(`File too large: ${errorData.error}`, {
duration: 10000, // 10 seconds for important message
});
} else if (error.message?.includes('connection') || error.message?.includes('established')) {
} else if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
// 如果是连接错误,提供更明确的错误信息
toast.error(`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`);
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(`Failed to load file: ${error.message || errorData?.error || 'Unknown error'}`);
toast.error(
`Failed to load file: ${error.message || errorData?.error || "Unknown error"}`,
);
}
} finally {
setIsLoading(false);
@@ -163,7 +215,7 @@ export function FileWindow({
await writeSSHFile(sshSessionId, file.path, newContent);
setContent(newContent);
setPendingContent(''); // 清除待保存内容
setPendingContent(""); // 清除待保存内容
// 清除自动保存定时器
if (autoSaveTimerRef.current) {
@@ -171,15 +223,20 @@ export function FileWindow({
autoSaveTimerRef.current = null;
}
toast.success('File saved successfully');
toast.success("File saved successfully");
} catch (error: any) {
console.error('Failed to save file:', error);
console.error("Failed to save file:", error);
// 如果是连接错误,提供更明确的错误信息
if (error.message?.includes('connection') || error.message?.includes('established')) {
toast.error(`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`);
if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(`Failed to save file: ${error.message || 'Unknown error'}`);
toast.error(`Failed to save file: ${error.message || "Unknown error"}`);
}
} finally {
setIsLoading(false);
@@ -198,12 +255,12 @@ export function FileWindow({
// 设置新的1分钟自动保存定时器
autoSaveTimerRef.current = setTimeout(async () => {
try {
console.log('Auto-saving file...');
console.log("Auto-saving file...");
await handleSave(newContent);
toast.success('File auto-saved');
toast.success("File auto-saved");
} catch (error) {
console.error('Auto-save failed:', error);
toast.error('Auto-save failed');
console.error("Auto-save failed:", error);
toast.error("Auto-save failed");
}
}, 60000); // 1分钟 = 60000毫秒
};
@@ -233,10 +290,12 @@ export function FileWindow({
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: response.mimeType || 'application/octet-stream' });
const blob = new Blob([byteArray], {
type: response.mimeType || "application/octet-stream",
});
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
const link = document.createElement("a");
link.href = url;
link.download = response.fileName || file.name;
document.body.appendChild(link);
@@ -244,16 +303,23 @@ export function FileWindow({
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success('File downloaded successfully');
toast.success("File downloaded successfully");
}
} catch (error: any) {
console.error('Failed to download file:', error);
console.error("Failed to download file:", error);
// 如果是连接错误,提供更明确的错误信息
if (error.message?.includes('connection') || error.message?.includes('established')) {
toast.error(`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`);
if (
error.message?.includes("connection") ||
error.message?.includes("established")
) {
toast.error(
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
);
} else {
toast.error(`Failed to download file: ${error.message || 'Unknown error'}`);
toast.error(
`Failed to download file: ${error.message || "Unknown error"}`,
);
}
}
};
@@ -307,4 +373,4 @@ export function FileWindow({
/>
</DraggableWindow>
);
}
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { DraggableWindow } from './DraggableWindow';
import { Terminal } from '../../Terminal/Terminal';
import { useWindowManager } from './WindowManager';
import React from "react";
import { DraggableWindow } from "./DraggableWindow";
import { Terminal } from "../../Terminal/Terminal";
import { useWindowManager } from "./WindowManager";
interface SSHHost {
id: number;
@@ -12,7 +12,7 @@ interface SSHHost {
password?: string;
key?: string;
keyPassword?: string;
authType: 'password' | 'key';
authType: "password" | "key";
credentialId?: number;
userId?: number;
}
@@ -32,12 +32,13 @@ export function TerminalWindow({
initialPath,
initialX = 200,
initialY = 150,
executeCommand
executeCommand,
}: TerminalWindowProps) {
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } = useWindowManager();
const { closeWindow, minimizeWindow, maximizeWindow, focusWindow, windows } =
useWindowManager();
// 获取当前窗口状态
const currentWindow = windows.find(w => w.id === windowId);
const currentWindow = windows.find((w) => w.id === windowId);
if (!currentWindow) {
console.warn(`Window with id ${windowId} not found`);
return null;
@@ -62,8 +63,8 @@ export function TerminalWindow({
const terminalTitle = executeCommand
? `运行 - ${hostConfig.name}:${executeCommand}`
: initialPath
? `终端 - ${hostConfig.name}:${initialPath}`
: `终端 - ${hostConfig.name}`;
? `终端 - ${hostConfig.name}:${initialPath}`
: `终端 - ${hostConfig.name}`;
return (
<DraggableWindow
@@ -90,4 +91,4 @@ export function TerminalWindow({
/>
</DraggableWindow>
);
}
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef } from 'react';
import React, { useState, useCallback, useRef } from "react";
export interface WindowInstance {
id: string;
@@ -19,7 +19,7 @@ interface WindowManagerProps {
interface WindowManagerContextType {
windows: WindowInstance[];
openWindow: (window: Omit<WindowInstance, 'id' | 'zIndex'>) => string;
openWindow: (window: Omit<WindowInstance, "id" | "zIndex">) => string;
closeWindow: (id: string) => void;
minimizeWindow: (id: string) => void;
maximizeWindow: (id: string) => void;
@@ -27,7 +27,8 @@ interface WindowManagerContextType {
updateWindow: (id: string, updates: Partial<WindowInstance>) => void;
}
const WindowManagerContext = React.createContext<WindowManagerContextType | null>(null);
const WindowManagerContext =
React.createContext<WindowManagerContextType | null>(null);
export function WindowManager({ children }: WindowManagerProps) {
const [windows, setWindows] = useState<WindowInstance[]>([]);
@@ -35,65 +36,73 @@ export function WindowManager({ children }: WindowManagerProps) {
const windowCounter = useRef(0);
// 打开新窗口
const openWindow = useCallback((windowData: Omit<WindowInstance, 'id' | 'zIndex'>) => {
const id = `window-${++windowCounter.current}`;
const zIndex = ++nextZIndex.current;
const openWindow = useCallback(
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
const id = `window-${++windowCounter.current}`;
const zIndex = ++nextZIndex.current;
// 计算偏移位置,避免窗口完全重叠
const offset = (windows.length % 5) * 30;
const adjustedX = windowData.x + offset;
const adjustedY = windowData.y + offset;
// 计算偏移位置,避免窗口完全重叠
const offset = (windows.length % 5) * 30;
const adjustedX = windowData.x + offset;
const adjustedY = windowData.y + offset;
const newWindow: WindowInstance = {
...windowData,
id,
zIndex,
x: adjustedX,
y: adjustedY,
};
const newWindow: WindowInstance = {
...windowData,
id,
zIndex,
x: adjustedX,
y: adjustedY,
};
setWindows(prev => [...prev, newWindow]);
return id;
}, [windows.length]);
setWindows((prev) => [...prev, newWindow]);
return id;
},
[windows.length],
);
// 关闭窗口
const closeWindow = useCallback((id: string) => {
setWindows(prev => prev.filter(w => w.id !== id));
setWindows((prev) => prev.filter((w) => w.id !== id));
}, []);
// 最小化窗口
const minimizeWindow = useCallback((id: string) => {
setWindows(prev => prev.map(w =>
w.id === id ? { ...w, isMinimized: !w.isMinimized } : w
));
setWindows((prev) =>
prev.map((w) =>
w.id === id ? { ...w, isMinimized: !w.isMinimized } : w,
),
);
}, []);
// 最大化/还原窗口
const maximizeWindow = useCallback((id: string) => {
setWindows(prev => prev.map(w =>
w.id === id ? { ...w, isMaximized: !w.isMaximized } : w
));
setWindows((prev) =>
prev.map((w) =>
w.id === id ? { ...w, isMaximized: !w.isMaximized } : w,
),
);
}, []);
// 聚焦窗口 (置于顶层)
const focusWindow = useCallback((id: string) => {
setWindows(prev => {
const targetWindow = prev.find(w => w.id === id);
setWindows((prev) => {
const targetWindow = prev.find((w) => w.id === id);
if (!targetWindow) return prev;
const newZIndex = ++nextZIndex.current;
return prev.map(w =>
w.id === id ? { ...w, zIndex: newZIndex } : w
);
return prev.map((w) => (w.id === id ? { ...w, zIndex: newZIndex } : w));
});
}, []);
// 更新窗口属性
const updateWindow = useCallback((id: string, updates: Partial<WindowInstance>) => {
setWindows(prev => prev.map(w =>
w.id === id ? { ...w, ...updates } : w
));
}, []);
const updateWindow = useCallback(
(id: string, updates: Partial<WindowInstance>) => {
setWindows((prev) =>
prev.map((w) => (w.id === id ? { ...w, ...updates } : w)),
);
},
[],
);
const contextValue: WindowManagerContextType = {
windows,
@@ -110,9 +119,9 @@ export function WindowManager({ children }: WindowManagerProps) {
{children}
{/* 渲染所有窗口 */}
<div className="window-container">
{windows.map(window => (
{windows.map((window) => (
<div key={window.id}>
{typeof window.component === 'function'
{typeof window.component === "function"
? window.component(window.id)
: window.component}
</div>
@@ -126,7 +135,7 @@ export function WindowManager({ children }: WindowManagerProps) {
export function useWindowManager() {
const context = React.useContext(WindowManagerContext);
if (!context) {
throw new Error('useWindowManager must be used within a WindowManager');
throw new Error("useWindowManager must be used within a WindowManager");
}
return context;
}
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback } from "react";
interface DragAndDropState {
isDragging: boolean;
@@ -17,76 +17,81 @@ export function useDragAndDrop({
onFilesDropped,
onError,
maxFileSize = 100, // 100MB default
allowedTypes = [] // empty means all types allowed
allowedTypes = [], // empty means all types allowed
}: UseDragAndDropProps) {
const [state, setState] = useState<DragAndDropState>({
isDragging: false,
dragCounter: 0,
draggedFiles: []
draggedFiles: [],
});
const validateFiles = useCallback((files: FileList): string | null => {
const maxSizeBytes = maxFileSize * 1024 * 1024;
const validateFiles = useCallback(
(files: FileList): string | null => {
const maxSizeBytes = maxFileSize * 1024 * 1024;
for (let i = 0; i < files.length; i++) {
const file = files[i];
for (let i = 0; i < files.length; i++) {
const file = files[i];
// Check file size
if (file.size > maxSizeBytes) {
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
}
// Check file size
if (file.size > maxSizeBytes) {
return `File "${file.name}" is too large. Maximum size is ${maxFileSize}MB.`;
}
// Check file type if restrictions exist
if (allowedTypes.length > 0) {
const fileExt = file.name.split('.').pop()?.toLowerCase();
const mimeType = file.type.toLowerCase();
// Check file type if restrictions exist
if (allowedTypes.length > 0) {
const fileExt = file.name.split(".").pop()?.toLowerCase();
const mimeType = file.type.toLowerCase();
const isAllowed = allowedTypes.some(type => {
// Check by extension
if (type.startsWith('.')) {
return fileExt === type.slice(1);
const isAllowed = allowedTypes.some((type) => {
// Check by extension
if (type.startsWith(".")) {
return fileExt === type.slice(1);
}
// Check by MIME type
if (type.includes("/")) {
return (
mimeType === type || mimeType.startsWith(type.replace("*", ""))
);
}
// Check by category
switch (type) {
case "image":
return mimeType.startsWith("image/");
case "video":
return mimeType.startsWith("video/");
case "audio":
return mimeType.startsWith("audio/");
case "text":
return mimeType.startsWith("text/");
default:
return false;
}
});
if (!isAllowed) {
return `File type "${file.type || "unknown"}" is not allowed.`;
}
// Check by MIME type
if (type.includes('/')) {
return mimeType === type || mimeType.startsWith(type.replace('*', ''));
}
// Check by category
switch (type) {
case 'image':
return mimeType.startsWith('image/');
case 'video':
return mimeType.startsWith('video/');
case 'audio':
return mimeType.startsWith('audio/');
case 'text':
return mimeType.startsWith('text/');
default:
return false;
}
});
if (!isAllowed) {
return `File type "${file.type || 'unknown'}" is not allowed.`;
}
}
}
return null;
}, [maxFileSize, allowedTypes]);
return null;
},
[maxFileSize, allowedTypes],
);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState(prev => ({
setState((prev) => ({
...prev,
dragCounter: prev.dragCounter + 1
dragCounter: prev.dragCounter + 1,
}));
if (e.dataTransfer.items && e.dataTransfer.items.length > 0) {
setState(prev => ({
setState((prev) => ({
...prev,
isDragging: true
isDragging: true,
}));
}
}, []);
@@ -95,12 +100,12 @@ export function useDragAndDrop({
e.preventDefault();
e.stopPropagation();
setState(prev => {
setState((prev) => {
const newCounter = prev.dragCounter - 1;
return {
...prev,
dragCounter: newCounter,
isDragging: newCounter > 0
isDragging: newCounter > 0,
};
});
}, []);
@@ -110,39 +115,42 @@ export function useDragAndDrop({
e.stopPropagation();
// Set dropEffect to indicate what operation is allowed
e.dataTransfer.dropEffect = 'copy';
e.dataTransfer.dropEffect = "copy";
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: []
});
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: [],
});
const files = e.dataTransfer.files;
const files = e.dataTransfer.files;
if (files.length === 0) {
return;
}
if (files.length === 0) {
return;
}
const validationError = validateFiles(files);
if (validationError) {
onError?.(validationError);
return;
}
const validationError = validateFiles(files);
if (validationError) {
onError?.(validationError);
return;
}
onFilesDropped(files);
}, [validateFiles, onFilesDropped, onError]);
onFilesDropped(files);
},
[validateFiles, onFilesDropped, onError],
);
const resetDragState = useCallback(() => {
setState({
isDragging: false,
dragCounter: 0,
draggedFiles: []
draggedFiles: [],
});
}, []);
@@ -152,8 +160,8 @@ export function useDragAndDrop({
onDragEnter: handleDragEnter,
onDragLeave: handleDragLeave,
onDragOver: handleDragOver,
onDrop: handleDrop
onDrop: handleDrop,
},
resetDragState
resetDragState,
};
}
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from 'react';
import { useState, useCallback } from "react";
interface FileItem {
name: string;
@@ -16,10 +16,10 @@ export function useFileSelection() {
const selectFile = useCallback((file: FileItem, multiSelect = false) => {
if (multiSelect) {
setSelectedFiles(prev => {
const isSelected = prev.some(f => f.path === file.path);
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter(f => f.path !== file.path);
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
@@ -29,17 +29,20 @@ export function useFileSelection() {
}
}, []);
const selectRange = useCallback((files: FileItem[], startFile: FileItem, endFile: FileItem) => {
const startIndex = files.findIndex(f => f.path === startFile.path);
const endIndex = files.findIndex(f => f.path === endFile.path);
const selectRange = useCallback(
(files: FileItem[], startFile: FileItem, endFile: FileItem) => {
const startIndex = files.findIndex((f) => f.path === startFile.path);
const endIndex = files.findIndex((f) => f.path === endFile.path);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const rangeFiles = files.slice(start, end + 1);
setSelectedFiles(rangeFiles);
}
}, []);
if (startIndex !== -1 && endIndex !== -1) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const rangeFiles = files.slice(start, end + 1);
setSelectedFiles(rangeFiles);
}
},
[],
);
const selectAll = useCallback((files: FileItem[]) => {
setSelectedFiles([...files]);
@@ -50,26 +53,32 @@ export function useFileSelection() {
}, []);
const toggleSelection = useCallback((file: FileItem) => {
setSelectedFiles(prev => {
const isSelected = prev.some(f => f.path === file.path);
setSelectedFiles((prev) => {
const isSelected = prev.some((f) => f.path === file.path);
if (isSelected) {
return prev.filter(f => f.path !== file.path);
return prev.filter((f) => f.path !== file.path);
} else {
return [...prev, file];
}
});
}, []);
const isSelected = useCallback((file: FileItem) => {
return selectedFiles.some(f => f.path === file.path);
}, [selectedFiles]);
const isSelected = useCallback(
(file: FileItem) => {
return selectedFiles.some((f) => f.path === file.path);
},
[selectedFiles],
);
const getSelectedCount = useCallback(() => {
return selectedFiles.length;
}, [selectedFiles]);
const setSelection = useCallback((files: FileItem[]) => {
console.log('Setting selection to:', files.map(f => f.name));
console.log(
"Setting selection to:",
files.map((f) => f.name),
);
setSelectedFiles(files);
}, []);
@@ -82,6 +91,6 @@ export function useFileSelection() {
toggleSelection,
isSelected,
getSelectedCount,
setSelection
setSelection,
};
}
}

View File

@@ -208,7 +208,10 @@ export function HostManagerEditor({
})
.superRefine((data, ctx) => {
if (data.authType === "password") {
if (data.requirePassword && (!data.password || data.password.trim() === "")) {
if (
data.requirePassword &&
(!data.password || data.password.trim() === "")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("hosts.passwordRequired"),
@@ -425,7 +428,11 @@ export function HostManagerEditor({
submitData.keyType = null;
if (data.authType === "credential") {
if (data.credentialId === "existing_credential" && editingHost && editingHost.id) {
if (
data.credentialId === "existing_credential" &&
editingHost &&
editingHost.id
) {
delete submitData.credentialId;
} else {
submitData.credentialId = data.credentialId;
@@ -1521,7 +1528,11 @@ export function HostManagerEditor({
<footer className="shrink-0 w-full pb-0">
<Separator className="p-0.25" />
<Button className="translate-y-2" type="submit" variant="outline">
{editingHost ? editingHost.id ? t("hosts.updateHost") : t("hosts.cloneHost") : t("hosts.addHost")}
{editingHost
? editingHost.id
? t("hosts.updateHost")
: t("hosts.cloneHost")
: t("hosts.addHost")}
</Button>
</footer>
</form>

View File

@@ -208,12 +208,12 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
};
const handleClone = (host: SSHHost) => {
if(onEditHost) {
const clonedHost = {...host};
if (onEditHost) {
const clonedHost = { ...host };
delete clonedHost.id;
onEditHost(clonedHost);
}
}
};
const handleRemoveFromFolder = async (host: SSHHost) => {
confirmWithToast(

View File

@@ -26,7 +26,14 @@ interface SSHTerminalProps {
}
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
{ hostConfig, isVisible, splitScreen = false, onClose, initialPath, executeCommand },
{
hostConfig,
isVisible,
splitScreen = false,
onClose,
initialPath,
executeCommand,
},
ref,
) {
const { t } = useTranslation();
@@ -458,8 +465,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
// Add macOS-specific keyboard event handling for special characters
const handleMacKeyboard = (e: KeyboardEvent) => {
// Detect macOS
const isMacOS = navigator.platform.toUpperCase().indexOf('MAC') >= 0 ||
navigator.userAgent.toUpperCase().indexOf('MAC') >= 0;
const isMacOS =
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
if (!isMacOS) return;
@@ -468,18 +476,18 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
// Use both e.key and e.code to handle different keyboard layouts
const keyMappings: { [key: string]: string } = {
// Using e.key values
'7': '|', // Option+7 = pipe symbol
'2': '€', // Option+2 = euro symbol
'8': '[', // Option+8 = left bracket
'9': ']', // Option+9 = right bracket
'l': '@', // Option+L = at symbol
'L': '@', // Option+L = at symbol (uppercase)
"7": "|", // Option+7 = pipe symbol
"2": "€", // Option+2 = euro symbol
"8": "[", // Option+8 = left bracket
"9": "]", // Option+9 = right bracket
l: "@", // Option+L = at symbol
L: "@", // Option+L = at symbol (uppercase)
// Using e.code values as fallback
'Digit7': '|', // Option+7 = pipe symbol
'Digit2': '€', // Option+2 = euro symbol
'Digit8': '[', // Option+8 = left bracket
'Digit9': ']', // Option+9 = right bracket
'KeyL': '@', // Option+L = at symbol
Digit7: "|", // Option+7 = pipe symbol
Digit2: "€", // Option+2 = euro symbol
Digit8: "[", // Option+8 = left bracket
Digit9: "]", // Option+9 = right bracket
KeyL: "@", // Option+L = at symbol
};
const char = keyMappings[e.key] || keyMappings[e.code];
@@ -489,7 +497,9 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
// Send the character directly to the terminal
if (webSocketRef.current?.readyState === 1) {
webSocketRef.current.send(JSON.stringify({ type: "input", data: char }));
webSocketRef.current.send(
JSON.stringify({ type: "input", data: char }),
);
}
return false;
}