v1.10.0 (#471)
* fix select edit host but not update view (#438) * fix: Checksum issue with chocolatey * fix: Remove homebrew old stuff * Add Korean translation (#439) Co-authored-by: 송준우 <2484@coreit.co.kr> * feat: Automate flatpak * fix: Add imagemagik to electron builder to resolve build error * fix: Build error with runtime repo flag * fix: Flatpak runtime error and install freedesktop ver warning * fix: Flatpak runtime error and install freedesktop ver warning * feat: Re-add homebrew cask and move scripts to backend * fix: No sandbox flag issue * fix: Change name for electron macos cask output * fix: Sandbox error with Linux * fix: Remove comming soon for app stores in readme * Adding Comment at the end of the public_key on the host on deploy (#440) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * -Add New Interface for Credential DB -Add Credential Name as a comment into the server authorized_key file --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Sudo auto fill password (#441) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Feature Sudo password auto-fill; * Fix locale json shema; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Added Italian Language; (#445) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Auto collapse snippet folders (#448) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * feat: Add collapsable snippets (customizable in user profile) * Translations (#447) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Added Italian Language; * Fix translations; Removed duplicate keys, synchronised other languages using English as the source, translated added keys, fixed inaccurate translations. --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * Remove PTY-level keepalive (#449) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Remove PTY-level keepalive to prevent unwanted terminal output; use SSH-level keepalive instead --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * feat: Seperate server stats and tunnel management (improved both UI's) then started initial docker implementation * fix: finalize adding docker to db * feat: Add docker management support (local squash) * Fix RBAC role system bugs and improve UX (#446) * Fix RBAC role system bugs and improve UX - Fix user list dropdown selection in host sharing - Fix role sharing permissions to include role-based access - Fix translation template interpolation for success messages - Standardize system roles to admin and user only - Auto-assign user role to new registrations - Remove blocking confirmation dialogs in modal contexts - Add missing i18n keys for common actions - Fix button type to prevent unintended form submissions * Enhance RBAC system with UI improvements and security fixes - Move role assignment to Users tab with per-user role management - Protect system roles (admin/user) from editing and manual assignment - Simplify permission system: remove Use level, keep View and Manage - Hide Update button and Sharing tab for view-only/shared hosts - Prevent users from sharing hosts with themselves - Unify table and modal styling across admin panels - Auto-assign system roles on user registration - Add permission metadata to host interface * Add empty state message for role assignment - Display helpful message when no custom roles available - Clarify that system roles are auto-assigned - Add noCustomRolesToAssign translation in English and Chinese * fix: Prevent credential sharing errors for shared hosts - Skip credential resolution for shared hosts with credential authentication to prevent decryption errors (credentials are encrypted per-user) - Add warning alert in sharing tab when host uses credential authentication - Inform users that shared users cannot connect to credential-based hosts - Add translations for credential sharing warning (EN/ZH) This prevents authentication failures when sharing hosts configured with credential authentication while maintaining security by keeping credentials isolated per user. * feat: Improve rbac UI and fixes some bugs --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * SOCKS5 support (#452) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * SOCKS5 support Adding single and chain socks5 proxy support * fix: cleanup files --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * Notes and Expiry fields add (#453) * Add termix.rb Cask file * Update Termix to version 1.9.0 with new checksum * Update README to remove 'coming soon' notes * Notes and Expiry add * fix: cleanup files --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * fix: ssh host types * fix: sudo incorrect styling and remove expiration date * feat: add sudo password and add diagonal bg's * fix: snippet running on enter key * fix: base64 decoding * fix: improve server stats / rbac * fix: wrap ssh host json export in hosts array * feat: auto trim host inputs, fix file manager jump hosts, dashboard prevent duplicates, file manager terminal not size updating, improve left sidebar sorting, hide/show tags, add apperance user profile tab, add new host manager tabs. * feat: improve terminal connection speed * fix: sqlite constriant errors and support non-root user (nginx perm issue) * feat: add beta syntax highlighing to terminal * feat: update imports and improve admin settings user management * chore: update translations * chore: update translations * feat: Complete light mode implementation with semantic theme system (#450) - Add comprehensive light/dark mode CSS variables with semantic naming - Implement theme-aware scrollbars using CSS variables - Add light mode backgrounds: --bg-base, --bg-elevated, --bg-surface, etc. - Add theme-aware borders: --border-base, --border-panel, --border-subtle - Add semantic text colors: --foreground-secondary, --foreground-subtle - Convert oklch colors to hex for better compatibility - Add theme awareness to CodeMirror editors - Update dark mode colors for consistency (background, sidebar, card, muted, input) - Add Tailwind color mappings for semantic classes Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: syntax errors * chore: updating/match themes and split admin settings * feat: add translation workflow and remove old translation.json * fix: translation workflow error * fix: translation workflow error * feat: improve translation system and update workflow * fix: wrong path for translations * fix: change translation to flat files * fix: gh rule error * chore: auto-translate to multiple languages (#458) * chore: improve organization and made a few styling changes in host manager * feat: improve terminal stability and split out the host manager * fix: add unnversiioned files * chore: migrate all to use the new theme system * fix: wrong animation line colors * fix: rbac implementation general issues (local squash) * fix: remove unneeded files * feat: add 10 new langs * chore: update gitnore * chore: auto-translate to multiple languages (#459) * fix: improve tunnel system * fix: properly split tabs, still need to fix up the host manager * chore: cleanup files (possible RC) * feat: add norwegian * chore: auto-translate to multiple languages (#461) * fix: small qol fixes and began readme update * fix: run cleanup script * feat: add docker docs button * feat: general bug fixes and readme updates * fix: translations * chore: auto-translate to multiple languages (#462) * fix: cleanup files * fix: test new translation issue and add better server-stats support * fix: fix translate error * chore: auto-translate to multiple languages (#463) * fix: fix translate mismatching text * chore: auto-translate to multiple languages (#465) * fix: fix translate mismatching text * fix: fix translate mismatching text * chore: auto-translate to multiple languages (#466) * fix: fix translate mismatching text * fix: fix translate mismatching text * fix: fix translate mismatching text * chore: auto-translate to multiple languages (#467) * fix: fix translate mismatching text * chore: auto-translate to multiple languages (#468) * feat: add to readme, a few qol changes, and improve server stats in general * chore: auto-translate to multiple languages (#469) * feat: turned disk uage into graph and fixed issue with termina console * fix: electron build error and hide icons when shared * chore: run clean * fix: general server stats issues, file manager decoding, ui qol * fix: add dashboard line breaks * fix: docker console error * fix: docker console not loading and mismatched stripped background for electron * fix: docker console not loading * chore: docker console not loading in docker * chore: translate readme to chinese * chore: match package lock to package json * chore: nginx config issue for dokcer console * chore: auto-translate to multiple languages (#470) --------- Co-authored-by: Tran Trung Kien <kientt13.7@gmail.com> Co-authored-by: junu <bigdwarf_@naver.com> Co-authored-by: 송준우 <2484@coreit.co.kr> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: Nunzio Marfè <nunzio.marfe@protonmail.com> Co-authored-by: Wesley Reid <starhound@lostsouls.org> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Denis <38875137+Medvedinca@users.noreply.github.com> Co-authored-by: Peet McKinney <68706879+PeetMcK@users.noreply.github.com>
This commit was merged in pull request #471.
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CompressDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
fileNames: string[];
|
||||
onCompress: (archiveName: string, format: string) => void;
|
||||
}
|
||||
|
||||
export function CompressDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileNames,
|
||||
onCompress,
|
||||
}: CompressDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [archiveName, setArchiveName] = useState("");
|
||||
const [format, setFormat] = useState("zip");
|
||||
|
||||
useEffect(() => {
|
||||
if (open && fileNames.length > 0) {
|
||||
if (fileNames.length === 1) {
|
||||
const baseName = fileNames[0].replace(/\.[^/.]+$/, "");
|
||||
setArchiveName(baseName);
|
||||
} else {
|
||||
setArchiveName("archive");
|
||||
}
|
||||
}
|
||||
}, [open, fileNames]);
|
||||
|
||||
const handleCompress = () => {
|
||||
if (!archiveName.trim()) return;
|
||||
|
||||
let finalName = archiveName.trim();
|
||||
const extensions: Record<string, string> = {
|
||||
zip: ".zip",
|
||||
"tar.gz": ".tar.gz",
|
||||
"tar.bz2": ".tar.bz2",
|
||||
"tar.xz": ".tar.xz",
|
||||
tar: ".tar",
|
||||
"7z": ".7z",
|
||||
};
|
||||
|
||||
const expectedExtension = extensions[format];
|
||||
if (expectedExtension && !finalName.endsWith(expectedExtension)) {
|
||||
finalName += expectedExtension;
|
||||
}
|
||||
|
||||
onCompress(finalName, format);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("fileManager.compressFiles")}</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("fileManager.compressFilesDesc", { count: fileNames.length })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-3">
|
||||
<Label
|
||||
className="text-base font-semibold text-foreground"
|
||||
htmlFor="archiveName"
|
||||
>
|
||||
{t("fileManager.archiveName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="archiveName"
|
||||
value={archiveName}
|
||||
onChange={(e) => setArchiveName(e.target.value)}
|
||||
placeholder={t("fileManager.enterArchiveName")}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleCompress();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label
|
||||
className="text-base font-semibold text-foreground"
|
||||
htmlFor="format"
|
||||
>
|
||||
{t("fileManager.compressionFormat")}
|
||||
</Label>
|
||||
<Select value={format} onValueChange={setFormat}>
|
||||
<SelectTrigger id="format">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zip">ZIP (.zip)</SelectItem>
|
||||
<SelectItem value="tar.gz">TAR.GZ (.tar.gz)</SelectItem>
|
||||
<SelectItem value="tar.bz2">TAR.BZ2 (.tar.bz2)</SelectItem>
|
||||
<SelectItem value="tar.xz">TAR.XZ (.tar.xz)</SelectItem>
|
||||
<SelectItem value="tar">TAR (.tar)</SelectItem>
|
||||
<SelectItem value="7z">7-Zip (.7z)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-hover/50 border border-edge p-3">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{t("fileManager.selectedFiles")}:
|
||||
</p>
|
||||
<ul className="text-sm space-y-1">
|
||||
{fileNames.slice(0, 5).map((name, index) => (
|
||||
<li key={index} className="truncate text-foreground">
|
||||
• {name}
|
||||
</li>
|
||||
))}
|
||||
{fileNames.length > 5 && (
|
||||
<li className="text-muted-foreground italic">
|
||||
{t("fileManager.andMoreFiles", {
|
||||
count: fileNames.length - 5,
|
||||
})}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleCompress} disabled={!archiveName.trim()}>
|
||||
{t("fileManager.compress")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { DiffEditor } from "@monaco-editor/react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Download,
|
||||
RefreshCw,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ArrowLeftRight,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
readSSHFile,
|
||||
downloadSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { FileItem, SSHHost } from "@/types";
|
||||
|
||||
interface DiffViewerProps {
|
||||
file1: FileItem;
|
||||
file2: FileItem;
|
||||
sshSessionId: string;
|
||||
sshHost: SSHHost;
|
||||
onDownload1?: () => void;
|
||||
onDownload2?: () => void;
|
||||
}
|
||||
|
||||
export function DiffViewer({
|
||||
file1,
|
||||
file2,
|
||||
sshSessionId,
|
||||
sshHost,
|
||||
}: DiffViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
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 [showLineNumbers, setShowLineNumbers] = useState(true);
|
||||
|
||||
const ensureSSHConnection = async () => {
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
if (!status.connected) {
|
||||
await connectSSH(sshSessionId, {
|
||||
hostId: sshHost.id,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port,
|
||||
username: sshHost.username,
|
||||
password: sshHost.password,
|
||||
sshKey: sshHost.key,
|
||||
keyPassword: sshHost.keyPassword,
|
||||
authType: sshHost.authType,
|
||||
credentialId: sshHost.credentialId,
|
||||
userId: sshHost.userId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
await connectSSH(sshSessionId, {
|
||||
hostId: sshHost.id,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port,
|
||||
username: sshHost.username,
|
||||
password: sshHost.password,
|
||||
sshKey: sshHost.key,
|
||||
keyPassword: sshHost.keyPassword,
|
||||
authType: sshHost.authType,
|
||||
credentialId: sshHost.credentialId,
|
||||
userId: sshHost.userId,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadFileContents = async () => {
|
||||
if (file1.type !== "file" || file2.type !== "file") {
|
||||
setError(t("fileManager.canOnlyCompareFiles"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await ensureSSHConnection();
|
||||
|
||||
const [response1, response2] = await Promise.all([
|
||||
readSSHFile(sshSessionId, file1.path),
|
||||
readSSHFile(sshSessionId, file2.path),
|
||||
]);
|
||||
|
||||
setContent1(response1.content || "");
|
||||
setContent2(response2.content || "");
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to load files for diff:", error);
|
||||
|
||||
const err = error as {
|
||||
message?: string;
|
||||
response?: { data?: { tooLarge?: boolean; error?: string } };
|
||||
};
|
||||
const errorData = err?.response?.data;
|
||||
if (errorData?.tooLarge) {
|
||||
setError(t("fileManager.fileTooLarge", { error: errorData.error }));
|
||||
} else if (
|
||||
err.message?.includes("connection") ||
|
||||
err.message?.includes("established")
|
||||
) {
|
||||
setError(
|
||||
t("fileManager.sshConnectionFailed", {
|
||||
name: sshHost.name,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
t("fileManager.loadFileFailed", {
|
||||
error:
|
||||
err.message || errorData?.error || t("fileManager.unknownError"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadFile = async (file: FileItem) => {
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
|
||||
if (response?.content) {
|
||||
const byteCharacters = atob(response.content);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], {
|
||||
type: response.mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = response.fileName || file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(
|
||||
t("fileManager.downloadFileSuccess", { name: file.name }),
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to download file:", error);
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
t("fileManager.downloadFileFailed") +
|
||||
": " +
|
||||
(err.message || t("fileManager.unknownError")),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileLanguage = (fileName: string): string => {
|
||||
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",
|
||||
};
|
||||
return languageMap[ext || ""] || "plaintext";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadFileContents();
|
||||
}, [file1, file2, sshSessionId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-canvas">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("fileManager.loadingFileComparison")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-canvas">
|
||||
<div className="text-center max-w-md">
|
||||
<FileText className="w-16 h-16 mx-auto mb-4 text-red-500 opacity-50" />
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={loadFileContents} variant="outline">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t("fileManager.reload")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-canvas">
|
||||
<div className="flex-shrink-0 border-b border-edge p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t("fileManager.compare")}:
|
||||
</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>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
setDiffMode(
|
||||
diffMode === "side-by-side" ? "inline" : "side-by-side",
|
||||
)
|
||||
}
|
||||
>
|
||||
{diffMode === "side-by-side"
|
||||
? t("fileManager.sideBySide")
|
||||
: t("fileManager.inline")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowLineNumbers(!showLineNumbers)}
|
||||
>
|
||||
{showLineNumbers ? (
|
||||
<Eye className="w-4 h-4" />
|
||||
) : (
|
||||
<EyeOff className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadFile(file1)}
|
||||
title={t("fileManager.downloadFile", { name: file1.name })}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
{file1.name}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDownloadFile(file2)}
|
||||
title={t("fileManager.downloadFile", { name: file2.name })}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
{file2.name}
|
||||
</Button>
|
||||
|
||||
<Button variant="outline" size="sm" onClick={loadFileContents}>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<DiffEditor
|
||||
original={content1}
|
||||
modified={content2}
|
||||
language={getFileLanguage(file1.name)}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
renderSideBySide: diffMode === "side-by-side",
|
||||
lineNumbers: showLineNumbers ? "on" : "off",
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
wordWrap: "off",
|
||||
automaticLayout: true,
|
||||
readOnly: true,
|
||||
originalEditable: false,
|
||||
scrollbar: {
|
||||
vertical: "visible",
|
||||
horizontal: "visible",
|
||||
},
|
||||
diffWordWrap: "off",
|
||||
ignoreTrimWhitespace: false,
|
||||
}}
|
||||
loading={
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500 mx-auto mb-2"></div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("fileManager.initializingEditor")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow.tsx";
|
||||
import { DiffViewer } from "./DiffViewer.tsx";
|
||||
import { useWindowManager } from "./WindowManager.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem, SSHHost } from "../../../../types/index.js";
|
||||
|
||||
interface DiffWindowProps {
|
||||
windowId: string;
|
||||
file1: FileItem;
|
||||
file2: FileItem;
|
||||
sshSessionId: string;
|
||||
sshHost: SSHHost;
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
}
|
||||
|
||||
export function DiffWindow({
|
||||
windowId,
|
||||
file1,
|
||||
file2,
|
||||
sshSessionId,
|
||||
sshHost,
|
||||
initialX = 150,
|
||||
initialY = 100,
|
||||
}: DiffWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { closeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
|
||||
const handleClose = () => {
|
||||
closeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
maximizeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
focusWindow(windowId);
|
||||
};
|
||||
|
||||
if (!currentWindow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DraggableWindow
|
||||
title={t("fileManager.fileComparison", {
|
||||
file1: file1.name,
|
||||
file2: file2.name,
|
||||
})}
|
||||
initialX={initialX}
|
||||
initialY={initialY}
|
||||
initialWidth={1200}
|
||||
initialHeight={700}
|
||||
minWidth={800}
|
||||
minHeight={500}
|
||||
onClose={handleClose}
|
||||
onMaximize={handleMaximize}
|
||||
onFocus={handleFocus}
|
||||
isMaximized={currentWindow.isMaximized}
|
||||
zIndex={currentWindow.zIndex}
|
||||
>
|
||||
<DiffViewer
|
||||
file1={file1}
|
||||
file2={file2}
|
||||
sshSessionId={sshSessionId}
|
||||
sshHost={sshHost}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { Minus, X, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface DraggableWindowProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
onClose: () => void;
|
||||
onMinimize?: () => void;
|
||||
onMaximize?: () => void;
|
||||
onResize?: () => void;
|
||||
isMaximized?: boolean;
|
||||
zIndex?: number;
|
||||
onFocus?: () => void;
|
||||
targetSize?: { width: number; height: number };
|
||||
}
|
||||
|
||||
export function DraggableWindow({
|
||||
title,
|
||||
children,
|
||||
initialX = 100,
|
||||
initialY = 100,
|
||||
initialWidth = 600,
|
||||
initialHeight = 400,
|
||||
minWidth = 300,
|
||||
minHeight = 200,
|
||||
onClose,
|
||||
onMinimize,
|
||||
onMaximize,
|
||||
onResize,
|
||||
isMaximized = false,
|
||||
zIndex = 1000,
|
||||
onFocus,
|
||||
targetSize,
|
||||
}: DraggableWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const [position, setPosition] = useState({ x: initialX, y: initialY });
|
||||
const [size, setSize] = useState({
|
||||
width: initialWidth,
|
||||
height: initialHeight,
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [resizeDirection, setResizeDirection] = useState<string>("");
|
||||
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const [windowStart, setWindowStart] = useState({ x: 0, y: 0 });
|
||||
const [sizeStart, setSizeStart] = useState({ width: 0, height: 0 });
|
||||
|
||||
const windowRef = useRef<HTMLDivElement>(null);
|
||||
const titleBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetSize && !isMaximized) {
|
||||
const maxWidth = Math.min(window.innerWidth * 0.9, 1200);
|
||||
const maxHeight = Math.min(window.innerHeight * 0.8, 800);
|
||||
|
||||
let newWidth = Math.min(targetSize.width + 50, maxWidth);
|
||||
let newHeight = Math.min(targetSize.height + 150, maxHeight);
|
||||
|
||||
if (newWidth > maxWidth || newHeight > maxHeight) {
|
||||
const widthRatio = maxWidth / newWidth;
|
||||
const heightRatio = maxHeight / newHeight;
|
||||
const scale = Math.min(widthRatio, heightRatio);
|
||||
|
||||
newWidth = Math.floor(newWidth * scale);
|
||||
newHeight = Math.floor(newHeight * scale);
|
||||
}
|
||||
|
||||
newWidth = Math.max(newWidth, minWidth);
|
||||
newHeight = Math.max(newHeight, minHeight);
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
|
||||
setPosition({
|
||||
x: Math.max(0, (window.innerWidth - newWidth) / 2),
|
||||
y: Math.max(0, (window.innerHeight - newHeight) / 2),
|
||||
});
|
||||
}
|
||||
}, [targetSize, isMaximized, minWidth, minHeight]);
|
||||
|
||||
const handleWindowClick = useCallback(() => {
|
||||
onFocus?.();
|
||||
}, [onFocus]);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isDragging && !isMaximized) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
|
||||
const newX = windowStart.x + deltaX;
|
||||
const newY = windowStart.y + deltaY;
|
||||
|
||||
const windowElement = windowRef.current;
|
||||
let positioningContainer = null;
|
||||
let currentElement = windowElement?.parentElement;
|
||||
|
||||
while (currentElement && currentElement !== document.body) {
|
||||
const computedStyle = window.getComputedStyle(currentElement);
|
||||
const position = computedStyle.position;
|
||||
const transform = computedStyle.transform;
|
||||
|
||||
if (
|
||||
position === "relative" ||
|
||||
position === "absolute" ||
|
||||
position === "fixed" ||
|
||||
transform !== "none"
|
||||
) {
|
||||
positioningContainer = currentElement;
|
||||
break;
|
||||
}
|
||||
|
||||
currentElement = currentElement.parentElement;
|
||||
}
|
||||
|
||||
let maxX, maxY, minX, minY;
|
||||
|
||||
if (positioningContainer) {
|
||||
const containerRect = positioningContainer.getBoundingClientRect();
|
||||
|
||||
maxX = containerRect.width - size.width;
|
||||
maxY = containerRect.height - size.height;
|
||||
minX = 0;
|
||||
minY = 0;
|
||||
} else {
|
||||
maxX = window.innerWidth - size.width;
|
||||
maxY = window.innerHeight - size.height;
|
||||
minX = 0;
|
||||
minY = 0;
|
||||
}
|
||||
|
||||
const constrainedX = Math.max(minX, Math.min(maxX, newX));
|
||||
const constrainedY = Math.max(minY, Math.min(maxY, newY));
|
||||
|
||||
setPosition({
|
||||
x: constrainedX,
|
||||
y: constrainedY,
|
||||
});
|
||||
}
|
||||
|
||||
if (isResizing && !isMaximized) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
|
||||
let newWidth = sizeStart.width;
|
||||
let newHeight = sizeStart.height;
|
||||
let newX = windowStart.x;
|
||||
let newY = windowStart.y;
|
||||
|
||||
if (resizeDirection.includes("right")) {
|
||||
newWidth = Math.max(minWidth, sizeStart.width + deltaX);
|
||||
}
|
||||
if (resizeDirection.includes("left")) {
|
||||
const widthChange = -deltaX;
|
||||
newWidth = Math.max(minWidth, sizeStart.width + widthChange);
|
||||
if (newWidth > minWidth || widthChange > 0) {
|
||||
newX = windowStart.x - (newWidth - sizeStart.width);
|
||||
} else {
|
||||
newX = windowStart.x - (minWidth - sizeStart.width);
|
||||
}
|
||||
}
|
||||
|
||||
if (resizeDirection.includes("bottom")) {
|
||||
newHeight = Math.max(minHeight, sizeStart.height + deltaY);
|
||||
}
|
||||
if (resizeDirection.includes("top")) {
|
||||
const heightChange = -deltaY;
|
||||
newHeight = Math.max(minHeight, sizeStart.height + heightChange);
|
||||
if (newHeight > minHeight || heightChange > 0) {
|
||||
newY = windowStart.y - (newHeight - sizeStart.height);
|
||||
} else {
|
||||
newY = windowStart.y - (minHeight - sizeStart.height);
|
||||
}
|
||||
}
|
||||
|
||||
newX = Math.max(0, Math.min(window.innerWidth - newWidth, newX));
|
||||
newY = Math.max(0, Math.min(window.innerHeight - newHeight, newY));
|
||||
|
||||
setSize({ width: newWidth, height: newHeight });
|
||||
setPosition({ x: newX, y: newY });
|
||||
|
||||
if (onResize) {
|
||||
onResize();
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
isDragging,
|
||||
isResizing,
|
||||
isMaximized,
|
||||
dragStart,
|
||||
windowStart,
|
||||
sizeStart,
|
||||
size,
|
||||
position,
|
||||
minWidth,
|
||||
minHeight,
|
||||
resizeDirection,
|
||||
onResize,
|
||||
],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
setIsResizing(false);
|
||||
setResizeDirection("");
|
||||
}, []);
|
||||
|
||||
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: position.x, y: position.y });
|
||||
setSizeStart({ width: size.width, height: size.height });
|
||||
onFocus?.();
|
||||
},
|
||||
[isMaximized, position, 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";
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.cursor = "";
|
||||
};
|
||||
}
|
||||
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
||||
|
||||
const handleTitleDoubleClick = useCallback(() => {
|
||||
onMaximize?.();
|
||||
}, [onMaximize]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={windowRef}
|
||||
className={cn(
|
||||
"absolute bg-card border border-border rounded-lg shadow-2xl",
|
||||
"select-none overflow-hidden",
|
||||
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,
|
||||
}}
|
||||
onClick={handleWindowClick}
|
||||
>
|
||||
<div
|
||||
ref={titleBarRef}
|
||||
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",
|
||||
)}
|
||||
onMouseDown={handleMouseDown}
|
||||
onDoubleClick={handleTitleDoubleClick}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<span className="text-sm font-medium truncate">{title}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{onMinimize && (
|
||||
<button
|
||||
className="w-8 h-6 flex items-center justify-center rounded hover:bg-accent transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMinimize();
|
||||
}}
|
||||
title={t("common.minimize")}
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onMaximize && (
|
||||
<button
|
||||
className="w-8 h-6 flex items-center justify-center rounded hover:bg-accent transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMaximize();
|
||||
}}
|
||||
title={isMaximized ? t("common.restore") : t("common.maximize")}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
) : (
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="w-8 h-6 flex items-center justify-center rounded hover:bg-destructive hover:text-destructive-foreground transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="flex-1 overflow-hidden"
|
||||
style={{ height: "calc(100% - 40px)" }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{!isMaximized && (
|
||||
<>
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-1 cursor-n-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "top")}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-1 cursor-s-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "bottom")}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 w-1 cursor-w-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "left")}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 bottom-0 right-0 w-1 cursor-e-resize"
|
||||
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")}
|
||||
/>
|
||||
<div
|
||||
className="absolute top-0 right-0 w-2 h-2 cursor-ne-resize"
|
||||
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")}
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 right-0 w-2 h-2 cursor-se-resize"
|
||||
onMouseDown={(e) => handleResizeStart(e, "bottom-right")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1440
src/ui/desktop/apps/features/file-manager/components/FileViewer.tsx
Normal file
1440
src/ui/desktop/apps/features/file-manager/components/FileViewer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,424 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow.tsx";
|
||||
import { FileViewer } from "./FileViewer.tsx";
|
||||
import { useWindowManager } from "./WindowManager.tsx";
|
||||
import {
|
||||
downloadSSHFile,
|
||||
readSSHFile,
|
||||
writeSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
size?: number;
|
||||
modified?: string;
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
authType: "password" | "key";
|
||||
credentialId?: number;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
interface FileWindowProps {
|
||||
windowId: string;
|
||||
file: FileItem;
|
||||
sshSessionId: string;
|
||||
sshHost: SSHHost;
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
onFileNotFound?: (file: FileItem) => void;
|
||||
}
|
||||
|
||||
export function FileWindow({
|
||||
windowId,
|
||||
file,
|
||||
sshSessionId,
|
||||
sshHost,
|
||||
initialX = 100,
|
||||
initialY = 100,
|
||||
onFileNotFound,
|
||||
}: FileWindowProps) {
|
||||
const { closeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [content, setContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [pendingContent, setPendingContent] = useState<string>("");
|
||||
const [mediaDimensions, setMediaDimensions] = useState<
|
||||
{ width: number; height: number } | undefined
|
||||
>();
|
||||
const autoSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
|
||||
const ensureSSHConnection = async () => {
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
|
||||
if (!status.connected) {
|
||||
await connectSSH(sshSessionId, {
|
||||
hostId: sshHost.id,
|
||||
ip: sshHost.ip,
|
||||
port: sshHost.port,
|
||||
username: sshHost.username,
|
||||
password: sshHost.password,
|
||||
sshKey: sshHost.key,
|
||||
keyPassword: sshHost.keyPassword,
|
||||
authType: sshHost.authType,
|
||||
credentialId: sshHost.credentialId,
|
||||
userId: sshHost.userId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("SSH connection check/reconnect failed:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const loadFileContent = async () => {
|
||||
if (file.type !== "file") return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await readSSHFile(sshSessionId, file.path);
|
||||
const fileContent = response.content || "";
|
||||
setContent(fileContent);
|
||||
setPendingContent(fileContent);
|
||||
|
||||
if (!file.size) {
|
||||
const contentSize = new Blob([fileContent]).size;
|
||||
file.size = contentSize;
|
||||
}
|
||||
|
||||
const mediaExtensions = [
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"gif",
|
||||
"bmp",
|
||||
"svg",
|
||||
"webp",
|
||||
"tiff",
|
||||
"ico",
|
||||
"mp3",
|
||||
"wav",
|
||||
"ogg",
|
||||
"aac",
|
||||
"flac",
|
||||
"m4a",
|
||||
"wma",
|
||||
"mp4",
|
||||
"avi",
|
||||
"mov",
|
||||
"wmv",
|
||||
"flv",
|
||||
"mkv",
|
||||
"webm",
|
||||
"m4v",
|
||||
"zip",
|
||||
"rar",
|
||||
"7z",
|
||||
"tar",
|
||||
"gz",
|
||||
"bz2",
|
||||
"xz",
|
||||
"exe",
|
||||
"dll",
|
||||
"so",
|
||||
"dylib",
|
||||
"bin",
|
||||
"iso",
|
||||
];
|
||||
|
||||
const extension = file.name.split(".").pop()?.toLowerCase();
|
||||
setIsEditable(!mediaExtensions.includes(extension || ""));
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to load file:", error);
|
||||
|
||||
const err = error as {
|
||||
message?: string;
|
||||
isFileNotFound?: boolean;
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
tooLarge?: boolean;
|
||||
error?: string;
|
||||
fileNotFound?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
const errorData = err?.response?.data;
|
||||
if (errorData?.tooLarge) {
|
||||
toast.error(`File too large: ${errorData.error}`, {
|
||||
duration: 10000,
|
||||
});
|
||||
} else if (
|
||||
err.message?.includes("connection") ||
|
||||
err.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
const errorMessage =
|
||||
errorData?.error || err.message || "Unknown error";
|
||||
const isFileNotFound =
|
||||
err.isFileNotFound ||
|
||||
errorData?.fileNotFound ||
|
||||
err.response?.status === 404 ||
|
||||
errorMessage.includes("File not found") ||
|
||||
errorMessage.includes("No such file or directory") ||
|
||||
errorMessage.includes("cannot access") ||
|
||||
errorMessage.includes("not found") ||
|
||||
errorMessage.includes("Resource not found");
|
||||
|
||||
if (isFileNotFound && onFileNotFound) {
|
||||
onFileNotFound(file);
|
||||
toast.error(
|
||||
t("fileManager.fileNotFoundAndRemoved", { name: file.name }),
|
||||
);
|
||||
|
||||
closeWindow(windowId);
|
||||
return;
|
||||
} else {
|
||||
toast.error(
|
||||
t("fileManager.failedToLoadFile", {
|
||||
error: errorMessage.includes("Server error occurred")
|
||||
? t("fileManager.serverErrorOccurred")
|
||||
: errorMessage,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFileContent();
|
||||
}, [file, sshSessionId, sshHost]);
|
||||
|
||||
const handleRevert = async () => {
|
||||
const loadFileContent = async () => {
|
||||
if (file.type !== "file") return;
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await readSSHFile(sshSessionId, file.path);
|
||||
const fileContent = response.content || "";
|
||||
setContent(fileContent);
|
||||
setPendingContent("");
|
||||
|
||||
if (!file.size) {
|
||||
const contentSize = new Blob([fileContent]).size;
|
||||
file.size = contentSize;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to load file content:", error);
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.failedToLoadFile")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadFileContent();
|
||||
};
|
||||
|
||||
const handleSave = async (newContent: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
await ensureSSHConnection();
|
||||
|
||||
await writeSSHFile(sshSessionId, file.path, newContent);
|
||||
setContent(newContent);
|
||||
setPendingContent("");
|
||||
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
autoSaveTimerRef.current = null;
|
||||
}
|
||||
|
||||
toast.success(t("fileManager.fileSavedSuccessfully"));
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to save file:", error);
|
||||
|
||||
const err = error as { message?: string };
|
||||
if (
|
||||
err.message?.includes("connection") ||
|
||||
err.message?.includes("established")
|
||||
) {
|
||||
toast.error(
|
||||
`SSH connection failed. Please check your connection to ${sshHost.name} (${sshHost.ip}:${sshHost.port})`,
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
`${t("fileManager.failedToSaveFile")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleContentChange = (newContent: string) => {
|
||||
setPendingContent(newContent);
|
||||
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
autoSaveTimerRef.current = null;
|
||||
}
|
||||
|
||||
if (newContent !== content) {
|
||||
autoSaveTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
await handleSave(newContent);
|
||||
toast.success(t("fileManager.fileAutoSaved"));
|
||||
} catch (error) {
|
||||
console.error("Auto-save failed:", error);
|
||||
toast.error(t("fileManager.autoSaveFailed"));
|
||||
}
|
||||
}, 60000);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimerRef.current) {
|
||||
clearTimeout(autoSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const response = await downloadSSHFile(sshSessionId, file.path);
|
||||
|
||||
if (response?.content) {
|
||||
const byteCharacters = atob(response.content);
|
||||
const byteNumbers = new Array(byteCharacters.length);
|
||||
for (let i = 0; i < byteCharacters.length; i++) {
|
||||
byteNumbers[i] = byteCharacters.charCodeAt(i);
|
||||
}
|
||||
const byteArray = new Uint8Array(byteNumbers);
|
||||
const blob = new Blob([byteArray], {
|
||||
type: response.mimeType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = response.fileName || file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
toast.success(t("fileManager.fileDownloadedSuccessfully"));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to download file:", error);
|
||||
|
||||
const err = error as { message?: string };
|
||||
if (
|
||||
err.message?.includes("connection") ||
|
||||
err.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: ${err.message || "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
closeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
maximizeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
focusWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMediaDimensionsChange = (dimensions: {
|
||||
width: number;
|
||||
height: number;
|
||||
}) => {
|
||||
setMediaDimensions(dimensions);
|
||||
};
|
||||
|
||||
if (!currentWindow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DraggableWindow
|
||||
title={file.name}
|
||||
initialX={initialX}
|
||||
initialY={initialY}
|
||||
initialWidth={800}
|
||||
initialHeight={600}
|
||||
minWidth={400}
|
||||
minHeight={300}
|
||||
onClose={handleClose}
|
||||
onMaximize={handleMaximize}
|
||||
onFocus={handleFocus}
|
||||
isMaximized={currentWindow.isMaximized}
|
||||
zIndex={currentWindow.zIndex}
|
||||
targetSize={mediaDimensions}
|
||||
>
|
||||
<FileViewer
|
||||
file={file}
|
||||
content={pendingContent || content}
|
||||
savedContent={content}
|
||||
isLoading={isLoading}
|
||||
onRevert={handleRevert}
|
||||
isEditable={isEditable}
|
||||
onContentChange={handleContentChange}
|
||||
onSave={(newContent) => handleSave(newContent)}
|
||||
onDownload={handleDownload}
|
||||
onMediaDimensionsChange={handleMediaDimensionsChange}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Label } from "@/components/ui/label.tsx";
|
||||
import { Checkbox } from "@/components/ui/checkbox.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Shield } from "lucide-react";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
permissions?: string;
|
||||
owner?: string;
|
||||
group?: string;
|
||||
}
|
||||
|
||||
interface PermissionsDialogProps {
|
||||
file: FileItem | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (file: FileItem, permissions: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const parsePermissions = (
|
||||
perms: string,
|
||||
): { owner: number; group: number; other: number } => {
|
||||
if (!perms) {
|
||||
return { owner: 0, group: 0, other: 0 };
|
||||
}
|
||||
|
||||
if (/^\d{3,4}$/.test(perms)) {
|
||||
const numStr = perms.slice(-3);
|
||||
return {
|
||||
owner: parseInt(numStr[0] || "0", 10),
|
||||
group: parseInt(numStr[1] || "0", 10),
|
||||
other: parseInt(numStr[2] || "0", 10),
|
||||
};
|
||||
}
|
||||
const cleanPerms = perms.replace(/^-/, "").substring(0, 9);
|
||||
|
||||
const calcBits = (str: string): number => {
|
||||
let value = 0;
|
||||
if (str[0] === "r") value += 4;
|
||||
if (str[1] === "w") value += 2;
|
||||
if (str[2] === "x") value += 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
return {
|
||||
owner: calcBits(cleanPerms.substring(0, 3)),
|
||||
group: calcBits(cleanPerms.substring(3, 6)),
|
||||
other: calcBits(cleanPerms.substring(6, 9)),
|
||||
};
|
||||
};
|
||||
|
||||
const toNumeric = (owner: number, group: number, other: number): string => {
|
||||
return `${owner}${group}${other}`;
|
||||
};
|
||||
|
||||
export function PermissionsDialog({
|
||||
file,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: PermissionsDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const initialPerms = parsePermissions(file?.permissions || "644");
|
||||
const [ownerRead, setOwnerRead] = useState((initialPerms.owner & 4) !== 0);
|
||||
const [ownerWrite, setOwnerWrite] = useState((initialPerms.owner & 2) !== 0);
|
||||
const [ownerExecute, setOwnerExecute] = useState(
|
||||
(initialPerms.owner & 1) !== 0,
|
||||
);
|
||||
|
||||
const [groupRead, setGroupRead] = useState((initialPerms.group & 4) !== 0);
|
||||
const [groupWrite, setGroupWrite] = useState((initialPerms.group & 2) !== 0);
|
||||
const [groupExecute, setGroupExecute] = useState(
|
||||
(initialPerms.group & 1) !== 0,
|
||||
);
|
||||
|
||||
const [otherRead, setOtherRead] = useState((initialPerms.other & 4) !== 0);
|
||||
const [otherWrite, setOtherWrite] = useState((initialPerms.other & 2) !== 0);
|
||||
const [otherExecute, setOtherExecute] = useState(
|
||||
(initialPerms.other & 1) !== 0,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (file) {
|
||||
const perms = parsePermissions(file.permissions || "644");
|
||||
setOwnerRead((perms.owner & 4) !== 0);
|
||||
setOwnerWrite((perms.owner & 2) !== 0);
|
||||
setOwnerExecute((perms.owner & 1) !== 0);
|
||||
setGroupRead((perms.group & 4) !== 0);
|
||||
setGroupWrite((perms.group & 2) !== 0);
|
||||
setGroupExecute((perms.group & 1) !== 0);
|
||||
setOtherRead((perms.other & 4) !== 0);
|
||||
setOtherWrite((perms.other & 2) !== 0);
|
||||
setOtherExecute((perms.other & 1) !== 0);
|
||||
}
|
||||
}, [file]);
|
||||
|
||||
const calculateOctal = (): string => {
|
||||
const owner =
|
||||
(ownerRead ? 4 : 0) + (ownerWrite ? 2 : 0) + (ownerExecute ? 1 : 0);
|
||||
const group =
|
||||
(groupRead ? 4 : 0) + (groupWrite ? 2 : 0) + (groupExecute ? 1 : 0);
|
||||
const other =
|
||||
(otherRead ? 4 : 0) + (otherWrite ? 2 : 0) + (otherExecute ? 1 : 0);
|
||||
return toNumeric(owner, group, other);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!file) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const permissions = calculateOctal();
|
||||
await onSave(file, permissions);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to update permissions:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!file) return null;
|
||||
|
||||
const octal = calculateOctal();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-canvas border-2 border-edge">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
{t("fileManager.changePermissions")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("fileManager.changePermissionsDesc")}:{" "}
|
||||
<span className="font-mono text-foreground">{file.name}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<Label className="text-muted-foreground">
|
||||
{t("fileManager.currentPermissions")}
|
||||
</Label>
|
||||
<p className="font-mono text-lg mt-1">
|
||||
{file.permissions || "644"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-muted-foreground">
|
||||
{t("fileManager.newPermissions")}
|
||||
</Label>
|
||||
<p className="font-mono text-lg mt-1">{octal}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("fileManager.owner")} {file.owner && `(${file.owner})`}
|
||||
</Label>
|
||||
<div className="flex gap-6 ml-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="owner-read"
|
||||
checked={ownerRead}
|
||||
onCheckedChange={(checked) => setOwnerRead(checked === true)}
|
||||
/>
|
||||
<label htmlFor="owner-read" className="text-sm cursor-pointer">
|
||||
{t("fileManager.read")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="owner-write"
|
||||
checked={ownerWrite}
|
||||
onCheckedChange={(checked) => setOwnerWrite(checked === true)}
|
||||
/>
|
||||
<label htmlFor="owner-write" className="text-sm cursor-pointer">
|
||||
{t("fileManager.write")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="owner-execute"
|
||||
checked={ownerExecute}
|
||||
onCheckedChange={(checked) =>
|
||||
setOwnerExecute(checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="owner-execute"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{t("fileManager.execute")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("fileManager.group")} {file.group && `(${file.group})`}
|
||||
</Label>
|
||||
<div className="flex gap-6 ml-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="group-read"
|
||||
checked={groupRead}
|
||||
onCheckedChange={(checked) => setGroupRead(checked === true)}
|
||||
/>
|
||||
<label htmlFor="group-read" className="text-sm cursor-pointer">
|
||||
{t("fileManager.read")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="group-write"
|
||||
checked={groupWrite}
|
||||
onCheckedChange={(checked) => setGroupWrite(checked === true)}
|
||||
/>
|
||||
<label htmlFor="group-write" className="text-sm cursor-pointer">
|
||||
{t("fileManager.write")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="group-execute"
|
||||
checked={groupExecute}
|
||||
onCheckedChange={(checked) =>
|
||||
setGroupExecute(checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="group-execute"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{t("fileManager.execute")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("fileManager.others")}
|
||||
</Label>
|
||||
<div className="flex gap-6 ml-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="other-read"
|
||||
checked={otherRead}
|
||||
onCheckedChange={(checked) => setOtherRead(checked === true)}
|
||||
/>
|
||||
<label htmlFor="other-read" className="text-sm cursor-pointer">
|
||||
{t("fileManager.read")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="other-write"
|
||||
checked={otherWrite}
|
||||
onCheckedChange={(checked) => setOtherWrite(checked === true)}
|
||||
/>
|
||||
<label htmlFor="other-write" className="text-sm cursor-pointer">
|
||||
{t("fileManager.write")}
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="other-execute"
|
||||
checked={otherExecute}
|
||||
onCheckedChange={(checked) =>
|
||||
setOtherExecute(checked === true)
|
||||
}
|
||||
/>
|
||||
<label
|
||||
htmlFor="other-execute"
|
||||
className="text-sm cursor-pointer"
|
||||
>
|
||||
{t("fileManager.execute")}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={loading}>
|
||||
{loading ? t("common.saving") : t("common.save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from "react";
|
||||
import { DraggableWindow } from "./DraggableWindow.tsx";
|
||||
import { Terminal } from "@/ui/desktop/apps/features/terminal/Terminal.tsx";
|
||||
import { useWindowManager } from "./WindowManager.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
authType: "password" | "key";
|
||||
credentialId?: number;
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
interface TerminalWindowProps {
|
||||
windowId: string;
|
||||
hostConfig: SSHHost;
|
||||
initialPath?: string;
|
||||
initialX?: number;
|
||||
initialY?: number;
|
||||
executeCommand?: string;
|
||||
}
|
||||
|
||||
export function TerminalWindow({
|
||||
windowId,
|
||||
hostConfig,
|
||||
initialPath,
|
||||
initialX = 200,
|
||||
initialY = 150,
|
||||
executeCommand,
|
||||
}: TerminalWindowProps) {
|
||||
const { t } = useTranslation();
|
||||
const { closeWindow, maximizeWindow, focusWindow, windows } =
|
||||
useWindowManager();
|
||||
const terminalRef = React.useRef<{ fit?: () => void } | null>(null);
|
||||
const resizeTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const currentWindow = windows.find((w) => w.id === windowId);
|
||||
if (!currentWindow) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
closeWindow(windowId);
|
||||
};
|
||||
|
||||
const handleMaximize = () => {
|
||||
maximizeWindow(windowId);
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (terminalRef.current?.fit) {
|
||||
terminalRef.current.fit();
|
||||
}
|
||||
}, 150);
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
focusWindow(windowId);
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
if (resizeTimeoutRef.current) {
|
||||
clearTimeout(resizeTimeoutRef.current);
|
||||
}
|
||||
|
||||
resizeTimeoutRef.current = setTimeout(() => {
|
||||
if (terminalRef.current?.fit) {
|
||||
terminalRef.current.fit();
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const terminalTitle = executeCommand
|
||||
? t("terminal.runTitle", { host: hostConfig.name, command: executeCommand })
|
||||
: initialPath
|
||||
? t("terminal.terminalWithPath", {
|
||||
host: hostConfig.name,
|
||||
path: initialPath,
|
||||
})
|
||||
: t("terminal.terminalTitle", { host: hostConfig.name });
|
||||
|
||||
return (
|
||||
<DraggableWindow
|
||||
title={terminalTitle}
|
||||
initialX={initialX}
|
||||
initialY={initialY}
|
||||
initialWidth={800}
|
||||
initialHeight={500}
|
||||
minWidth={600}
|
||||
minHeight={400}
|
||||
onClose={handleClose}
|
||||
onMaximize={handleMaximize}
|
||||
onFocus={handleFocus}
|
||||
onResize={handleResize}
|
||||
isMaximized={currentWindow.isMaximized}
|
||||
zIndex={currentWindow.zIndex}
|
||||
>
|
||||
<Terminal
|
||||
ref={terminalRef}
|
||||
hostConfig={hostConfig}
|
||||
isVisible={!currentWindow.isMinimized}
|
||||
initialPath={initialPath}
|
||||
executeCommand={executeCommand}
|
||||
onClose={handleClose}
|
||||
/>
|
||||
</DraggableWindow>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import React, { useState, useCallback, useRef } from "react";
|
||||
|
||||
export interface WindowInstance {
|
||||
id: string;
|
||||
title: string;
|
||||
component: React.ReactNode | ((windowId: string) => React.ReactNode);
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isMaximized: boolean;
|
||||
isMinimized: boolean;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
interface WindowManagerProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
interface WindowManagerContextType {
|
||||
windows: WindowInstance[];
|
||||
openWindow: (window: Omit<WindowInstance, "id" | "zIndex">) => string;
|
||||
closeWindow: (id: string) => void;
|
||||
minimizeWindow: (id: string) => void;
|
||||
maximizeWindow: (id: string) => void;
|
||||
focusWindow: (id: string) => void;
|
||||
updateWindow: (id: string, updates: Partial<WindowInstance>) => void;
|
||||
}
|
||||
|
||||
const WindowManagerContext =
|
||||
React.createContext<WindowManagerContextType | null>(null);
|
||||
|
||||
export function WindowManager({ children }: WindowManagerProps) {
|
||||
const [windows, setWindows] = useState<WindowInstance[]>([]);
|
||||
const nextZIndex = useRef(1000);
|
||||
const windowCounter = useRef(0);
|
||||
|
||||
const openWindow = useCallback(
|
||||
(windowData: Omit<WindowInstance, "id" | "zIndex">) => {
|
||||
const id = `window-${++windowCounter.current}`;
|
||||
const zIndex = ++nextZIndex.current;
|
||||
|
||||
const offset = (windows.length % 5) * 20;
|
||||
let adjustedX = windowData.x + offset;
|
||||
let adjustedY = windowData.y + offset;
|
||||
|
||||
const maxX = Math.max(0, window.innerWidth - windowData.width - 20);
|
||||
const maxY = Math.max(0, window.innerHeight - windowData.height - 20);
|
||||
|
||||
adjustedX = Math.max(20, Math.min(adjustedX, maxX));
|
||||
adjustedY = Math.max(20, Math.min(adjustedY, maxY));
|
||||
|
||||
const newWindow: WindowInstance = {
|
||||
...windowData,
|
||||
id,
|
||||
zIndex,
|
||||
x: adjustedX,
|
||||
y: adjustedY,
|
||||
};
|
||||
|
||||
setWindows((prev) => [...prev, newWindow]);
|
||||
return id;
|
||||
},
|
||||
[windows.length],
|
||||
);
|
||||
|
||||
const closeWindow = useCallback((id: string) => {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const maximizeWindow = useCallback((id: string) => {
|
||||
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);
|
||||
if (!targetWindow) return prev;
|
||||
|
||||
const newZIndex = ++nextZIndex.current;
|
||||
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 contextValue: WindowManagerContextType = {
|
||||
windows,
|
||||
openWindow,
|
||||
closeWindow,
|
||||
minimizeWindow,
|
||||
maximizeWindow,
|
||||
focusWindow,
|
||||
updateWindow,
|
||||
};
|
||||
|
||||
return (
|
||||
<WindowManagerContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<div className="window-container">
|
||||
{windows.map((window) => (
|
||||
<div key={window.id}>
|
||||
{typeof window.component === "function"
|
||||
? window.component(window.id)
|
||||
: window.component}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</WindowManagerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useWindowManager() {
|
||||
const context = React.useContext(WindowManagerContext);
|
||||
if (!context) {
|
||||
throw new Error("useWindowManager must be used within a WindowManager");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
Reference in New Issue
Block a user