v1.9.0 (#437)
* fix: Resolve database encryption atomicity issues and enhance debugging (#430) * fix: Resolve database encryption atomicity issues and enhance debugging This commit addresses critical data corruption issues caused by non-atomic file writes during database encryption, and adds comprehensive diagnostic logging to help debug encryption-related failures. **Problem:** Users reported "Unsupported state or unable to authenticate data" errors when starting the application after system crashes or Docker container restarts. The root cause was non-atomic writes of encrypted database files: 1. Encrypted data file written (step 1) 2. Metadata file written (step 2) → If process crashes between steps 1 and 2, files become inconsistent → New IV/tag in data file, old IV/tag in metadata → GCM authentication fails on next startup → User data permanently inaccessible **Solution - Atomic Writes:** 1. Write-to-temp + atomic-rename pattern: - Write to temporary files (*.tmp-timestamp-pid) - Perform atomic rename operations - Clean up temp files on failure 2. Data integrity validation: - Add dataSize field to metadata - Verify file size before decryption - Early detection of corrupted writes 3. Enhanced error diagnostics: - Key fingerprints (SHA256 prefix) for verification - File modification timestamps - Detailed GCM auth failure messages - Automatic diagnostic info generation **Changes:** database-file-encryption.ts: - Implement atomic write pattern in encryptDatabaseFromBuffer - Implement atomic write pattern in encryptDatabaseFile - Add dataSize field to EncryptedFileMetadata interface - Validate file size before decryption in decryptDatabaseToBuffer - Enhanced error messages for GCM auth failures - Add getDiagnosticInfo() function for comprehensive debugging - Add debug logging for all encryption/decryption operations system-crypto.ts: - Add detailed logging for DATABASE_KEY initialization - Log key source (env var vs .env file) - Add key fingerprints to all log messages - Better error messages when key loading fails db/index.ts: - Automatically generate diagnostic info on decryption failure - Log detailed debugging information to help users troubleshoot **Debugging Info Added:** - Key initialization: source, fingerprint, length, path - Encryption: original size, encrypted size, IV/tag prefixes, temp paths - Decryption: file timestamps, metadata content, key fingerprint matching - Auth failures: .env file status, key availability, file consistency - File diagnostics: existence, readability, size validation, mtime comparison **Backward Compatibility:** - dataSize field is optional (metadata.dataSize?: number) - Old encrypted files without dataSize continue to work - No migration required **Testing:** - Compiled successfully - No breaking changes to existing APIs - Graceful handling of legacy v1 encrypted files Fixes data loss issues reported by users experiencing container restarts and system crashes during database saves. * fix: Cleanup PR * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: Merge metadata and DB into 1 file * fix: Add initial command palette * Feature/german language support (#431) * Update translation.json Fixed some translation issues for German, made it more user friendly and common. * Update translation.json added updated block for serverStats * Update translation.json Added translations * Update translation.json Removed duplicate of "free":"Free" * feat: Finalize command palette * fix: Several bug fixes for terminals, server stats, and general feature improvements * feat: Enhanced security, UI improvements, and animations (#432) * fix: Remove empty catch blocks and add error logging * refactor: Modularize server stats widget collectors * feat: Add i18n support for terminal customization and login stats - Add comprehensive terminal customization translations (60+ keys) for appearance, behavior, and advanced settings across all 4 languages - Add SSH login statistics translations - Update HostManagerEditor to use i18n for all terminal customization UI elements - Update LoginStatsWidget to use i18n for all UI text - Add missing logger imports in backend files for improved debugging * feat: Add keyboard shortcut enhancements with Kbd component - Add shadcn kbd component for displaying keyboard shortcuts - Enhance file manager context menu to display shortcuts with Kbd component - Add 5 new keyboard shortcuts to file manager: - Ctrl+D: Download selected files - Ctrl+N: Create new file - Ctrl+Shift+N: Create new folder - Ctrl+U: Upload files - Enter: Open/run selected file - Add keyboard shortcut hints to command palette footer - Create helper function to parse and render keyboard shortcuts * feat: Add i18n support for command palette - Add commandPalette translation section with 22 keys to all 4 languages - Update CommandPalette component to use i18n for all UI text - Translate search placeholder, group headings, menu items, and shortcut hints - Support multilingual command palette interface * feat: Add smooth transitions and animations to UI - Add fade-in/fade-out transition to command palette (200ms) - Add scale animation to command palette on open/close - Add smooth popup animation to context menu (150ms) - Add visual feedback for file selection with ring effect - Add hover scale effect to file grid items - Add transition-all to list view items for consistent behavior - Zero JavaScript overhead, pure CSS transitions - All animations under 200ms for instant feel * feat: Add button active state and dashboard card animations - Add active:scale-95 to all buttons for tactile click feedback - Add hover border effect to dashboard cards (150ms transition) - Add pulse animation to dashboard loading states - Pure CSS transitions with zero JavaScript overhead - Improves enterprise-level feel of UI * feat: Add smooth macOS-style page transitions - Add fullscreen crossfade transition for login/logout (300ms fade-out + 400ms fade-in) - Add slide-in-from-right animation for all page switches (Dashboard, Terminal, SSH Manager, Admin, Profile) - Fix TypeScript compilation by adding esModuleInterop to tsconfig.node.json - Pass handleLogout from DesktopApp to LeftSidebar for consistent transition behavior All page transitions now use Tailwind animate-in utilities with 300ms duration for smooth, native-feeling UX * fix: Add key prop to force animation re-trigger on tab switch Each page container now has key={currentTab} to ensure React unmounts and remounts the element on every tab switch, properly triggering the slide-in animation * revert: Remove page transition animations Page switching animations were not noticeable enough and felt unnecessary. Keep only the login/logout fullscreen crossfade transitions which provide clear visual feedback for authentication state changes * feat: Add ripple effect to login/logout transitions Add three-layer expanding ripple animation during fadeOut phase: - Ripples expand from screen center using primary theme color - Each layer has staggered delay (0ms, 150ms, 300ms) for wave effect - Ripples fade out as they expand to create elegant visual feedback - Uses pure CSS keyframe animation, no external libraries Total animation: 800ms ripple + 300ms screen fade * feat: Add smooth TERMIX logo animation to transitions Changes: - Extend transition duration from 300ms/400ms to 800ms/600ms for more elegant feel - Reduce ripple intensity from /20,/15,/10 to /8,/5 for subtlety - Slow down ripple animation from 0.8s to 2s with cubic-bezier easing - Add centered TERMIX logo with monospace font and subtitle - Logo fades in from 80% scale, holds, then fades out at 110% scale - Total effect: 1.2s logo animation synced with 2s ripple waves Creates a premium, branded transition experience * feat: Enhance transition animation with premium details Timing adjustments: - Extend fadeOut from 800ms to 1200ms - Extend fadeIn from 600ms to 800ms - Slow background fade to 700ms for elegance Visual enhancements: - Add 4-layer ripple waves (10%, 7%, 5%, 3% opacity) with staggered delays - Ripple animation extended to 2.5s with refined opacity curve - Logo blur effect: starts at 8px, sharpens to 0px, exits at 4px - Logo glow effect: triple-layer text-shadow using primary theme color - Increase logo size from text-6xl to text-7xl - Subtitle delayed fade-in from bottom with smooth slide animation Creates a cinematic, polished brand experience * feat: Redesign login page with split-screen cinematic layout Major redesign of authentication page: Left Side (40% width): - Full-height gradient background using primary theme color - Large TERMIX logo with glow effect - Subtitle and tagline - Infinite animated ripple waves (3 layers) - Hidden on mobile, shows brand identity Right Side (60% width): - Centered glassmorphism card with backdrop blur - Refined tab switcher with pill-style active state - Enlarged title with gradient text effect - Added welcome subtitles for better UX - Card slides in from bottom on load - All existing functionality preserved Visual enhancements: - Tab navigation: segmented control style in muted container - Active tab: white background with subtle shadow - Smooth 200ms transitions on all interactions - Card: rounded-2xl, shadow-xl, semi-transparent border Creates premium, modern login experience matching transition animations * feat: Update login page theme colors and add i18n support - Changed login page gradient from blue to match dark theme colors - Updated ripple effects to use theme primary color - Added i18n translation keys for login page (auth.tagline, auth.description, auth.welcomeBack, auth.createAccount, auth.continueExternal) - Updated all language files (en, zh, de, ru, pt-BR) with new translations - Fixed TypeScript compilation issues by clearing build cache * refactor: Use shadcn Tabs component and fix modal styling - Replace custom tab navigation with shadcn Tabs component - Restore border-2 border-dark-border for modal consistency - Remove circular icon from login success message - Simplify authentication success display * refactor: Remove ripple effects and gradient from login page - Remove animated ripple background effects - Remove gradient background, use solid color (bg-dark-bg-darker) - Remove text-shadow glow effect from logo - Simplify brand showcase to clean, minimal design * feat: Add decorative slash and remove subtitle from login page - Add decorative slash divider with gradient lines below TERMIX logo - Remove subtitle text (welcomeBack and createAccount) - Simplify page title to show only the main heading * feat: Add diagonal line pattern background to login page - Replace decorative slash with subtle diagonal line pattern background - Use repeating-linear-gradient at 45deg angle - Set very low opacity (0.03) for subtle effect - Pattern uses theme primary color * fix: Display diagonal line pattern on login background - Combine background color and pattern in single style attribute - Use white semi-transparent lines (rgba 0.03 opacity) - 45deg angle, 35px spacing, 2px width - Remove separate overlay div to ensure pattern visibility * security: Fix user enumeration vulnerability in login - Unify error messages for invalid username and incorrect password - Both return 401 status with 'Invalid username or password' - Prevent attackers from enumerating valid usernames - Maintain detailed logging for debugging purposes - Changed from 404 'User not found' to generic auth failure message * security: Add login rate limiting to prevent brute force attacks - Implement LoginRateLimiter with IP and username-based tracking - Block after 5 failed attempts within 15 minutes - Lock account/IP for 15 minutes after threshold - Automatic cleanup of expired entries every 5 minutes - Track remaining attempts in logs for monitoring - Return 429 status with remaining time on rate limit - Reset counters on successful login - Dual protection: both IP-based and username-based limits * French translation (#434) * Adding French Language * Enhancements * feat: Replace the old ssh tools system with a new dedicated sidebar * fix: Merge zac/luke * fix: Finalize new sidebar, improve and loading animations * Added ability to close non-primary tabs involved in a split view (#435) * fix: General bug fixes/small feature improvements * feat: General UI improvements and translation updates * fix: Command history and file manager styling issues * feat: General bug fixes, added server stat commands, improved split screen, link accounts, etc * fix: add Accept header for OIDC callback request (#436) * Delete DOWNLOADS.md * fix: add Accept header for OIDC callback request --------- Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> * fix: More bug fixes and QOL fixes * fix: Server stats not respecting interval and fixed SSH toool type issues * fix: Remove github links * fix: Delete account spacing * fix: Increment version * fix: Unable to delete hosts and add nginx for terminal * fix: Unable to delete hosts * fix: Unable to delete hosts * fix: Unable to delete hosts * fix: OIDC/local account linking breaking both logins * chore: File cleanup * feat: Max terminal tab size and save current file manager sorting type * fix: Terminal display issue, migrate host editor to use combobox * feat: Add snippet folder/customization system * fix: Fix OIDC linking and prep release * fix: Increment version --------- Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Max <herzmaximilian@gmail.com> Co-authored-by: SlimGary <trash.slim@gmail.com> Co-authored-by: jarrah31 <jarrah31@gmail.com> Co-authored-by: Kf637 <mail@kf637.tech>
This commit was merged in pull request #437.
This commit is contained in:
420
src/ui/desktop/apps/command-palette/CommandPalette.tsx
Normal file
420
src/ui/desktop/apps/command-palette/CommandPalette.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import {
|
||||
Command,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandGroup,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command.tsx";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import {
|
||||
Key,
|
||||
Server,
|
||||
Settings,
|
||||
User,
|
||||
Github,
|
||||
Terminal,
|
||||
FolderOpen,
|
||||
Pencil,
|
||||
EllipsisVertical,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BiMoney, BiSupport } from "react-icons/bi";
|
||||
import { BsDiscord } from "react-icons/bs";
|
||||
import { GrUpdate } from "react-icons/gr";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts";
|
||||
import type { RecentActivityItem } from "@/ui/main-axios.ts";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
|
||||
interface SSHHost {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
folder: string;
|
||||
tags: string[];
|
||||
pin: boolean;
|
||||
authType: string;
|
||||
password?: string;
|
||||
key?: string;
|
||||
keyPassword?: string;
|
||||
keyType?: string;
|
||||
enableTerminal: boolean;
|
||||
enableTunnel: boolean;
|
||||
enableFileManager: boolean;
|
||||
defaultPath: string;
|
||||
tunnelConnections: unknown[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
||||
const [recentActivity, setRecentActivity] = useState<RecentActivityItem[]>(
|
||||
[],
|
||||
);
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
inputRef.current?.focus();
|
||||
getRecentActivity(50).then((activity) => {
|
||||
setRecentActivity(activity.slice(0, 5));
|
||||
});
|
||||
getSSHHosts().then((allHosts) => {
|
||||
setHosts(allHosts);
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleAddHost = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
updateTab(sshManagerTab.id, { initialTab: "add_host" });
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "ssh_manager",
|
||||
title: t("commandPalette.hostManager"),
|
||||
initialTab: "add_host",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleAddCredential = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
updateTab(sshManagerTab.id, { initialTab: "add_credential" });
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "ssh_manager",
|
||||
title: t("commandPalette.hostManager"),
|
||||
initialTab: "add_credential",
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenAdminSettings = () => {
|
||||
const adminTab = tabList.find((t) => t.type === "admin");
|
||||
if (adminTab) {
|
||||
setCurrentTab(adminTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "admin",
|
||||
title: t("commandPalette.adminSettings"),
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenUserProfile = () => {
|
||||
const userProfileTab = tabList.find((t) => t.type === "user_profile");
|
||||
if (userProfileTab) {
|
||||
setCurrentTab(userProfileTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
type: "user_profile",
|
||||
title: t("commandPalette.userProfile"),
|
||||
});
|
||||
setCurrentTab(id);
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleOpenUpdateLog = () => {
|
||||
window.open("https://github.com/Termix-SSH/Termix/releases", "_blank");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleGitHub = () => {
|
||||
window.open("https://github.com/Termix-SSH/Termix", "_blank");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleSupport = () => {
|
||||
window.open("https://github.com/Termix-SSH/Support/issues/new", "_blank");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleDiscord = () => {
|
||||
window.open("https://discord.com/invite/jVQGdvHDrf", "_blank");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleDonate = () => {
|
||||
window.open("https://github.com/sponsors/LukeGus", "_blank");
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleActivityClick = (item: RecentActivityItem) => {
|
||||
getSSHHosts().then((hosts) => {
|
||||
const host = hosts.find((h: { id: number }) => h.id === item.hostId);
|
||||
if (!host) return;
|
||||
|
||||
if (item.type === "terminal") {
|
||||
addTab({
|
||||
type: "terminal",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
} else if (item.type === "file_manager") {
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: item.hostName,
|
||||
hostConfig: host,
|
||||
});
|
||||
}
|
||||
});
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleHostTerminalClick = (host: SSHHost) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({ type: "terminal", title, hostConfig: host });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleHostFileManagerClick = (host: SSHHost) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({ type: "file_manager", title, hostConfig: host });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleHostServerDetailsClick = (host: SSHHost) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({ type: "server", title, hostConfig: host });
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleHostEditClick = (host: SSHHost) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "ssh_manager",
|
||||
title: t("commandPalette.hostManager"),
|
||||
hostConfig: host,
|
||||
initialTab: "add_host",
|
||||
});
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 flex items-center justify-center bg-black/30 transition-opacity duration-200",
|
||||
!isOpen && "opacity-0 pointer-events-none",
|
||||
)}
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
<Command
|
||||
className={cn(
|
||||
"w-3/4 max-w-2xl max-h-[60vh] rounded-lg border-2 border-dark-border shadow-md flex flex-col",
|
||||
"transition-all duration-200 ease-out",
|
||||
!isOpen && "scale-95 opacity-0",
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CommandInput
|
||||
ref={inputRef}
|
||||
placeholder={t("commandPalette.searchPlaceholder")}
|
||||
/>
|
||||
<CommandList
|
||||
key={recentActivity.length}
|
||||
className="w-full h-auto flex-grow overflow-y-auto"
|
||||
style={{ maxHeight: "inherit" }}
|
||||
>
|
||||
{recentActivity.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading={t("commandPalette.recentActivity")}>
|
||||
{recentActivity.map((item, index) => (
|
||||
<CommandItem
|
||||
key={`recent-activity-${index}-${item.type}-${item.hostId}-${item.timestamp}`}
|
||||
value={`recent-activity-${index}-${item.hostName}-${item.type}`}
|
||||
onSelect={() => handleActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? <Terminal /> : <FolderOpen />}
|
||||
<span>{item.hostName}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
<CommandGroup heading={t("commandPalette.navigation")}>
|
||||
<CommandItem onSelect={handleAddHost}>
|
||||
<Server />
|
||||
<span>{t("commandPalette.addHost")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleAddCredential}>
|
||||
<Key />
|
||||
<span>{t("commandPalette.addCredential")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenAdminSettings}>
|
||||
<Settings />
|
||||
<span>{t("commandPalette.adminSettings")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenUserProfile}>
|
||||
<User />
|
||||
<span>{t("commandPalette.userProfile")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleOpenUpdateLog}>
|
||||
<GrUpdate />
|
||||
<span>{t("commandPalette.updateLog")}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
{hosts.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading={t("commandPalette.hosts")}>
|
||||
{hosts.map((host, index) => {
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
return (
|
||||
<CommandItem
|
||||
key={`host-${index}-${host.id}`}
|
||||
value={`host-${index}-${title}-${host.id}`}
|
||||
onSelect={() => {
|
||||
if (host.enableTerminal) {
|
||||
handleHostTerminalClick(host);
|
||||
}
|
||||
}}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4" />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="!px-2 h-7 border-1 border-dark-border"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<EllipsisVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
side="right"
|
||||
className="w-56 bg-dark-bg border-dark-border text-white"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostServerDetailsClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Server className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.openServerDetails")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostFileManagerClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.openFileManager")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleHostEditClick(host);
|
||||
}}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="flex-1">
|
||||
{t("commandPalette.edit")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
<CommandGroup heading={t("commandPalette.links")}>
|
||||
<CommandItem onSelect={handleGitHub}>
|
||||
<Github />
|
||||
<span>{t("commandPalette.github")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleSupport}>
|
||||
<BiSupport />
|
||||
<span>{t("commandPalette.support")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleDiscord}>
|
||||
<BsDiscord />
|
||||
<span>{t("commandPalette.discord")}</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={handleDonate}>
|
||||
<BiMoney />
|
||||
<span>{t("commandPalette.donate")}</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
<div className="border-t border-dark-border px-4 py-2 bg-dark-hover/50 flex items-center justify-between text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("commandPalette.press")}</span>
|
||||
<KbdGroup>
|
||||
<Kbd>Shift</Kbd>
|
||||
<Kbd>Shift</Kbd>
|
||||
</KbdGroup>
|
||||
<span>{t("commandPalette.toToggle")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t("commandPalette.close")}</span>
|
||||
<Kbd>Esc</Kbd>
|
||||
</div>
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -52,6 +53,8 @@ export function CredentialEditor({
|
||||
const [detectedKeyType, setDetectedKeyType] = useState<string | null>(null);
|
||||
const [keyDetectionLoading, setKeyDetectionLoading] = useState(false);
|
||||
const keyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("general");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const [detectedPublicKeyType, setDetectedPublicKeyType] = useState<
|
||||
string | null
|
||||
@@ -60,6 +63,10 @@ export function CredentialEditor({
|
||||
useState(false);
|
||||
const publicKeyDetectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setFormError(null);
|
||||
}, [activeTab]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
@@ -320,6 +327,8 @@ export function CredentialEditor({
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
setFormError(null);
|
||||
|
||||
if (!data.name || data.name.trim() === "") {
|
||||
data.name = data.username;
|
||||
}
|
||||
@@ -378,6 +387,28 @@ export function CredentialEditor({
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormError = () => {
|
||||
const errors = form.formState.errors;
|
||||
|
||||
if (
|
||||
errors.name ||
|
||||
errors.username ||
|
||||
errors.description ||
|
||||
errors.folder ||
|
||||
errors.tags
|
||||
) {
|
||||
setActiveTab("general");
|
||||
} else if (
|
||||
errors.password ||
|
||||
errors.key ||
|
||||
errors.publicKey ||
|
||||
errors.keyPassword ||
|
||||
errors.keyType
|
||||
) {
|
||||
setActiveTab("authentication");
|
||||
}
|
||||
};
|
||||
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
||||
@@ -427,11 +458,20 @@ export function CredentialEditor({
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
onSubmit={form.handleSubmit(onSubmit, handleFormError)}
|
||||
className="flex flex-col flex-1 min-h-0 h-full"
|
||||
>
|
||||
<ScrollArea className="flex-1 min-h-0 w-full my-1 pb-2">
|
||||
<Tabs defaultValue="general" className="w-full">
|
||||
{formError && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<AlertDescription>{formError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">
|
||||
{t("credentials.general")}
|
||||
|
||||
@@ -16,6 +16,18 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Search,
|
||||
Key,
|
||||
@@ -32,7 +44,9 @@ import {
|
||||
Upload,
|
||||
Server,
|
||||
User,
|
||||
ChevronsUpDown,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
getCredentials,
|
||||
deleteCredential,
|
||||
@@ -82,9 +96,7 @@ export function CredentialsManager({
|
||||
>([]);
|
||||
const [selectedHostId, setSelectedHostId] = useState<string>("");
|
||||
const [deployLoading, setDeployLoading] = useState(false);
|
||||
const [hostSearchQuery, setHostSearchQuery] = useState("");
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [hostComboboxOpen, setHostComboboxOpen] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -94,41 +106,11 @@ export function CredentialsManager({
|
||||
|
||||
useEffect(() => {
|
||||
if (showDeployDialog) {
|
||||
setDropdownOpen(false);
|
||||
setHostSearchQuery("");
|
||||
setHostComboboxOpen(false);
|
||||
setSelectedHostId("");
|
||||
setTimeout(() => {
|
||||
if (
|
||||
document.activeElement &&
|
||||
(document.activeElement as HTMLElement).blur
|
||||
) {
|
||||
(document.activeElement as HTMLElement).blur();
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}, [showDeployDialog]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (dropdownOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const fetchHosts = async () => {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
@@ -168,8 +150,7 @@ export function CredentialsManager({
|
||||
}
|
||||
setDeployingCredential(credential);
|
||||
setSelectedHostId("");
|
||||
setHostSearchQuery("");
|
||||
setDropdownOpen(false);
|
||||
setHostComboboxOpen(false);
|
||||
setShowDeployDialog(true);
|
||||
};
|
||||
|
||||
@@ -640,6 +621,9 @@ export function CredentialsManager({
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
ID: {credential.id}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.authType === "password"
|
||||
? t("credentials.password")
|
||||
@@ -824,13 +808,10 @@ export function CredentialsManager({
|
||||
)}
|
||||
|
||||
<Sheet open={showDeployDialog} onOpenChange={setShowDeployDialog}>
|
||||
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto">
|
||||
<SheetContent className="w-[500px] max-w-[50vw] overflow-y-auto bg-dark-bg">
|
||||
<div className="px-4 py-4">
|
||||
<div className="space-y-3 pb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 rounded-lg bg-green-100 dark:bg-green-900/30">
|
||||
<Upload className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-lg font-semibold">
|
||||
{t("credentials.deploySSHKey")}
|
||||
@@ -899,67 +880,62 @@ export function CredentialsManager({
|
||||
<Server className="h-4 w-4 mr-2 text-muted-foreground" />
|
||||
{t("credentials.targetHost")}
|
||||
</label>
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<Input
|
||||
placeholder={t("credentials.chooseHostToDeploy")}
|
||||
value={hostSearchQuery}
|
||||
onChange={(e) => {
|
||||
setHostSearchQuery(e.target.value);
|
||||
}}
|
||||
onClick={() => {
|
||||
setDropdownOpen(true);
|
||||
}}
|
||||
className="w-full"
|
||||
autoFocus={false}
|
||||
tabIndex={0}
|
||||
/>
|
||||
{dropdownOpen && (
|
||||
<div className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||
{availableHosts.length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
{t("credentials.noHostsAvailable")}
|
||||
</div>
|
||||
) : availableHosts.filter(
|
||||
(host) =>
|
||||
!hostSearchQuery ||
|
||||
host.name
|
||||
?.toLowerCase()
|
||||
.includes(hostSearchQuery.toLowerCase()) ||
|
||||
host.ip
|
||||
?.toLowerCase()
|
||||
.includes(hostSearchQuery.toLowerCase()) ||
|
||||
host.username
|
||||
?.toLowerCase()
|
||||
.includes(hostSearchQuery.toLowerCase()),
|
||||
).length === 0 ? (
|
||||
<div className="p-3 text-sm text-muted-foreground text-center">
|
||||
{t("credentials.noHostsMatchSearch")}
|
||||
</div>
|
||||
) : (
|
||||
availableHosts
|
||||
.filter(
|
||||
(host) =>
|
||||
!hostSearchQuery ||
|
||||
host.name
|
||||
?.toLowerCase()
|
||||
.includes(hostSearchQuery.toLowerCase()) ||
|
||||
host.ip
|
||||
?.toLowerCase()
|
||||
.includes(hostSearchQuery.toLowerCase()) ||
|
||||
host.username
|
||||
?.toLowerCase()
|
||||
.includes(hostSearchQuery.toLowerCase()),
|
||||
)
|
||||
.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center gap-3 py-2 px-3 hover:bg-muted cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedHostId(host.id.toString());
|
||||
setHostSearchQuery(host.name || host.ip);
|
||||
setDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
<Popover
|
||||
open={hostComboboxOpen}
|
||||
onOpenChange={setHostComboboxOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={hostComboboxOpen}
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{selectedHostId
|
||||
? (() => {
|
||||
const host = availableHosts.find(
|
||||
(h) => h.id.toString() === selectedHostId,
|
||||
);
|
||||
return host
|
||||
? `${host.name || host.ip}`
|
||||
: t("credentials.chooseHostToDeploy");
|
||||
})()
|
||||
: t("credentials.chooseHostToDeploy")}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t("credentials.chooseHostToDeploy")}
|
||||
/>
|
||||
<CommandEmpty>
|
||||
{availableHosts.length === 0
|
||||
? t("credentials.noHostsAvailable")
|
||||
: t("credentials.noHostsMatchSearch")}
|
||||
</CommandEmpty>
|
||||
<CommandGroup className="max-h-[300px] overflow-y-auto">
|
||||
{availableHosts.map((host) => (
|
||||
<CommandItem
|
||||
key={host.id}
|
||||
value={`${host.name} ${host.ip} ${host.username} ${host.id}`}
|
||||
onSelect={() => {
|
||||
setSelectedHostId(host.id.toString());
|
||||
setHostComboboxOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedHostId === host.id.toString()
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-1.5 rounded bg-muted">
|
||||
<Server className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
@@ -972,11 +948,12 @@ export function CredentialsManager({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="border border-blue-200 dark:border-blue-800 rounded-lg p-3 bg-blue-50 dark:bg-blue-900/20">
|
||||
@@ -1006,7 +983,7 @@ export function CredentialsManager({
|
||||
<Button
|
||||
onClick={performDeploy}
|
||||
disabled={!selectedHostId || deployLoading}
|
||||
className="flex-1 bg-green-600 hover:bg-green-700 text-white"
|
||||
className="flex-1"
|
||||
>
|
||||
{deployLoading ? (
|
||||
<div className="flex items-center">
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
import {
|
||||
ChartLine,
|
||||
Clock,
|
||||
@@ -50,6 +51,8 @@ interface DashboardProps {
|
||||
userId: string | null;
|
||||
}) => void;
|
||||
isTopbarOpen: boolean;
|
||||
rightSidebarOpen?: boolean;
|
||||
rightSidebarWidth?: number;
|
||||
}
|
||||
|
||||
export function Dashboard({
|
||||
@@ -58,6 +61,8 @@ export function Dashboard({
|
||||
onAuthSuccess,
|
||||
isTopbarOpen,
|
||||
onSelectView,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
}: DashboardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [loggedIn, setLoggedIn] = useState(isAuthenticated);
|
||||
@@ -85,16 +90,19 @@ export function Dashboard({
|
||||
>([]);
|
||||
const [serverStatsLoading, setServerStatsLoading] = useState<boolean>(true);
|
||||
|
||||
const { addTab, setCurrentTab, tabs: tabList } = useTabs();
|
||||
const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs();
|
||||
|
||||
let sidebarState: "expanded" | "collapsed" = "expanded";
|
||||
try {
|
||||
const sidebar = useSidebar();
|
||||
sidebarState = sidebar.state;
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("Dashboard operation failed:", error);
|
||||
}
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const rightMarginPx = 17;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -173,7 +181,9 @@ export function Dashboard({
|
||||
if (Array.isArray(tunnelConnections)) {
|
||||
totalTunnelsCount += tunnelConnections.length;
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("Dashboard operation failed:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
setTotalTunnels(totalTunnelsCount);
|
||||
@@ -194,27 +204,57 @@ export function Dashboard({
|
||||
|
||||
setServerStatsLoading(true);
|
||||
const serversWithStats = await Promise.all(
|
||||
hosts.slice(0, 50).map(async (host: { id: number; name: string }) => {
|
||||
try {
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu.percent,
|
||||
ram: metrics.memory.percent,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
}),
|
||||
hosts
|
||||
.slice(0, 50)
|
||||
.map(
|
||||
async (host: {
|
||||
id: number;
|
||||
name: string;
|
||||
statsConfig?: string | { metricsEnabled?: boolean };
|
||||
}) => {
|
||||
try {
|
||||
let statsConfig: { metricsEnabled?: boolean } = {
|
||||
metricsEnabled: true,
|
||||
};
|
||||
if (host.statsConfig) {
|
||||
if (typeof host.statsConfig === "string") {
|
||||
statsConfig = JSON.parse(host.statsConfig);
|
||||
} else {
|
||||
statsConfig = host.statsConfig;
|
||||
}
|
||||
}
|
||||
|
||||
if (statsConfig.metricsEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metrics = await getServerMetricsById(host.id);
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: metrics.cpu.percent,
|
||||
ram: metrics.memory.percent,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
id: host.id,
|
||||
name: host.name || `Host ${host.id}`,
|
||||
cpu: null,
|
||||
ram: null,
|
||||
};
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
const validServerStats = serversWithStats.filter(
|
||||
(server) => server.cpu !== null && server.ram !== null,
|
||||
(
|
||||
server,
|
||||
): server is {
|
||||
id: number;
|
||||
name: string;
|
||||
cpu: number | null;
|
||||
ram: number | null;
|
||||
} => server !== null && server.cpu !== null && server.ram !== null,
|
||||
);
|
||||
setServerStats(validServerStats);
|
||||
setServerStatsLoading(false);
|
||||
@@ -264,6 +304,7 @@ export function Dashboard({
|
||||
const handleAddHost = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
updateTab(sshManagerTab.id, { initialTab: "add_host" });
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
@@ -278,6 +319,7 @@ export function Dashboard({
|
||||
const handleAddCredential = () => {
|
||||
const sshManagerTab = tabList.find((t) => t.type === "ssh_manager");
|
||||
if (sshManagerTab) {
|
||||
updateTab(sshManagerTab.id, { initialTab: "add_credential" });
|
||||
setCurrentTab(sshManagerTab.id);
|
||||
} else {
|
||||
const id = addTab({
|
||||
@@ -327,23 +369,32 @@ export function Dashboard({
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex"
|
||||
className="bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden flex min-w-0"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginRight: rightSidebarOpen
|
||||
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
|
||||
: rightMarginPx,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
transition:
|
||||
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col relative z-10 w-full h-full">
|
||||
<div className="flex flex-row items-center justify-between w-full px-3 mt-3">
|
||||
<div className="text-2xl text-white font-semibold">
|
||||
<div className="flex flex-col relative z-10 w-full h-full min-w-0">
|
||||
<div className="flex flex-row items-center justify-between w-full px-3 mt-3 min-w-0 flex-wrap gap-2">
|
||||
<div className="text-2xl text-white font-semibold shrink-0">
|
||||
{t("dashboard.title")}
|
||||
</div>
|
||||
<div className="flex flex-row gap-3">
|
||||
<div className="flex flex-row gap-3 flex-wrap min-w-0">
|
||||
<div className="flex flex-col items-center gap-4 justify-center mr-5 min-w-0 shrink">
|
||||
<p className="text-muted-foreground text-sm whitespace-nowrap">
|
||||
Press <Kbd>LShift</Kbd> twice to open the command palette
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
className="font-semibold shrink-0"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
@@ -355,7 +406,7 @@ export function Dashboard({
|
||||
{t("dashboard.github")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
className="font-semibold shrink-0"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
@@ -367,7 +418,7 @@ export function Dashboard({
|
||||
{t("dashboard.support")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
className="font-semibold shrink-0"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
@@ -379,7 +430,7 @@ export function Dashboard({
|
||||
{t("dashboard.discord")}
|
||||
</Button>
|
||||
<Button
|
||||
className="font-semibold"
|
||||
className="font-semibold shrink-0"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
window.open("https://github.com/sponsors/LukeGus", "_blank")
|
||||
@@ -392,23 +443,23 @@ export function Dashboard({
|
||||
|
||||
<Separator className="mt-3 p-0.25" />
|
||||
|
||||
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0">
|
||||
<div className="flex flex-row flex-1 gap-4 min-h-0">
|
||||
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex flex-col flex-1 my-5 mx-5 gap-4 min-h-0 min-w-0">
|
||||
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
|
||||
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<Server className="mr-3" />
|
||||
{t("dashboard.serverOverview")}
|
||||
</p>
|
||||
<div className="bg-dark-bg w-full h-auto border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center justify-between mb-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center justify-between mb-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<History
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<p className="ml-2 leading-none">
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.version")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -430,14 +481,14 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between mb-5">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center justify-between mb-5 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Clock
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<p className="ml-2 leading-none">
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.uptime")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -449,14 +500,14 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center justify-between min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Database
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<p className="ml-2 leading-none">
|
||||
<p className="ml-2 leading-none truncate">
|
||||
{t("dashboard.database")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -473,14 +524,14 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Server
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalServers")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -488,14 +539,14 @@ export function Dashboard({
|
||||
{totalServers}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Network
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalTunnels")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -505,14 +556,14 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col grid grid-cols-2 gap-2 mt-2">
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3">
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="flex flex-row items-center justify-between bg-dark-bg w-full h-auto mt-3 border-2 border-dark-border rounded-md px-3 py-3 min-w-0 gap-2">
|
||||
<div className="flex flex-row items-center min-w-0">
|
||||
<Key
|
||||
size={16}
|
||||
color="#FFFFFF"
|
||||
className="mr-3 shrink-0"
|
||||
/>
|
||||
<p className="m-0 leading-none">
|
||||
<p className="m-0 leading-none truncate">
|
||||
{t("dashboard.totalCredentials")}
|
||||
</p>
|
||||
</div>
|
||||
@@ -523,7 +574,7 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<div className="flex flex-row items-center justify-between mb-3 mt-1">
|
||||
<p className="text-xl font-semibold flex flex-row items-center">
|
||||
@@ -540,10 +591,10 @@ export function Dashboard({
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{recentActivityLoading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm">
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingRecentActivity")}</span>
|
||||
</div>
|
||||
@@ -556,7 +607,7 @@ export function Dashboard({
|
||||
<Button
|
||||
key={item.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg"
|
||||
className="border-2 !border-dark-border bg-dark-bg min-w-0"
|
||||
onClick={() => handleActivityClick(item)}
|
||||
>
|
||||
{item.type === "terminal" ? (
|
||||
@@ -574,17 +625,17 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row flex-1 gap-4 min-h-0">
|
||||
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex flex-row flex-1 gap-4 min-h-0 min-w-0">
|
||||
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
|
||||
<div className="flex flex-col mx-3 my-2 overflow-y-auto overflow-x-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<FastForward className="mr-3" />
|
||||
{t("dashboard.quickActions")}
|
||||
</p>
|
||||
<div className="grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-y-auto overflow-x-hidden">
|
||||
<div className="grid gap-4 grid-cols-3 auto-rows-min overflow-y-auto overflow-x-hidden">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
||||
onClick={handleAddHost}
|
||||
>
|
||||
<Server
|
||||
@@ -597,7 +648,7 @@ export function Dashboard({
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
||||
onClick={handleAddCredential}
|
||||
>
|
||||
<Key
|
||||
@@ -611,7 +662,7 @@ export function Dashboard({
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
||||
onClick={handleOpenAdminSettings}
|
||||
>
|
||||
<Settings
|
||||
@@ -625,7 +676,7 @@ export function Dashboard({
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3"
|
||||
className="border-2 !border-dark-border flex flex-col items-center justify-center h-auto p-3 min-w-0"
|
||||
onClick={handleOpenUserProfile}
|
||||
>
|
||||
<User
|
||||
@@ -639,17 +690,17 @@ export function Dashboard({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden">
|
||||
<div className="flex-1 min-w-0 border-2 border-dark-border rounded-md bg-dark-bg-darker flex flex-col overflow-hidden transition-all duration-150 hover:border-primary/20">
|
||||
<div className="flex flex-col mx-3 my-2 flex-1 overflow-hidden">
|
||||
<p className="text-xl font-semibold mb-3 mt-1 flex flex-row items-center">
|
||||
<ChartLine className="mr-3" />
|
||||
{t("dashboard.serverStats")}
|
||||
</p>
|
||||
<div
|
||||
className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
className={`grid gap-4 grid-cols-3 auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`}
|
||||
>
|
||||
{serverStatsLoading ? (
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm">
|
||||
<div className="flex flex-row items-center text-muted-foreground text-sm animate-pulse">
|
||||
<Loader2 className="animate-spin mr-2" size={16} />
|
||||
<span>{t("dashboard.loadingServerStats")}</span>
|
||||
</div>
|
||||
@@ -662,7 +713,7 @@ export function Dashboard({
|
||||
<Button
|
||||
key={server.id}
|
||||
variant="outline"
|
||||
className="border-2 !border-dark-border bg-dark-bg h-auto p-3"
|
||||
className="border-2 !border-dark-border bg-dark-bg h-auto p-3 min-w-0"
|
||||
>
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-row items-center mb-2">
|
||||
@@ -671,7 +722,7 @@ export function Dashboard({
|
||||
{server.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-row justify-between text-xs text-muted-foreground">
|
||||
<div className="flex flex-row justify-start gap-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("dashboard.cpu")}:{" "}
|
||||
{server.cpu !== null
|
||||
|
||||
@@ -16,6 +16,8 @@ import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx";
|
||||
import { PermissionsDialog } from "./components/PermissionsDialog";
|
||||
import { CompressDialog } from "./components/CompressDialog";
|
||||
import {
|
||||
Upload,
|
||||
FolderPlus,
|
||||
@@ -49,6 +51,9 @@ import {
|
||||
addFolderShortcut,
|
||||
getPinnedFiles,
|
||||
logActivity,
|
||||
changeSSHPermissions,
|
||||
extractSSHArchive,
|
||||
compressSSHFiles,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { SidebarItem } from "./FileManagerSidebar";
|
||||
|
||||
@@ -97,7 +102,10 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const [isReconnecting, setIsReconnecting] = useState<boolean>(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">(() => {
|
||||
const saved = localStorage.getItem("fileManagerViewMode");
|
||||
return saved === "grid" || saved === "list" ? saved : "grid";
|
||||
});
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = useState<string>("");
|
||||
@@ -146,6 +154,11 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
|
||||
const [createIntent, setCreateIntent] = useState<CreateIntent | null>(null);
|
||||
const [editingFile, setEditingFile] = useState<FileItem | null>(null);
|
||||
const [permissionsDialogFile, setPermissionsDialogFile] =
|
||||
useState<FileItem | null>(null);
|
||||
const [compressDialogFiles, setCompressDialogFiles] = useState<FileItem[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const { selectedFiles, clearSelection, setSelection } = useFileSelection();
|
||||
|
||||
@@ -527,41 +540,20 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error);
|
||||
|
||||
const isTextFile =
|
||||
file.type.startsWith("text/") ||
|
||||
file.type === "application/json" ||
|
||||
file.type === "application/javascript" ||
|
||||
file.type === "application/xml" ||
|
||||
file.type === "image/svg+xml" ||
|
||||
file.name.match(
|
||||
/\.(txt|json|js|ts|jsx|tsx|css|scss|less|html|htm|xml|svg|yaml|yml|md|markdown|mdown|mkdn|mdx|py|java|c|cpp|h|sh|bash|zsh|bat|ps1|toml|ini|conf|config|sql|vue|svelte)$/i,
|
||||
);
|
||||
|
||||
if (isTextFile) {
|
||||
reader.onload = () => {
|
||||
if (reader.result) {
|
||||
resolve(reader.result as string);
|
||||
} else {
|
||||
reject(new Error("Failed to read text file content"));
|
||||
reader.onload = () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(reader.result);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
} else {
|
||||
reader.onload = () => {
|
||||
if (reader.result instanceof ArrayBuffer) {
|
||||
const bytes = new Uint8Array(reader.result);
|
||||
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"));
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
const base64 = btoa(binary);
|
||||
resolve(base64);
|
||||
} else {
|
||||
reject(new Error("Failed to read file"));
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
|
||||
await uploadSSHFile(
|
||||
@@ -911,6 +903,26 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function handleCopyPath(files: FileItem[]) {
|
||||
if (files.length === 0) return;
|
||||
|
||||
const paths = files.map((file) => file.path).join("\n");
|
||||
|
||||
navigator.clipboard.writeText(paths).then(
|
||||
() => {
|
||||
toast.success(
|
||||
files.length === 1
|
||||
? t("fileManager.pathCopiedToClipboard")
|
||||
: t("fileManager.pathsCopiedToClipboard", { count: files.length }),
|
||||
);
|
||||
},
|
||||
(err) => {
|
||||
console.error("Failed to copy path to clipboard:", err);
|
||||
toast.error(t("fileManager.failedToCopyPath"));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function handlePasteFiles() {
|
||||
if (!clipboard || !sshSessionId) return;
|
||||
|
||||
@@ -1058,6 +1070,80 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExtractArchive(file: FileItem) {
|
||||
if (!sshSessionId) return;
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
toast.info(t("fileManager.extractingArchive", { name: file.name }));
|
||||
|
||||
await extractSSHArchive(
|
||||
sshSessionId,
|
||||
file.path,
|
||||
undefined,
|
||||
currentHost?.id,
|
||||
currentHost?.userId?.toString(),
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("fileManager.archiveExtractedSuccessfully", { name: file.name }),
|
||||
);
|
||||
|
||||
handleRefreshDirectory();
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleOpenCompressDialog(files: FileItem[]) {
|
||||
setCompressDialogFiles(files);
|
||||
}
|
||||
|
||||
async function handleCompress(archiveName: string, format: string) {
|
||||
if (!sshSessionId || compressDialogFiles.length === 0) return;
|
||||
|
||||
try {
|
||||
await ensureSSHConnection();
|
||||
|
||||
const paths = compressDialogFiles.map((f) => f.path);
|
||||
const fileNames = compressDialogFiles.map((f) => f.name);
|
||||
|
||||
toast.info(
|
||||
t("fileManager.compressingFiles", {
|
||||
count: fileNames.length,
|
||||
name: archiveName,
|
||||
}),
|
||||
);
|
||||
|
||||
await compressSSHFiles(
|
||||
sshSessionId,
|
||||
paths,
|
||||
archiveName,
|
||||
format,
|
||||
currentHost?.id,
|
||||
currentHost?.userId?.toString(),
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("fileManager.filesCompressedSuccessfully", {
|
||||
name: archiveName,
|
||||
}),
|
||||
);
|
||||
|
||||
handleRefreshDirectory();
|
||||
clearSelection();
|
||||
} catch (error: unknown) {
|
||||
const err = error as { message?: string };
|
||||
toast.error(
|
||||
`${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUndo() {
|
||||
if (undoHistory.length === 0) {
|
||||
toast.info(t("fileManager.noUndoableActions"));
|
||||
@@ -1180,6 +1266,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
setEditingFile(file);
|
||||
}
|
||||
|
||||
function handleOpenPermissionsDialog(file: FileItem) {
|
||||
setPermissionsDialogFile(file);
|
||||
}
|
||||
|
||||
async function handleSavePermissions(file: FileItem, permissions: string) {
|
||||
if (!sshSessionId) {
|
||||
toast.error(t("fileManager.noSSHConnection"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await changeSSHPermissions(
|
||||
sshSessionId,
|
||||
file.path,
|
||||
permissions,
|
||||
currentHost?.id,
|
||||
currentHost?.userId?.toString(),
|
||||
);
|
||||
|
||||
toast.success(t("fileManager.permissionsChangedSuccessfully"));
|
||||
await handleRefreshDirectory();
|
||||
} catch (error: unknown) {
|
||||
console.error("Failed to change permissions:", error);
|
||||
toast.error(t("fileManager.failedToChangePermissions"));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureSSHConnection() {
|
||||
if (!sshSessionId || !currentHost || isReconnecting) return;
|
||||
|
||||
@@ -1775,6 +1889,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}
|
||||
}, [currentHost?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("Saving viewMode to localStorage:", viewMode);
|
||||
localStorage.setItem("fileManagerViewMode", viewMode);
|
||||
console.log("Saved value:", localStorage.getItem("fileManagerViewMode"));
|
||||
}, [viewMode]);
|
||||
|
||||
const filteredFiles = files.filter((file) =>
|
||||
file.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
);
|
||||
@@ -1928,6 +2048,8 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
createIntent={createIntent}
|
||||
onConfirmCreate={handleConfirmCreate}
|
||||
onCancelCreate={handleCancelCreate}
|
||||
onNewFile={handleCreateNewFile}
|
||||
onNewFolder={handleCreateNewFolder}
|
||||
/>
|
||||
|
||||
<FileManagerContextMenu
|
||||
@@ -1966,10 +2088,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
onAddShortcut={handleAddShortcut}
|
||||
isPinned={isPinnedFile}
|
||||
currentPath={currentPath}
|
||||
onProperties={handleOpenPermissionsDialog}
|
||||
onExtractArchive={handleExtractArchive}
|
||||
onCompress={handleOpenCompressDialog}
|
||||
onCopyPath={handleCopyPath}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CompressDialog
|
||||
open={compressDialogFiles.length > 0}
|
||||
onOpenChange={(open) => !open && setCompressDialogFiles([])}
|
||||
fileNames={compressDialogFiles.map((f) => f.name)}
|
||||
onCompress={handleCompress}
|
||||
/>
|
||||
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
@@ -1991,6 +2124,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PermissionsDialog
|
||||
file={permissionsDialogFile}
|
||||
open={permissionsDialogFile !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPermissionsDialogFile(null);
|
||||
}}
|
||||
onSave={handleSavePermissions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ import {
|
||||
Play,
|
||||
Star,
|
||||
Bookmark,
|
||||
FileArchive,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Kbd, KbdGroup } from "@/components/ui/kbd";
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -59,6 +61,9 @@ interface ContextMenuProps {
|
||||
onAddShortcut?: (path: string) => void;
|
||||
isPinned?: (file: FileItem) => boolean;
|
||||
currentPath?: string;
|
||||
onExtractArchive?: (file: FileItem) => void;
|
||||
onCompress?: (files: FileItem[]) => void;
|
||||
onCopyPath?: (files: FileItem[]) => void;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
@@ -98,12 +103,21 @@ export function FileManagerContextMenu({
|
||||
onAddShortcut,
|
||||
isPinned,
|
||||
currentPath,
|
||||
onExtractArchive,
|
||||
onCompress,
|
||||
onCopyPath,
|
||||
}: ContextMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const [menuPosition, setMenuPosition] = useState({ x, y });
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (!isVisible) {
|
||||
setIsMounted(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMounted(true);
|
||||
|
||||
const adjustPosition = () => {
|
||||
const menuWidth = 200;
|
||||
@@ -182,8 +196,6 @@ export function FileManagerContextMenu({
|
||||
};
|
||||
}, [isVisible, x, y, onClose]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const isFileContext = files.length > 0;
|
||||
const isSingleFile = files.length === 1;
|
||||
const isMultipleFiles = files.length > 1;
|
||||
@@ -249,6 +261,43 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
if (isSingleFile && files[0].type === "file" && onExtractArchive) {
|
||||
const fileName = files[0].name.toLowerCase();
|
||||
const isArchive =
|
||||
fileName.endsWith(".zip") ||
|
||||
fileName.endsWith(".tar") ||
|
||||
fileName.endsWith(".tar.gz") ||
|
||||
fileName.endsWith(".tgz") ||
|
||||
fileName.endsWith(".tar.bz2") ||
|
||||
fileName.endsWith(".tbz2") ||
|
||||
fileName.endsWith(".tar.xz") ||
|
||||
fileName.endsWith(".gz") ||
|
||||
fileName.endsWith(".bz2") ||
|
||||
fileName.endsWith(".xz") ||
|
||||
fileName.endsWith(".7z") ||
|
||||
fileName.endsWith(".rar");
|
||||
|
||||
if (isArchive) {
|
||||
menuItems.push({
|
||||
icon: <FileArchive className="w-4 h-4" />,
|
||||
label: t("fileManager.extractArchive"),
|
||||
action: () => onExtractArchive(files[0]),
|
||||
shortcut: "Ctrl+E",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (isFileContext && onCompress) {
|
||||
menuItems.push({
|
||||
icon: <FileArchive className="w-4 h-4" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.compressFiles")
|
||||
: t("fileManager.compressFile"),
|
||||
action: () => onCompress(files),
|
||||
shortcut: "Ctrl+Shift+C",
|
||||
});
|
||||
}
|
||||
|
||||
if (isSingleFile && files[0].type === "file") {
|
||||
const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false;
|
||||
|
||||
@@ -316,7 +365,30 @@ export function FileManagerContextMenu({
|
||||
});
|
||||
}
|
||||
|
||||
if ((isSingleFile && onRename) || onCopy || onCut) {
|
||||
if (onCopyPath) {
|
||||
menuItems.push({
|
||||
icon: <Clipboard className="w-4 h-4" />,
|
||||
label: isMultipleFiles
|
||||
? t("fileManager.copyPaths")
|
||||
: t("fileManager.copyPath"),
|
||||
action: () => onCopyPath(files),
|
||||
shortcut: "Ctrl+Shift+P",
|
||||
});
|
||||
}
|
||||
|
||||
if ((isSingleFile && onRename) || onCopy || onCut || onCopyPath) {
|
||||
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]),
|
||||
});
|
||||
}
|
||||
|
||||
if ((isSingleFile && onProperties) || onDelete) {
|
||||
menuItems.push({ separator: true } as MenuItem);
|
||||
}
|
||||
|
||||
@@ -331,18 +403,6 @@ export function FileManagerContextMenu({
|
||||
danger: true,
|
||||
});
|
||||
}
|
||||
|
||||
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]),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (onOpenTerminal && currentPath) {
|
||||
menuItems.push({
|
||||
@@ -425,13 +485,36 @@ export function FileManagerContextMenu({
|
||||
return index > 0 && index < filteredMenuItems.length - 1;
|
||||
});
|
||||
|
||||
const renderShortcut = (shortcut: string) => {
|
||||
const keys = shortcut.split("+");
|
||||
if (keys.length === 1) {
|
||||
return <Kbd>{keys[0]}</Kbd>;
|
||||
}
|
||||
return (
|
||||
<KbdGroup>
|
||||
{keys.map((key, index) => (
|
||||
<Kbd key={index}>{key}</Kbd>
|
||||
))}
|
||||
</KbdGroup>
|
||||
);
|
||||
};
|
||||
|
||||
if (!isVisible && !isMounted) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[99990]" />
|
||||
<div
|
||||
className={cn(
|
||||
"fixed inset-0 z-[99990] transition-opacity duration-150",
|
||||
!isMounted && "opacity-0",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
data-context-menu
|
||||
className="fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden"
|
||||
className={cn(
|
||||
"fixed bg-dark-bg border border-dark-border rounded-lg shadow-xl min-w-[180px] max-w-[250px] z-[99995] overflow-hidden",
|
||||
)}
|
||||
style={{
|
||||
left: menuPosition.x,
|
||||
top: menuPosition.y,
|
||||
@@ -470,9 +553,9 @@ export function FileManagerContextMenu({
|
||||
<span className="flex-1">{item.label}</span>
|
||||
</div>
|
||||
{item.shortcut && (
|
||||
<span className="text-xs text-muted-foreground ml-2 flex-shrink-0">
|
||||
{item.shortcut}
|
||||
</span>
|
||||
<div className="ml-2 flex-shrink-0">
|
||||
{renderShortcut(item.shortcut)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem } from "../../../types/index.js";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface CreateIntent {
|
||||
id: string;
|
||||
@@ -92,17 +93,37 @@ interface FileManagerGridProps {
|
||||
createIntent?: CreateIntent | null;
|
||||
onConfirmCreate?: (name: string) => void;
|
||||
onCancelCreate?: () => void;
|
||||
onNewFile?: () => void;
|
||||
onNewFolder?: () => void;
|
||||
}
|
||||
|
||||
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6";
|
||||
const getFileTypeColor = (file: FileItem): string => {
|
||||
const colorEnabled = localStorage.getItem("fileColorCoding") !== "false";
|
||||
if (!colorEnabled) {
|
||||
return "text-muted-foreground";
|
||||
}
|
||||
|
||||
if (file.type === "directory") {
|
||||
return <Folder className={`${iconClass} text-muted-foreground`} />;
|
||||
return "text-red-400";
|
||||
}
|
||||
|
||||
if (file.type === "link") {
|
||||
return <FileSymlink className={`${iconClass} text-muted-foreground`} />;
|
||||
return "text-green-400";
|
||||
}
|
||||
|
||||
return "text-blue-400";
|
||||
};
|
||||
|
||||
const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||
const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6";
|
||||
const colorClass = getFileTypeColor(file);
|
||||
|
||||
if (file.type === "directory") {
|
||||
return <Folder className={`${iconClass} ${colorClass}`} />;
|
||||
}
|
||||
|
||||
if (file.type === "link") {
|
||||
return <FileSymlink className={`${iconClass} ${colorClass}`} />;
|
||||
}
|
||||
|
||||
const ext = file.name.split(".").pop()?.toLowerCase();
|
||||
@@ -111,30 +132,30 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||
case "txt":
|
||||
case "md":
|
||||
case "readme":
|
||||
return <FileText className={`${iconClass} text-muted-foreground`} />;
|
||||
return <FileText className={`${iconClass} ${colorClass}`} />;
|
||||
case "png":
|
||||
case "jpg":
|
||||
case "jpeg":
|
||||
case "gif":
|
||||
case "bmp":
|
||||
case "svg":
|
||||
return <FileImage className={`${iconClass} text-muted-foreground`} />;
|
||||
return <FileImage className={`${iconClass} ${colorClass}`} />;
|
||||
case "mp4":
|
||||
case "avi":
|
||||
case "mkv":
|
||||
case "mov":
|
||||
return <FileVideo className={`${iconClass} text-muted-foreground`} />;
|
||||
return <FileVideo className={`${iconClass} ${colorClass}`} />;
|
||||
case "mp3":
|
||||
case "wav":
|
||||
case "flac":
|
||||
case "ogg":
|
||||
return <FileAudio className={`${iconClass} text-muted-foreground`} />;
|
||||
return <FileAudio className={`${iconClass} ${colorClass}`} />;
|
||||
case "zip":
|
||||
case "tar":
|
||||
case "gz":
|
||||
case "rar":
|
||||
case "7z":
|
||||
return <Archive className={`${iconClass} text-muted-foreground`} />;
|
||||
return <Archive className={`${iconClass} ${colorClass}`} />;
|
||||
case "js":
|
||||
case "ts":
|
||||
case "jsx":
|
||||
@@ -148,7 +169,7 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||
case "rb":
|
||||
case "go":
|
||||
case "rs":
|
||||
return <Code className={`${iconClass} text-muted-foreground`} />;
|
||||
return <Code className={`${iconClass} ${colorClass}`} />;
|
||||
case "json":
|
||||
case "xml":
|
||||
case "yaml":
|
||||
@@ -157,9 +178,9 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => {
|
||||
case "ini":
|
||||
case "conf":
|
||||
case "config":
|
||||
return <Settings className={`${iconClass} text-muted-foreground`} />;
|
||||
return <Settings className={`${iconClass} ${colorClass}`} />;
|
||||
default:
|
||||
return <File className={`${iconClass} text-muted-foreground`} />;
|
||||
return <File className={`${iconClass} ${colorClass}`} />;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -192,6 +213,8 @@ export function FileManagerGrid({
|
||||
createIntent,
|
||||
onConfirmCreate,
|
||||
onCancelCreate,
|
||||
onNewFile,
|
||||
onNewFolder,
|
||||
}: FileManagerGridProps) {
|
||||
const { t } = useTranslation();
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
@@ -772,6 +795,42 @@ export function FileManagerGrid({
|
||||
onUndo();
|
||||
}
|
||||
break;
|
||||
case "d":
|
||||
case "D":
|
||||
if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
selectedFiles.length > 0 &&
|
||||
onDownload
|
||||
) {
|
||||
event.preventDefault();
|
||||
onDownload(selectedFiles);
|
||||
}
|
||||
break;
|
||||
case "n":
|
||||
case "N":
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
if (event.shiftKey && onNewFolder) {
|
||||
onNewFolder();
|
||||
} else if (!event.shiftKey && onNewFile) {
|
||||
onNewFile();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "u":
|
||||
case "U":
|
||||
if ((event.ctrlKey || event.metaKey) && onUpload) {
|
||||
event.preventDefault();
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.onchange = (e) => {
|
||||
const files = (e.target as HTMLInputElement).files;
|
||||
if (files) onUpload(files);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
break;
|
||||
case "Delete":
|
||||
if (selectedFiles.length > 0 && onDelete) {
|
||||
onDelete(selectedFiles);
|
||||
@@ -783,6 +842,12 @@ export function FileManagerGrid({
|
||||
onStartEdit(selectedFiles[0]);
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
if (selectedFiles.length === 1) {
|
||||
event.preventDefault();
|
||||
onFileOpen(selectedFiles[0]);
|
||||
}
|
||||
break;
|
||||
case "y":
|
||||
case "Y":
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
@@ -807,19 +872,8 @@ export function FileManagerGrid({
|
||||
onUndo,
|
||||
]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<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("common.loading")}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col bg-dark-bg overflow-hidden">
|
||||
<div className="h-full flex flex-col bg-dark-bg overflow-hidden relative">
|
||||
<div className="flex-shrink-0 border-b border-dark-border">
|
||||
<div className="flex items-center gap-1 p-2 border-b border-dark-border">
|
||||
<button
|
||||
@@ -950,7 +1004,7 @@ export function FileManagerGrid({
|
||||
tabIndex={0}
|
||||
>
|
||||
{dragState.type === "external" && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm z-10 pointer-events-none animate-in fade-in-0">
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/50 backdrop-blur-sm z-10 pointer-events-none">
|
||||
<div className="text-center p-8 bg-background/95 border-2 border-dashed border-primary rounded-lg shadow-lg">
|
||||
<Upload className="w-16 h-16 mx-auto mb-4 text-primary" />
|
||||
<p className="text-xl font-semibold text-foreground mb-2">
|
||||
@@ -1003,8 +1057,9 @@ export function FileManagerGrid({
|
||||
draggable={true}
|
||||
className={cn(
|
||||
"group p-3 rounded-lg cursor-pointer",
|
||||
"hover:bg-accent hover:text-accent-foreground border-2 border-transparent",
|
||||
isSelected && "bg-primary/20 border-primary",
|
||||
"hover:bg-accent hover:text-accent-foreground hover:scale-[1.02] border-2 border-transparent",
|
||||
isSelected &&
|
||||
"bg-primary/20 border-primary ring-2 ring-primary/20",
|
||||
dragState.target?.path === file.path &&
|
||||
"bg-muted border-primary border-dashed relative z-10",
|
||||
dragState.files.some((f) => f.path === file.path) &&
|
||||
@@ -1093,7 +1148,7 @@ export function FileManagerGrid({
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-2 rounded cursor-pointer",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
isSelected && "bg-primary/20",
|
||||
isSelected && "bg-primary/20 ring-2 ring-primary/20",
|
||||
dragState.target?.path === file.path &&
|
||||
"bg-muted border-primary border-dashed relative z-10",
|
||||
dragState.files.some((f) => f.path === file.path) &&
|
||||
@@ -1264,6 +1319,8 @@ export function FileManagerGrid({
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
<SimpleLoader visible={isLoading} message={t("common.loading")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
158
src/ui/desktop/apps/file-manager/components/CompressDialog.tsx
Normal file
158
src/ui/desktop/apps/file-manager/components/CompressDialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
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-dark-bg border-2 border-dark-border">
|
||||
<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-dark-hover/50 border border-dark-border p-3">
|
||||
<p className="text-sm text-gray-400 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-gray-400 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,318 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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-dark-bg border-2 border-dark-border">
|
||||
<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-gray-400">
|
||||
{t("fileManager.currentPermissions")}
|
||||
</Label>
|
||||
<p className="font-mono text-lg mt-1">
|
||||
{file.permissions || "644"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-gray-400">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,8 @@ export function HostManager({
|
||||
isTopbarOpen,
|
||||
initialTab = "host_viewer",
|
||||
hostConfig,
|
||||
rightSidebarOpen = false,
|
||||
rightSidebarWidth = 400,
|
||||
}: HostManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
@@ -35,28 +37,10 @@ export function HostManager({
|
||||
const lastProcessedHostIdRef = useRef<number | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (ignoreNextHostConfigChangeRef.current) {
|
||||
ignoreNextHostConfigChangeRef.current = false;
|
||||
return;
|
||||
if (initialTab) {
|
||||
setActiveTab(initialTab);
|
||||
}
|
||||
|
||||
if (hostConfig && initialTab === "add_host") {
|
||||
const currentHostId = hostConfig.id;
|
||||
|
||||
if (currentHostId !== lastProcessedHostIdRef.current) {
|
||||
setEditingHost(hostConfig);
|
||||
setActiveTab("add_host");
|
||||
lastProcessedHostIdRef.current = currentHostId;
|
||||
} else if (
|
||||
activeTab === "host_viewer" ||
|
||||
activeTab === "credentials" ||
|
||||
activeTab === "add_credential"
|
||||
) {
|
||||
setEditingHost(hostConfig);
|
||||
setActiveTab("add_host");
|
||||
}
|
||||
}
|
||||
}, [hostConfig, initialTab]);
|
||||
}, [initialTab]);
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
@@ -88,13 +72,13 @@ export function HostManager({
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
if (value !== "add_host") {
|
||||
if (activeTab === "add_host" && value !== "add_host") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
if (value !== "add_credential") {
|
||||
if (activeTab === "add_credential" && value !== "add_credential") {
|
||||
setEditingCredential(null);
|
||||
}
|
||||
setActiveTab(value);
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
@@ -108,10 +92,14 @@ export function HostManager({
|
||||
className="bg-dark-bg text-white p-4 pt-0 rounded-lg border-2 border-dark-border flex flex-col min-h-0 overflow-hidden"
|
||||
style={{
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginRight: rightSidebarOpen
|
||||
? `calc(var(--right-sidebar-width, ${rightSidebarWidth}px) + 8px)`
|
||||
: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
transition:
|
||||
"margin-left 200ms linear, margin-right 200ms linear, margin-top 200ms linear",
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,10 +22,15 @@ import {
|
||||
updateSSHHost,
|
||||
renameFolder,
|
||||
exportSSHHostWithCredentials,
|
||||
getSSHFolders,
|
||||
updateFolderMetadata,
|
||||
deleteAllHostsInFolder,
|
||||
getServerStatusById,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
@@ -45,16 +50,31 @@ import {
|
||||
Copy,
|
||||
Activity,
|
||||
Clock,
|
||||
Palette,
|
||||
Trash,
|
||||
Cloud,
|
||||
Database,
|
||||
Box,
|
||||
Package,
|
||||
Layers,
|
||||
Archive,
|
||||
HardDrive,
|
||||
Globe,
|
||||
FolderOpen,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
SSHHost,
|
||||
SSHFolder,
|
||||
SSHManagerHostViewerProps,
|
||||
} from "../../../../types/index.js";
|
||||
import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets";
|
||||
import { FolderEditDialog } from "./components/FolderEditDialog";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext";
|
||||
|
||||
export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { addTab } = useTabs();
|
||||
const [hosts, setHosts] = useState<SSHHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -65,23 +85,38 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||
const [editingFolderName, setEditingFolderName] = useState("");
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
const [folderMetadata, setFolderMetadata] = useState<Map<string, SSHFolder>>(
|
||||
new Map(),
|
||||
);
|
||||
const [editingFolderAppearance, setEditingFolderAppearance] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [serverStatuses, setServerStatuses] = useState<
|
||||
Map<number, "online" | "offline" | "degraded">
|
||||
>(new Map());
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
fetchFolderMetadata();
|
||||
|
||||
const handleHostsRefresh = () => {
|
||||
fetchHosts();
|
||||
fetchFolderMetadata();
|
||||
};
|
||||
|
||||
const handleFoldersRefresh = () => {
|
||||
fetchFolderMetadata();
|
||||
};
|
||||
|
||||
window.addEventListener("hosts:refresh", handleHostsRefresh);
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsRefresh);
|
||||
window.addEventListener("folders:changed", handleHostsRefresh);
|
||||
window.addEventListener("folders:changed", handleFoldersRefresh);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("hosts:refresh", handleHostsRefresh);
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsRefresh);
|
||||
window.removeEventListener("folders:changed", handleHostsRefresh);
|
||||
window.removeEventListener("folders:changed", handleFoldersRefresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -116,6 +151,156 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchFolderMetadata = async () => {
|
||||
try {
|
||||
const folders = await getSSHFolders();
|
||||
const metadataMap = new Map<string, SSHFolder>();
|
||||
folders.forEach((folder) => {
|
||||
metadataMap.set(folder.name, folder);
|
||||
});
|
||||
setFolderMetadata(metadataMap);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch folder metadata:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveFolderAppearance = async (
|
||||
folderName: string,
|
||||
color: string,
|
||||
icon: string,
|
||||
) => {
|
||||
try {
|
||||
await updateFolderMetadata(folderName, color, icon);
|
||||
toast.success(t("hosts.folderAppearanceUpdated"));
|
||||
await fetchFolderMetadata();
|
||||
window.dispatchEvent(new CustomEvent("folders:changed"));
|
||||
} catch (error) {
|
||||
console.error("Failed to update folder appearance:", error);
|
||||
toast.error(t("hosts.failedToUpdateFolderAppearance"));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAllHostsInFolder = async (folderName: string) => {
|
||||
const hostsInFolder = hostsByFolder[folderName] || [];
|
||||
confirmWithToast(
|
||||
t("hosts.confirmDeleteAllHostsInFolder", {
|
||||
folder: folderName,
|
||||
count: hostsInFolder.length,
|
||||
}),
|
||||
async () => {
|
||||
try {
|
||||
const result = await deleteAllHostsInFolder(folderName);
|
||||
toast.success(
|
||||
t("hosts.allHostsInFolderDeleted", {
|
||||
folder: folderName,
|
||||
count: result.deletedCount,
|
||||
}),
|
||||
);
|
||||
await fetchHosts();
|
||||
await fetchFolderMetadata();
|
||||
window.dispatchEvent(new CustomEvent("ssh-hosts:changed"));
|
||||
} catch (error) {
|
||||
console.error("Failed to delete hosts in folder:", error);
|
||||
toast.error(t("hosts.failedToDeleteHostsInFolder"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
const statusIntervals: NodeJS.Timeout[] = [];
|
||||
const statusCancelled: boolean[] = [];
|
||||
|
||||
hosts.forEach((host, index) => {
|
||||
const statsConfig = (() => {
|
||||
try {
|
||||
return host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
: DEFAULT_STATS_CONFIG;
|
||||
} catch {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
})();
|
||||
|
||||
const shouldShowStatus = statsConfig.statusCheckEnabled !== false;
|
||||
|
||||
if (!shouldShowStatus) {
|
||||
setServerStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(host.id, "offline");
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(host.id);
|
||||
if (!statusCancelled[index]) {
|
||||
setServerStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(
|
||||
host.id,
|
||||
res?.status === "online" ? "online" : "offline",
|
||||
);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (!statusCancelled[index]) {
|
||||
const err = error as { response?: { status?: number } };
|
||||
let status: "online" | "offline" | "degraded" = "offline";
|
||||
if (err?.response?.status === 504) {
|
||||
status = "degraded";
|
||||
}
|
||||
setServerStatuses((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(host.id, status);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchStatus();
|
||||
const intervalId = setInterval(fetchStatus, 10000);
|
||||
statusIntervals.push(intervalId);
|
||||
});
|
||||
|
||||
return () => {
|
||||
statusCancelled.fill(true);
|
||||
statusIntervals.forEach((interval) => clearInterval(interval));
|
||||
};
|
||||
}, [hosts]);
|
||||
|
||||
const getFolderIcon = (folderName: string) => {
|
||||
const metadata = folderMetadata.get(folderName);
|
||||
if (!metadata?.icon) return Folder;
|
||||
|
||||
const iconMap: Record<string, React.ComponentType> = {
|
||||
Folder,
|
||||
Server,
|
||||
Cloud,
|
||||
Database,
|
||||
Box,
|
||||
Package,
|
||||
Layers,
|
||||
Archive,
|
||||
HardDrive,
|
||||
Globe,
|
||||
};
|
||||
|
||||
return iconMap[metadata.icon] || Folder;
|
||||
};
|
||||
|
||||
const getFolderColor = (folderName: string) => {
|
||||
const metadata = folderMetadata.get(folderName);
|
||||
return metadata?.color;
|
||||
};
|
||||
|
||||
const handleDelete = async (hostId: number, hostName: string) => {
|
||||
confirmWithToast(
|
||||
t("hosts.confirmDelete", { name: hostName }),
|
||||
@@ -854,7 +1039,18 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<AccordionItem value={folder} className="border-none">
|
||||
<AccordionTrigger className="px-2 py-1 bg-muted/20 border-b hover:no-underline rounded-t-md">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Folder className="h-4 w-4" />
|
||||
{(() => {
|
||||
const FolderIcon = getFolderIcon(folder);
|
||||
const folderColor = getFolderColor(folder);
|
||||
return (
|
||||
<FolderIcon
|
||||
className="h-4 w-4"
|
||||
style={
|
||||
folderColor ? { color: folderColor } : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
{editingFolder === folder ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
@@ -935,6 +1131,50 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderHosts.length}
|
||||
</Badge>
|
||||
{folder !== t("hosts.uncategorized") && (
|
||||
<div className="flex items-center gap-1 ml-auto">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingFolderAppearance(folder);
|
||||
}}
|
||||
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Palette className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("hosts.editFolderAppearance")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDeleteAllHostsInFolder(folder);
|
||||
}}
|
||||
className="h-6 w-6 p-0 opacity-50 hover:opacity-100 hover:text-red-400 transition-all"
|
||||
>
|
||||
<Trash className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("hosts.deleteAllHostsInFolder")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
@@ -957,6 +1197,32 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
{(() => {
|
||||
const statsConfig = (() => {
|
||||
try {
|
||||
return host.statsConfig
|
||||
? JSON.parse(host.statsConfig)
|
||||
: DEFAULT_STATS_CONFIG;
|
||||
} catch {
|
||||
return DEFAULT_STATS_CONFIG;
|
||||
}
|
||||
})();
|
||||
const shouldShowStatus =
|
||||
statsConfig.statusCheckEnabled !==
|
||||
false;
|
||||
const serverStatus =
|
||||
serverStatuses.get(host.id) ||
|
||||
"degraded";
|
||||
|
||||
return shouldShowStatus ? (
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
) : null;
|
||||
})()}
|
||||
{host.pin && (
|
||||
<Pin className="h-3 w-3 text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
@@ -971,6 +1237,9 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
ID: {host.id}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
{host.folder && host.folder !== "" && (
|
||||
@@ -1179,6 +1448,88 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 pt-3 border-t border-border/50 flex items-center justify-center gap-1">
|
||||
{host.enableTerminal && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "terminal",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-blue-500/10 hover:border-blue-500/50 flex-1"
|
||||
>
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Terminal</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{host.enableFileManager && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-emerald-500/10 hover:border-emerald-500/50 flex-1"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open File Manager</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const title = host.name?.trim()
|
||||
? host.name
|
||||
: `${host.username}@${host.ip}:${host.port}`;
|
||||
addTab({
|
||||
type: "server",
|
||||
title,
|
||||
hostConfig: host,
|
||||
});
|
||||
}}
|
||||
className="h-7 px-2 hover:bg-purple-500/10 hover:border-purple-500/50 flex-1"
|
||||
>
|
||||
<Server className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Open Server Details</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
@@ -1202,6 +1553,26 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{editingFolderAppearance && (
|
||||
<FolderEditDialog
|
||||
folderName={editingFolderAppearance}
|
||||
currentColor={getFolderColor(editingFolderAppearance)}
|
||||
currentIcon={folderMetadata.get(editingFolderAppearance)?.icon}
|
||||
open={editingFolderAppearance !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setEditingFolderAppearance(null);
|
||||
}}
|
||||
onSave={async (color, icon) => {
|
||||
await handleSaveFolderAppearance(
|
||||
editingFolderAppearance,
|
||||
color,
|
||||
icon,
|
||||
);
|
||||
setEditingFolderAppearance(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
191
src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx
Normal file
191
src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Folder,
|
||||
Server,
|
||||
Cloud,
|
||||
Database,
|
||||
Box,
|
||||
Package,
|
||||
Layers,
|
||||
Archive,
|
||||
HardDrive,
|
||||
Globe,
|
||||
} from "lucide-react";
|
||||
|
||||
interface FolderEditDialogProps {
|
||||
folderName: string;
|
||||
currentColor?: string;
|
||||
currentIcon?: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (color: string, icon: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const AVAILABLE_COLORS = [
|
||||
{ value: "#ef4444", label: "Red" },
|
||||
{ value: "#f97316", label: "Orange" },
|
||||
{ value: "#eab308", label: "Yellow" },
|
||||
{ value: "#22c55e", label: "Green" },
|
||||
{ value: "#3b82f6", label: "Blue" },
|
||||
{ value: "#a855f7", label: "Purple" },
|
||||
{ value: "#ec4899", label: "Pink" },
|
||||
{ value: "#6b7280", label: "Gray" },
|
||||
];
|
||||
|
||||
const AVAILABLE_ICONS = [
|
||||
{ value: "Folder", label: "Folder", Icon: Folder },
|
||||
{ value: "Server", label: "Server", Icon: Server },
|
||||
{ value: "Cloud", label: "Cloud", Icon: Cloud },
|
||||
{ value: "Database", label: "Database", Icon: Database },
|
||||
{ value: "Box", label: "Box", Icon: Box },
|
||||
{ value: "Package", label: "Package", Icon: Package },
|
||||
{ value: "Layers", label: "Layers", Icon: Layers },
|
||||
{ value: "Archive", label: "Archive", Icon: Archive },
|
||||
{ value: "HardDrive", label: "HardDrive", Icon: HardDrive },
|
||||
{ value: "Globe", label: "Globe", Icon: Globe },
|
||||
];
|
||||
|
||||
export function FolderEditDialog({
|
||||
folderName,
|
||||
currentColor,
|
||||
currentIcon,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: FolderEditDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedColor, setSelectedColor] = useState(
|
||||
currentColor || AVAILABLE_COLORS[0].value,
|
||||
);
|
||||
const [selectedIcon, setSelectedIcon] = useState(
|
||||
currentIcon || AVAILABLE_ICONS[0].value,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelectedColor(currentColor || AVAILABLE_COLORS[0].value);
|
||||
setSelectedIcon(currentIcon || AVAILABLE_ICONS[0].value);
|
||||
}
|
||||
}, [open, currentColor, currentIcon]);
|
||||
|
||||
const handleSave = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onSave(selectedColor, selectedIcon);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save folder metadata:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-dark-bg border-2 border-dark-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5" />
|
||||
{t("hosts.editFolderAppearance")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{t("hosts.editFolderAppearanceDesc")}:{" "}
|
||||
<span className="font-mono text-foreground">{folderName}</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("hosts.folderColor")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{AVAILABLE_COLORS.map((color) => (
|
||||
<button
|
||||
key={color.value}
|
||||
type="button"
|
||||
className={`h-12 rounded-md border-2 transition-all hover:scale-105 ${
|
||||
selectedColor === color.value
|
||||
? "border-white shadow-lg scale-105"
|
||||
: "border-dark-border"
|
||||
}`}
|
||||
style={{ backgroundColor: color.value }}
|
||||
onClick={() => setSelectedColor(color.value)}
|
||||
title={color.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("hosts.folderIcon")}
|
||||
</Label>
|
||||
<div className="grid grid-cols-5 gap-3">
|
||||
{AVAILABLE_ICONS.map(({ value, label, Icon }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`h-14 rounded-md border-2 transition-all hover:scale-105 flex items-center justify-center ${
|
||||
selectedIcon === value
|
||||
? "border-primary bg-primary/10"
|
||||
: "border-dark-border bg-dark-bg-darker"
|
||||
}`}
|
||||
onClick={() => setSelectedIcon(value)}
|
||||
title={label}
|
||||
>
|
||||
<Icon className="w-6 h-6" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold text-foreground">
|
||||
{t("hosts.preview")}
|
||||
</Label>
|
||||
<div className="flex items-center gap-3 p-4 rounded-md bg-dark-bg-darker border border-dark-border">
|
||||
{(() => {
|
||||
const IconComponent =
|
||||
AVAILABLE_ICONS.find((i) => i.value === selectedIcon)?.Icon ||
|
||||
Folder;
|
||||
return (
|
||||
<IconComponent
|
||||
className="w-5 h-5"
|
||||
style={{ color: selectedColor }}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<span className="font-medium">{folderName}</span>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { Tunnel } from "@/ui/desktop/apps/tunnel/Tunnel.tsx";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
executeSnippet,
|
||||
type ServerMetrics,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
@@ -25,7 +26,14 @@ import {
|
||||
UptimeWidget,
|
||||
ProcessesWidget,
|
||||
SystemWidget,
|
||||
LoginStatsWidget,
|
||||
} from "./widgets";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface QuickAction {
|
||||
name: string;
|
||||
snippetId: number;
|
||||
}
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
@@ -35,6 +43,7 @@ interface HostConfig {
|
||||
folder?: string;
|
||||
enableFileManager?: boolean;
|
||||
tunnelConnections?: unknown[];
|
||||
quickActions?: QuickAction[];
|
||||
statsConfig?: string | StatsConfig;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
@@ -79,6 +88,9 @@ export function Server({
|
||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [showStatsUI, setShowStatsUI] = React.useState(true);
|
||||
const [executingActions, setExecutingActions] = React.useState<Set<number>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const statsConfig = React.useMemo((): StatsConfig => {
|
||||
if (!currentHostConfig?.statsConfig) {
|
||||
@@ -101,8 +113,14 @@ export function Server({
|
||||
const metricsEnabled = statsConfig.metricsEnabled !== false;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setServerStatus("offline");
|
||||
setMetrics(null);
|
||||
setMetricsHistory([]);
|
||||
setShowStatsUI(true);
|
||||
}
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig]);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
const renderWidget = (widgetType: WidgetType) => {
|
||||
switch (widgetType) {
|
||||
@@ -137,6 +155,11 @@ export function Server({
|
||||
<SystemWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
case "login_stats":
|
||||
return (
|
||||
<LoginStatsWidget metrics={metrics} metricsHistory={metricsHistory} />
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -436,42 +459,147 @@ export function Server({
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{metricsEnabled && showStatsUI && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 max-h-[50vh] overflow-y-auto">
|
||||
{isLoadingMetrics && !metrics ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">
|
||||
{t("serverStats.loadingMetrics")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : !metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
{(metricsEnabled && showStatsUI) ||
|
||||
(currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0) ? (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4 overflow-y-auto relative flex-1 flex flex-col">
|
||||
{currentHostConfig?.quickActions &&
|
||||
currentHostConfig.quickActions.length > 0 && (
|
||||
<div className={metricsEnabled && showStatsUI ? "mb-4" : ""}>
|
||||
<h3 className="text-sm font-semibold text-gray-400 mb-2">
|
||||
{t("serverStats.quickActions")}
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentHostConfig.quickActions.map((action, index) => {
|
||||
const isExecuting = executingActions.has(
|
||||
action.snippetId,
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="font-semibold"
|
||||
disabled={isExecuting}
|
||||
onClick={async () => {
|
||||
if (!currentHostConfig) return;
|
||||
|
||||
setExecutingActions((prev) =>
|
||||
new Set(prev).add(action.snippetId),
|
||||
);
|
||||
toast.loading(
|
||||
t("serverStats.executingQuickAction", {
|
||||
name: action.name,
|
||||
}),
|
||||
{ id: `quick-action-${action.snippetId}` },
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await executeSnippet(
|
||||
action.snippetId,
|
||||
currentHostConfig.id,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
toast.success(
|
||||
t("serverStats.quickActionSuccess", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description: result.output
|
||||
? result.output.substring(0, 200)
|
||||
: undefined,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(
|
||||
t("serverStats.quickActionFailed", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
result.error ||
|
||||
result.output ||
|
||||
undefined,
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
t("serverStats.quickActionError", {
|
||||
name: action.name,
|
||||
}),
|
||||
{
|
||||
id: `quick-action-${action.snippetId}`,
|
||||
description:
|
||||
error?.message || "Unknown error",
|
||||
duration: 5000,
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setExecutingActions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(action.snippetId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}}
|
||||
title={t("serverStats.executeQuickAction", {
|
||||
name: action.name,
|
||||
})}
|
||||
>
|
||||
{isExecuting ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-3 h-3 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||
{action.name}
|
||||
</div>
|
||||
) : (
|
||||
action.name
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{enabledWidgets.map((widgetType) => (
|
||||
<div key={widgetType} className="h-[280px]">
|
||||
{renderWidget(widgetType)}
|
||||
)}
|
||||
{metricsEnabled &&
|
||||
showStatsUI &&
|
||||
(!metrics && serverStatus === "offline" ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 mx-auto mb-3 rounded-full bg-red-500/20 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-red-400 rounded-full"></div>
|
||||
</div>
|
||||
<p className="text-gray-300 mb-1">
|
||||
{t("serverStats.serverOffline")}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
{t("serverStats.cannotFetchMetrics")}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{enabledWidgets.map((widgetType) => (
|
||||
<div key={widgetType} className="h-[280px]">
|
||||
{renderWidget(widgetType)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{metricsEnabled && showStatsUI && (
|
||||
<SimpleLoader
|
||||
visible={isLoadingMetrics && !metrics}
|
||||
message={t("serverStats.loadingMetrics")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 && (
|
||||
@@ -487,19 +615,6 @@ export function Server({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||
{t("serverStats.feedbackMessage")}{" "}
|
||||
<a
|
||||
href="https://github.com/Termix-SSH/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
142
src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx
Normal file
142
src/ui/desktop/apps/server/widgets/LoginStatsWidget.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React from "react";
|
||||
import { UserCheck, UserX, MapPin, Activity } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface LoginRecord {
|
||||
user: string;
|
||||
ip: string;
|
||||
time: string;
|
||||
status: "success" | "failed";
|
||||
}
|
||||
|
||||
interface LoginStatsMetrics {
|
||||
recentLogins: LoginRecord[];
|
||||
failedLogins: LoginRecord[];
|
||||
totalLogins: number;
|
||||
uniqueIPs: number;
|
||||
}
|
||||
|
||||
interface ServerMetrics {
|
||||
login_stats?: LoginStatsMetrics;
|
||||
}
|
||||
|
||||
interface LoginStatsWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const loginStats = metrics?.login_stats;
|
||||
const recentLogins = loginStats?.recentLogins || [];
|
||||
const failedLogins = loginStats?.failedLogins || [];
|
||||
const totalLogins = loginStats?.totalLogins || 0;
|
||||
const uniqueIPs = loginStats?.uniqueIPs || 0;
|
||||
|
||||
return (
|
||||
<div className="h-full w-full p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 flex-shrink-0 mb-3">
|
||||
<UserCheck className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.loginStats")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0 gap-3">
|
||||
<div className="grid grid-cols-2 gap-2 flex-shrink-0">
|
||||
<div className="bg-dark-bg-darker p-2 rounded border border-dark-border/30">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400 mb-1">
|
||||
<Activity className="h-3 w-3" />
|
||||
<span>{t("serverStats.totalLogins")}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-green-400">
|
||||
{totalLogins}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-dark-bg-darker p-2 rounded border border-dark-border/30">
|
||||
<div className="flex items-center gap-1 text-xs text-gray-400 mb-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
<span>{t("serverStats.uniqueIPs")}</span>
|
||||
</div>
|
||||
<div className="text-xl font-bold text-blue-400">{uniqueIPs}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto space-y-2">
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<UserCheck className="h-4 w-4 text-green-400" />
|
||||
<span className="text-sm font-semibold text-gray-300">
|
||||
{t("serverStats.recentSuccessfulLogins")}
|
||||
</span>
|
||||
</div>
|
||||
{recentLogins.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 italic p-2">
|
||||
{t("serverStats.noRecentLoginData")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{recentLogins.slice(0, 5).map((login, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs bg-dark-bg-darker p-2 rounded border border-dark-border/30 flex justify-between items-center"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-green-400 font-mono truncate">
|
||||
{login.user}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
{t("serverStats.from")}
|
||||
</span>
|
||||
<span className="text-blue-400 font-mono truncate">
|
||||
{login.ip}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500 text-[10px] flex-shrink-0 ml-2">
|
||||
{new Date(login.time).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{failedLogins.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<UserX className="h-4 w-4 text-red-400" />
|
||||
<span className="text-sm font-semibold text-gray-300">
|
||||
{t("serverStats.recentFailedAttempts")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{failedLogins.slice(0, 3).map((login, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs bg-red-900/20 p-2 rounded border border-red-500/30 flex justify-between items-center"
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-red-400 font-mono truncate">
|
||||
{login.user}
|
||||
</span>
|
||||
<span className="text-gray-500">
|
||||
{t("serverStats.from")}
|
||||
</span>
|
||||
<span className="text-blue-400 font-mono truncate">
|
||||
{login.ip}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-gray-500 text-[10px] flex-shrink-0 ml-2">
|
||||
{new Date(login.time).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export { NetworkWidget } from "./NetworkWidget";
|
||||
export { UptimeWidget } from "./UptimeWidget";
|
||||
export { ProcessesWidget } from "./ProcessesWidget";
|
||||
export { SystemWidget } from "./SystemWidget";
|
||||
export { LoginStatsWidget } from "./LoginStatsWidget";
|
||||
|
||||
@@ -1,480 +0,0 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Plus, Play, Edit, Trash2, Copy, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import {
|
||||
getSnippets,
|
||||
createSnippet,
|
||||
updateSnippet,
|
||||
deleteSnippet,
|
||||
} from "@/ui/main-axios";
|
||||
import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx";
|
||||
import type { Snippet, SnippetData } from "../../../../types/index.js";
|
||||
|
||||
interface TabData {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
terminalRef?: {
|
||||
current?: {
|
||||
sendInput?: (data: string) => void;
|
||||
};
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface SnippetsSidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onExecute: (content: string) => void;
|
||||
}
|
||||
|
||||
export function SnippetsSidebar({
|
||||
isOpen,
|
||||
onClose,
|
||||
onExecute,
|
||||
}: SnippetsSidebarProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const { tabs } = useTabs() as { tabs: TabData[] };
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingSnippet, setEditingSnippet] = useState<Snippet | null>(null);
|
||||
const [formData, setFormData] = useState<SnippetData>({
|
||||
name: "",
|
||||
content: "",
|
||||
description: "",
|
||||
});
|
||||
const [formErrors, setFormErrors] = useState({
|
||||
name: false,
|
||||
content: false,
|
||||
});
|
||||
const [selectedTabIds, setSelectedTabIds] = useState<number[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchSnippets();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchSnippets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getSnippets();
|
||||
setSnippets(Array.isArray(data) ? data : []);
|
||||
} catch {
|
||||
toast.error(t("snippets.failedToFetch"));
|
||||
setSnippets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditingSnippet(null);
|
||||
setFormData({ name: "", content: "", description: "" });
|
||||
setFormErrors({ name: false, content: false });
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleEdit = (snippet: Snippet) => {
|
||||
setEditingSnippet(snippet);
|
||||
setFormData({
|
||||
name: snippet.name,
|
||||
content: snippet.content,
|
||||
description: snippet.description || "",
|
||||
});
|
||||
setFormErrors({ name: false, content: false });
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleDelete = (snippet: Snippet) => {
|
||||
confirmWithToast(
|
||||
t("snippets.deleteConfirmDescription", { name: snippet.name }),
|
||||
async () => {
|
||||
try {
|
||||
await deleteSnippet(snippet.id);
|
||||
toast.success(t("snippets.deleteSuccess"));
|
||||
fetchSnippets();
|
||||
} catch {
|
||||
toast.error(t("snippets.deleteFailed"));
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const errors = {
|
||||
name: !formData.name.trim(),
|
||||
content: !formData.content.trim(),
|
||||
};
|
||||
|
||||
setFormErrors(errors);
|
||||
|
||||
if (errors.name || errors.content) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingSnippet) {
|
||||
await updateSnippet(editingSnippet.id, formData);
|
||||
toast.success(t("snippets.updateSuccess"));
|
||||
} else {
|
||||
await createSnippet(formData);
|
||||
toast.success(t("snippets.createSuccess"));
|
||||
}
|
||||
setShowDialog(false);
|
||||
fetchSnippets();
|
||||
} catch {
|
||||
toast.error(
|
||||
editingSnippet
|
||||
? t("snippets.updateFailed")
|
||||
: t("snippets.createFailed"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTabToggle = (tabId: number) => {
|
||||
setSelectedTabIds((prev) =>
|
||||
prev.includes(tabId)
|
||||
? prev.filter((id) => id !== tabId)
|
||||
: [...prev, tabId],
|
||||
);
|
||||
};
|
||||
|
||||
const handleExecute = (snippet: Snippet) => {
|
||||
if (selectedTabIds.length > 0) {
|
||||
selectedTabIds.forEach((tabId) => {
|
||||
const tab = tabs.find((t: TabData) => t.id === tabId);
|
||||
if (tab?.terminalRef?.current?.sendInput) {
|
||||
tab.terminalRef.current.sendInput(snippet.content + "\n");
|
||||
}
|
||||
});
|
||||
toast.success(
|
||||
t("snippets.executeSuccess", {
|
||||
name: snippet.name,
|
||||
count: selectedTabIds.length,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
onExecute(snippet.content);
|
||||
toast.success(t("snippets.executeSuccess", { name: snippet.name }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (snippet: Snippet) => {
|
||||
navigator.clipboard.writeText(snippet.content);
|
||||
toast.success(t("snippets.copySuccess", { name: snippet.name }));
|
||||
};
|
||||
|
||||
const terminalTabs = tabs.filter((tab: TabData) => tab.type === "terminal");
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="fixed top-0 left-0 right-0 bottom-0 z-[999999] flex justify-end pointer-events-auto isolate"
|
||||
style={{
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
>
|
||||
<div className="flex-1 cursor-pointer" onClick={onClose} />
|
||||
|
||||
<div
|
||||
className="w-[400px] h-full bg-dark-bg border-l-2 border-dark-border flex flex-col shadow-2xl relative isolate z-[999999]"
|
||||
style={{
|
||||
boxShadow: "-4px 0 20px rgba(0, 0, 0, 0.5)",
|
||||
transform: "translateZ(0)",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between p-4 border-b border-dark-border">
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{t("snippets.title")}
|
||||
</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 p-0 hover:bg-red-500 hover:text-white transition-colors flex items-center justify-center"
|
||||
title={t("common.close")}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-4">
|
||||
{terminalTabs.length > 0 && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{t("snippets.selectTerminals", {
|
||||
defaultValue: "Select Terminals (optional)",
|
||||
})}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{selectedTabIds.length > 0
|
||||
? t("snippets.executeOnSelected", {
|
||||
defaultValue: `Execute on ${selectedTabIds.length} selected terminal(s)`,
|
||||
count: selectedTabIds.length,
|
||||
})
|
||||
: t("snippets.executeOnCurrent", {
|
||||
defaultValue:
|
||||
"Execute on current terminal (click to select multiple)",
|
||||
})}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
||||
{terminalTabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={`rounded-full px-3 py-1 text-xs flex items-center gap-1 ${
|
||||
selectedTabIds.includes(tab.id)
|
||||
? "text-white bg-gray-700"
|
||||
: "text-gray-500"
|
||||
}`}
|
||||
onClick={() => handleTabToggle(tab.id)}
|
||||
>
|
||||
{tab.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="my-4" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
{t("snippets.new")}
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<p>{t("common.loading")}</p>
|
||||
</div>
|
||||
) : snippets.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
<p className="mb-2 font-medium">{t("snippets.empty")}</p>
|
||||
<p className="text-sm">{t("snippets.emptyHint")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipProvider>
|
||||
<div className="space-y-3">
|
||||
{snippets.map((snippet) => (
|
||||
<div
|
||||
key={snippet.id}
|
||||
className="bg-dark-bg-input border border-input rounded-lg cursor-pointer hover:shadow-lg hover:border-blue-400/50 hover:bg-dark-hover-alt transition-all duration-200 p-3 group"
|
||||
>
|
||||
<div className="mb-2">
|
||||
<h3 className="text-sm font-medium text-white mb-1">
|
||||
{snippet.name}
|
||||
</h3>
|
||||
{snippet.description && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{snippet.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded p-2 mb-3">
|
||||
<code className="text-xs font-mono break-all line-clamp-2 text-muted-foreground">
|
||||
{snippet.content}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="default"
|
||||
className="flex-1"
|
||||
onClick={() => handleExecute(snippet)}
|
||||
>
|
||||
<Play className="w-3 h-3 mr-1" />
|
||||
{t("snippets.run")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("snippets.runTooltip")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCopy(snippet)}
|
||||
>
|
||||
<Copy className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("snippets.copyTooltip")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(snippet)}
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("snippets.editTooltip")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDelete(snippet)}
|
||||
className="hover:bg-destructive hover:text-destructive-foreground"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{t("snippets.deleteTooltip")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showDialog && (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-[9999999] bg-black/50 backdrop-blur-sm"
|
||||
onClick={() => setShowDialog(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-6">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{editingSnippet
|
||||
? t("snippets.editDescription")
|
||||
: t("snippets.createDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white flex items-center gap-1">
|
||||
{t("snippets.name")}
|
||||
<span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder={t("snippets.namePlaceholder")}
|
||||
className={`${formErrors.name ? "border-destructive focus-visible:ring-destructive" : ""}`}
|
||||
autoFocus
|
||||
/>
|
||||
{formErrors.name && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{t("snippets.nameRequired")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white">
|
||||
{t("snippets.description")}
|
||||
<span className="text-muted-foreground ml-1">
|
||||
({t("common.optional")})
|
||||
</span>
|
||||
</label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, description: e.target.value })
|
||||
}
|
||||
placeholder={t("snippets.descriptionPlaceholder")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-white flex items-center gap-1">
|
||||
{t("snippets.content")}
|
||||
<span className="text-destructive">*</span>
|
||||
</label>
|
||||
<Textarea
|
||||
value={formData.content}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, content: e.target.value })
|
||||
}
|
||||
placeholder={t("snippets.contentPlaceholder")}
|
||||
className={`font-mono text-sm ${formErrors.content ? "border-destructive focus-visible:ring-destructive" : ""}`}
|
||||
rows={10}
|
||||
/>
|
||||
{formErrors.content && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{t("snippets.contentRequired")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="my-6" />
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDialog(false)}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} className="flex-1">
|
||||
{editingSnippet ? t("snippets.edit") : t("snippets.create")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
useState,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
@@ -26,6 +27,11 @@ import {
|
||||
TERMINAL_FONTS,
|
||||
} from "@/constants/terminal-themes";
|
||||
import type { TerminalConfig } from "@/types";
|
||||
import { useCommandTracker } from "@/ui/hooks/useCommandTracker";
|
||||
import { useCommandHistory as useCommandHistoryHook } from "@/ui/hooks/useCommandHistory";
|
||||
import { useCommandHistory } from "@/ui/desktop/apps/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
|
||||
import { SimpleLoader } from "@/ui/desktop/navigation/animations/SimpleLoader.tsx";
|
||||
|
||||
interface HostConfig {
|
||||
id?: number;
|
||||
@@ -85,6 +91,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const commandHistoryContext = useCommandHistory();
|
||||
|
||||
const config = { ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig };
|
||||
const themeColors =
|
||||
@@ -99,7 +106,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [isFitted, setIsFitted] = useState(false);
|
||||
const [isFitted, setIsFitted] = useState(true);
|
||||
const [, setConnectionError] = useState<string | null>(null);
|
||||
const [, setIsAuthenticated] = useState(false);
|
||||
const [totpRequired, setTotpRequired] = useState(false);
|
||||
@@ -122,11 +129,126 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const isConnectingRef = useRef(false);
|
||||
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const activityLoggedRef = useRef(false);
|
||||
const keyHandlerAttachedRef = useRef(false);
|
||||
|
||||
const { trackInput, getCurrentCommand, updateCurrentCommand } =
|
||||
useCommandTracker({
|
||||
hostId: hostConfig.id,
|
||||
enabled: true,
|
||||
onCommandExecuted: (command) => {
|
||||
if (!autocompleteHistory.current.includes(command)) {
|
||||
autocompleteHistory.current = [
|
||||
command,
|
||||
...autocompleteHistory.current,
|
||||
];
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const getCurrentCommandRef = useRef(getCurrentCommand);
|
||||
const updateCurrentCommandRef = useRef(updateCurrentCommand);
|
||||
|
||||
useEffect(() => {
|
||||
getCurrentCommandRef.current = getCurrentCommand;
|
||||
updateCurrentCommandRef.current = updateCurrentCommand;
|
||||
}, [getCurrentCommand, updateCurrentCommand]);
|
||||
|
||||
const [showAutocomplete, setShowAutocomplete] = useState(false);
|
||||
const [autocompleteSuggestions, setAutocompleteSuggestions] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] =
|
||||
useState(0);
|
||||
const [autocompletePosition, setAutocompletePosition] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
});
|
||||
const autocompleteHistory = useRef<string[]>([]);
|
||||
const currentAutocompleteCommand = useRef<string>("");
|
||||
|
||||
const showAutocompleteRef = useRef(false);
|
||||
const autocompleteSuggestionsRef = useRef<string[]>([]);
|
||||
const autocompleteSelectedIndexRef = useRef(0);
|
||||
|
||||
const [showHistoryDialog, setShowHistoryDialog] = useState(false);
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [isLoadingHistory, setIsLoadingHistory] = useState(false);
|
||||
|
||||
const setIsLoadingRef = useRef(commandHistoryContext.setIsLoading);
|
||||
const setCommandHistoryContextRef = useRef(
|
||||
commandHistoryContext.setCommandHistory,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoadingRef.current = commandHistoryContext.setIsLoading;
|
||||
setCommandHistoryContextRef.current =
|
||||
commandHistoryContext.setCommandHistory;
|
||||
}, [
|
||||
commandHistoryContext.setIsLoading,
|
||||
commandHistoryContext.setCommandHistory,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showHistoryDialog && hostConfig.id) {
|
||||
setIsLoadingHistory(true);
|
||||
setIsLoadingRef.current(true);
|
||||
import("@/ui/main-axios.ts")
|
||||
.then((module) => module.getCommandHistory(hostConfig.id!))
|
||||
.then((history) => {
|
||||
setCommandHistory(history);
|
||||
setCommandHistoryContextRef.current(history);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load command history:", error);
|
||||
setCommandHistory([]);
|
||||
setCommandHistoryContextRef.current([]);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoadingHistory(false);
|
||||
setIsLoadingRef.current(false);
|
||||
});
|
||||
}
|
||||
}, [showHistoryDialog, hostConfig.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const autocompleteEnabled =
|
||||
localStorage.getItem("commandAutocomplete") !== "false";
|
||||
|
||||
if (hostConfig.id && autocompleteEnabled) {
|
||||
import("@/ui/main-axios.ts")
|
||||
.then((module) => module.getCommandHistory(hostConfig.id!))
|
||||
.then((history) => {
|
||||
autocompleteHistory.current = history;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load autocomplete history:", error);
|
||||
autocompleteHistory.current = [];
|
||||
});
|
||||
} else {
|
||||
autocompleteHistory.current = [];
|
||||
}
|
||||
}, [hostConfig.id]);
|
||||
|
||||
useEffect(() => {
|
||||
showAutocompleteRef.current = showAutocomplete;
|
||||
}, [showAutocomplete]);
|
||||
|
||||
useEffect(() => {
|
||||
autocompleteSuggestionsRef.current = autocompleteSuggestions;
|
||||
}, [autocompleteSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
autocompleteSelectedIndexRef.current = autocompleteSelectedIndex;
|
||||
}, [autocompleteSelectedIndex]);
|
||||
|
||||
const activityLoggingRef = useRef(false);
|
||||
|
||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const notifyTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const lastFittedSizeRef = useRef<{ cols: number; rows: number } | null>(
|
||||
null,
|
||||
);
|
||||
const DEBOUNCE_MS = 140;
|
||||
|
||||
const logTerminalActivity = async () => {
|
||||
@@ -189,7 +311,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
terminal as { refresh?: (start: number, end: number) => void }
|
||||
).refresh(0, terminal.rows - 1);
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("Terminal operation failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function performFit() {
|
||||
@@ -202,20 +326,30 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
const lastSize = lastFittedSizeRef.current;
|
||||
if (
|
||||
lastSize &&
|
||||
lastSize.cols === terminal.cols &&
|
||||
lastSize.rows === terminal.rows
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
isFittingRef.current = true;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal && terminal.cols > 0 && terminal.rows > 0) {
|
||||
scheduleNotify(terminal.cols, terminal.rows);
|
||||
}
|
||||
hardRefresh();
|
||||
setIsFitted(true);
|
||||
} finally {
|
||||
isFittingRef.current = false;
|
||||
try {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal && terminal.cols > 0 && terminal.rows > 0) {
|
||||
scheduleNotify(terminal.cols, terminal.rows);
|
||||
lastFittedSizeRef.current = {
|
||||
cols: terminal.cols,
|
||||
rows: terminal.rows,
|
||||
};
|
||||
}
|
||||
});
|
||||
setIsFitted(true);
|
||||
} finally {
|
||||
isFittingRef.current = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleTotpSubmit(code: string) {
|
||||
@@ -331,7 +465,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
scheduleNotify(cols, rows);
|
||||
hardRefresh();
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("Terminal operation failed:", error);
|
||||
}
|
||||
},
|
||||
refresh: () => hardRefresh(),
|
||||
}),
|
||||
@@ -507,6 +643,7 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}),
|
||||
);
|
||||
terminal.onData((data) => {
|
||||
trackInput(data);
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
});
|
||||
|
||||
@@ -738,7 +875,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("Terminal operation failed:", error);
|
||||
}
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
@@ -758,10 +897,93 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("Terminal operation failed:", error);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const handleSelectCommand = useCallback(
|
||||
(command: string) => {
|
||||
if (!terminal || !webSocketRef.current) return;
|
||||
|
||||
for (const char of command) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: char }),
|
||||
);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
terminal.focus();
|
||||
}, 100);
|
||||
},
|
||||
[terminal],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
commandHistoryContext.setOnSelectCommand(handleSelectCommand);
|
||||
}, [handleSelectCommand]);
|
||||
|
||||
const handleAutocompleteSelect = useCallback(
|
||||
(selectedCommand: string) => {
|
||||
if (!webSocketRef.current) return;
|
||||
|
||||
const currentCmd = currentAutocompleteCommand.current;
|
||||
const completion = selectedCommand.substring(currentCmd.length);
|
||||
|
||||
for (const char of completion) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: char }),
|
||||
);
|
||||
}
|
||||
|
||||
updateCurrentCommand(selectedCommand);
|
||||
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteSuggestions([]);
|
||||
currentAutocompleteCommand.current = "";
|
||||
|
||||
setTimeout(() => {
|
||||
terminal?.focus();
|
||||
}, 50);
|
||||
|
||||
console.log(`[Autocomplete] ${currentCmd} → ${selectedCommand}`);
|
||||
},
|
||||
[terminal, updateCurrentCommand],
|
||||
);
|
||||
|
||||
const handleDeleteCommand = useCallback(
|
||||
async (command: string) => {
|
||||
if (!hostConfig.id) return;
|
||||
|
||||
try {
|
||||
const { deleteCommandFromHistory } = await import(
|
||||
"@/ui/main-axios.ts"
|
||||
);
|
||||
await deleteCommandFromHistory(hostConfig.id, command);
|
||||
|
||||
setCommandHistory((prev) => {
|
||||
const newHistory = prev.filter((cmd) => cmd !== command);
|
||||
setCommandHistoryContextRef.current(newHistory);
|
||||
return newHistory;
|
||||
});
|
||||
|
||||
autocompleteHistory.current = autocompleteHistory.current.filter(
|
||||
(cmd) => cmd !== command,
|
||||
);
|
||||
|
||||
console.log(`[Terminal] Command deleted from history: ${command}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete command from history:", error);
|
||||
}
|
||||
},
|
||||
[hostConfig.id],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
commandHistoryContext.setOnDeleteCommand(handleDeleteCommand);
|
||||
}, [handleDeleteCommand]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current) return;
|
||||
|
||||
@@ -855,7 +1077,9 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const pasteText = await readTextFromClipboard();
|
||||
if (pasteText) terminal.paste(pasteText);
|
||||
}
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error("Terminal operation failed:", error);
|
||||
}
|
||||
};
|
||||
element?.addEventListener("contextmenu", handleContextMenu);
|
||||
|
||||
@@ -864,6 +1088,22 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
navigator.platform.toUpperCase().indexOf("MAC") >= 0 ||
|
||||
navigator.userAgent.toUpperCase().indexOf("MAC") >= 0;
|
||||
|
||||
if (
|
||||
e.ctrlKey &&
|
||||
e.key === "r" &&
|
||||
!e.shiftKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowHistoryDialog(true);
|
||||
if (commandHistoryContext.openCommandHistory) {
|
||||
commandHistoryContext.openCommandHistory();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
config.backspaceMode === "control-h" &&
|
||||
e.key === "Backspace" &&
|
||||
@@ -952,6 +1192,191 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal) return;
|
||||
|
||||
const handleCustomKey = (e: KeyboardEvent): boolean => {
|
||||
if (e.type !== "keydown") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (showAutocompleteRef.current) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteSuggestions([]);
|
||||
currentAutocompleteCommand.current = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const currentIndex = autocompleteSelectedIndexRef.current;
|
||||
const suggestionsLength = autocompleteSuggestionsRef.current.length;
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
const newIndex =
|
||||
currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0;
|
||||
setAutocompleteSelectedIndex(newIndex);
|
||||
} else if (e.key === "ArrowUp") {
|
||||
const newIndex =
|
||||
currentIndex > 0 ? currentIndex - 1 : suggestionsLength - 1;
|
||||
setAutocompleteSelectedIndex(newIndex);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === "Enter" &&
|
||||
autocompleteSuggestionsRef.current.length > 0
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const selectedCommand =
|
||||
autocompleteSuggestionsRef.current[
|
||||
autocompleteSelectedIndexRef.current
|
||||
];
|
||||
const currentCmd = currentAutocompleteCommand.current;
|
||||
const completion = selectedCommand.substring(currentCmd.length);
|
||||
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
for (const char of completion) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: char }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
updateCurrentCommandRef.current(selectedCommand);
|
||||
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteSuggestions([]);
|
||||
currentAutocompleteCommand.current = "";
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === "Tab" &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
!e.shiftKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const currentIndex = autocompleteSelectedIndexRef.current;
|
||||
const suggestionsLength = autocompleteSuggestionsRef.current.length;
|
||||
const newIndex =
|
||||
currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0;
|
||||
setAutocompleteSelectedIndex(newIndex);
|
||||
return false;
|
||||
}
|
||||
|
||||
setShowAutocomplete(false);
|
||||
setAutocompleteSuggestions([]);
|
||||
currentAutocompleteCommand.current = "";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
e.key === "Tab" &&
|
||||
!e.ctrlKey &&
|
||||
!e.altKey &&
|
||||
!e.metaKey &&
|
||||
!e.shiftKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const autocompleteEnabled =
|
||||
localStorage.getItem("commandAutocomplete") !== "false";
|
||||
|
||||
if (!autocompleteEnabled) {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: "\t" }),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentCmd = getCurrentCommandRef.current().trim();
|
||||
if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) {
|
||||
const matches = autocompleteHistory.current
|
||||
.filter(
|
||||
(cmd) =>
|
||||
cmd.startsWith(currentCmd) &&
|
||||
cmd !== currentCmd &&
|
||||
cmd.length > currentCmd.length,
|
||||
)
|
||||
.slice(0, 5);
|
||||
|
||||
if (matches.length === 1) {
|
||||
const completedCommand = matches[0];
|
||||
const completion = completedCommand.substring(currentCmd.length);
|
||||
|
||||
for (const char of completion) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "input", data: char }),
|
||||
);
|
||||
}
|
||||
|
||||
updateCurrentCommandRef.current(completedCommand);
|
||||
} else if (matches.length > 1) {
|
||||
currentAutocompleteCommand.current = currentCmd;
|
||||
setAutocompleteSuggestions(matches);
|
||||
setAutocompleteSelectedIndex(0);
|
||||
|
||||
const cursorY = terminal.buffer.active.cursorY;
|
||||
const cursorX = terminal.buffer.active.cursorX;
|
||||
const rect = xtermRef.current?.getBoundingClientRect();
|
||||
|
||||
if (rect) {
|
||||
const cellHeight =
|
||||
terminal.rows > 0 ? rect.height / terminal.rows : 20;
|
||||
const cellWidth =
|
||||
terminal.cols > 0 ? rect.width / terminal.cols : 10;
|
||||
|
||||
const itemHeight = 32;
|
||||
const footerHeight = 32;
|
||||
const maxMenuHeight = 240;
|
||||
const estimatedMenuHeight = Math.min(
|
||||
matches.length * itemHeight + footerHeight,
|
||||
maxMenuHeight,
|
||||
);
|
||||
const cursorBottomY = rect.top + (cursorY + 1) * cellHeight;
|
||||
const cursorTopY = rect.top + cursorY * cellHeight;
|
||||
const spaceBelow = window.innerHeight - cursorBottomY;
|
||||
const spaceAbove = cursorTopY;
|
||||
|
||||
const showAbove =
|
||||
spaceBelow < estimatedMenuHeight && spaceAbove > spaceBelow;
|
||||
|
||||
setAutocompletePosition({
|
||||
top: showAbove
|
||||
? Math.max(0, cursorTopY - estimatedMenuHeight)
|
||||
: cursorBottomY,
|
||||
left: Math.max(0, rect.left + cursorX * cellWidth),
|
||||
});
|
||||
}
|
||||
|
||||
setShowAutocomplete(true);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
terminal.attachCustomKeyEventHandler(handleCustomKey);
|
||||
}, [terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !hostConfig || !visible) return;
|
||||
|
||||
@@ -999,27 +1424,17 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible || !isReady || !fitAddonRef.current || !terminal) {
|
||||
if (!isVisible && isFitted) {
|
||||
setIsFitted(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsFitted(false);
|
||||
let rafId: number;
|
||||
|
||||
let rafId1: number;
|
||||
let rafId2: number;
|
||||
|
||||
rafId1 = requestAnimationFrame(() => {
|
||||
rafId2 = requestAnimationFrame(() => {
|
||||
hardRefresh();
|
||||
performFit();
|
||||
});
|
||||
rafId = requestAnimationFrame(() => {
|
||||
performFit();
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (rafId1) cancelAnimationFrame(rafId1);
|
||||
if (rafId2) cancelAnimationFrame(rafId2);
|
||||
if (rafId) cancelAnimationFrame(rafId);
|
||||
};
|
||||
}, [isVisible, isReady, splitScreen, terminal]);
|
||||
|
||||
@@ -1045,9 +1460,8 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
ref={xtermRef}
|
||||
className="h-full w-full"
|
||||
style={{
|
||||
visibility:
|
||||
isReady && !isConnecting && isFitted ? "visible" : "hidden",
|
||||
opacity: isReady && !isConnecting && isFitted ? 1 : 0,
|
||||
visibility: isReady ? "visible" : "hidden",
|
||||
pointerEvents: isReady ? "auto" : "none",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (terminal && !splitScreen) {
|
||||
@@ -1078,17 +1492,19 @@ export const Terminal = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
|
||||
{isConnecting && (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-300">{t("terminal.connecting")}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<CommandAutocomplete
|
||||
visible={showAutocomplete}
|
||||
suggestions={autocompleteSuggestions}
|
||||
selectedIndex={autocompleteSelectedIndex}
|
||||
position={autocompletePosition}
|
||||
onSelect={handleAutocompleteSelect}
|
||||
/>
|
||||
|
||||
<SimpleLoader
|
||||
visible={isConnecting}
|
||||
message={t("terminal.connecting")}
|
||||
backgroundColor={backgroundColor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
|
||||
interface CommandAutocompleteProps {
|
||||
suggestions: string[];
|
||||
selectedIndex: number;
|
||||
onSelect: (command: string) => void;
|
||||
position: { top: number; left: number };
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function CommandAutocomplete({
|
||||
suggestions,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
position,
|
||||
visible,
|
||||
}: CommandAutocompleteProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const selectedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRef.current && containerRef.current) {
|
||||
selectedRef.current.scrollIntoView({
|
||||
block: "nearest",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
if (!visible || suggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const footerHeight = 32;
|
||||
const maxSuggestionsHeight = 240 - footerHeight;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed z-[9999] bg-dark-bg border border-dark-border rounded-md shadow-lg min-w-[200px] max-w-[600px] flex flex-col"
|
||||
style={{
|
||||
top: `${position.top}px`,
|
||||
left: `${position.left}px`,
|
||||
maxHeight: "240px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="overflow-y-auto"
|
||||
style={{ maxHeight: `${maxSuggestionsHeight}px` }}
|
||||
>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
ref={index === selectedIndex ? selectedRef : null}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono cursor-pointer transition-colors",
|
||||
"hover:bg-dark-hover",
|
||||
index === selectedIndex && "bg-gray-500/20 text-gray-400",
|
||||
)}
|
||||
onClick={() => onSelect(suggestion)}
|
||||
onMouseEnter={() => {}}
|
||||
>
|
||||
{suggestion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground border-t border-dark-border bg-dark-bg/50 shrink-0">
|
||||
Tab/Enter to complete • ↑↓ to navigate • Esc to close
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useCallback,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
interface CommandHistoryContextType {
|
||||
commandHistory: string[];
|
||||
isLoading: boolean;
|
||||
setCommandHistory: (history: string[]) => void;
|
||||
setIsLoading: (loading: boolean) => void;
|
||||
onSelectCommand?: (command: string) => void;
|
||||
setOnSelectCommand: (callback: (command: string) => void) => void;
|
||||
onDeleteCommand?: (command: string) => void;
|
||||
setOnDeleteCommand: (callback: (command: string) => void) => void;
|
||||
openCommandHistory: () => void;
|
||||
setOpenCommandHistory: (callback: () => void) => void;
|
||||
}
|
||||
|
||||
const CommandHistoryContext = createContext<
|
||||
CommandHistoryContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function CommandHistoryProvider({ children }: { children: ReactNode }) {
|
||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [onSelectCommand, setOnSelectCommand] = useState<
|
||||
((command: string) => void) | undefined
|
||||
>(undefined);
|
||||
const [onDeleteCommand, setOnDeleteCommand] = useState<
|
||||
((command: string) => void) | undefined
|
||||
>(undefined);
|
||||
const [openCommandHistory, setOpenCommandHistory] = useState<
|
||||
(() => void) | undefined
|
||||
>(() => () => {});
|
||||
|
||||
const handleSetOnSelectCommand = useCallback(
|
||||
(callback: (command: string) => void) => {
|
||||
setOnSelectCommand(() => callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetOnDeleteCommand = useCallback(
|
||||
(callback: (command: string) => void) => {
|
||||
setOnDeleteCommand(() => callback);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleSetOpenCommandHistory = useCallback((callback: () => void) => {
|
||||
setOpenCommandHistory(() => callback);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CommandHistoryContext.Provider
|
||||
value={{
|
||||
commandHistory,
|
||||
isLoading,
|
||||
setCommandHistory,
|
||||
setIsLoading,
|
||||
onSelectCommand,
|
||||
setOnSelectCommand: handleSetOnSelectCommand,
|
||||
onDeleteCommand,
|
||||
setOnDeleteCommand: handleSetOnDeleteCommand,
|
||||
openCommandHistory: openCommandHistory || (() => {}),
|
||||
setOpenCommandHistory: handleSetOpenCommandHistory,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CommandHistoryContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCommandHistory() {
|
||||
const context = useContext(CommandHistoryContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useCommandHistory must be used within a CommandHistoryProvider",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,55 +0,0 @@
|
||||
import React from "react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Hammer, Wrench, FileText } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ToolsMenuProps {
|
||||
onOpenSshTools: () => void;
|
||||
onOpenSnippets: () => void;
|
||||
}
|
||||
|
||||
export function ToolsMenu({
|
||||
onOpenSshTools,
|
||||
onOpenSnippets,
|
||||
}: ToolsMenuProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-[30px] h-[30px] border-dark-border"
|
||||
title={t("nav.tools")}
|
||||
>
|
||||
<Hammer className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="end"
|
||||
className="w-56 bg-dark-bg border-dark-border text-white"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenSshTools}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<Wrench className="h-4 w-4" />
|
||||
<span className="flex-1">{t("sshTools.title")}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onOpenSnippets}
|
||||
className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300"
|
||||
>
|
||||
<FileText className="h-4 w-4" />
|
||||
<span className="flex-1">{t("snippets.title")}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user