* 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:
Luke Gustafson
2025-11-17 09:46:05 -06:00
committed by GitHub
parent 38a59f3579
commit 8366c99b0f
104 changed files with 16070 additions and 2821 deletions

View 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>
);
}

View File

@@ -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")}

View File

@@ -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">

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>
);

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>
);

View 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>
);
}

View File

@@ -5,3 +5,4 @@ export { NetworkWidget } from "./NetworkWidget";
export { UptimeWidget } from "./UptimeWidget";
export { ProcessesWidget } from "./ProcessesWidget";
export { SystemWidget } from "./SystemWidget";
export { LoginStatsWidget } from "./LoginStatsWidget";

View File

@@ -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>
)}
</>
);
}

View File

@@ -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>
);
},

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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>
);
}