v1.6.0 (#221)
* Add documentation in Chinese language (#160) * Update file naming and structure for mobile support * Add conditional desktop/mobile rendering * Mobile terminal * Fix overwritten i18n (#161) * Add comprehensive Chinese internationalization support - Implemented i18n framework with react-i18next for multi-language support - Added Chinese (zh) and English (en) translation files with comprehensive coverage - Localized Admin interface, authentication flows, and error messages - Translated FileManager operations and UI elements - Updated HomepageAuth component with localized authentication messages - Localized LeftSidebar navigation and host management - Added language switcher component (shown after login only) - Configured default language as English with Chinese as secondary option - Localized TOTPSetup two-factor authentication interface - Updated Docker build to include translation files - Achieved 95%+ UI localization coverage across core components Co-Authored-By: Claude <noreply@anthropic.com> * Extend Chinese localization coverage to Host Manager components - Added comprehensive translations for HostManagerHostViewer component - Localized all host management UI text including import/export features - Translated error messages and confirmation dialogs for host operations - Added translations for HostManagerHostEditor validation messages - Localized connection details, organization settings, and form labels - Fixed syntax error in FileManagerOperations component - Achieved near-complete localization of SSH host management interface - Updated placeholders and tooltips for better user guidance Co-Authored-By: Claude <noreply@anthropic.com> * Complete comprehensive Chinese localization for Termix - Added full localization support for Tunnel components (connected/disconnected states, retry messages) - Localized all tunnel status messages and connection errors - Added translations for port forwarding UI elements - Verified Server, TopNavbar, and Tab components already have complete i18n support - Achieved 99%+ localization coverage across entire application - All core UI components now fully support Chinese and English languages This completes the comprehensive internationalization effort for the Termix SSH management platform. Co-Authored-By: Claude <noreply@anthropic.com> * Localize additional Host Manager components and authentication settings - Added translations for all authentication options (Password, Key, SSH Private Key) - Localized form labels in HostManagerHostEditor (Pin Connection, Enable Terminal/Tunnel/FileManager) - Translated Upload/Update Key button states - Localized Host Viewer and Add/Edit Host tab labels - Added Chinese translations for all host management settings - Fixed duplicate translation keys in JSON files Co-Authored-By: Claude <noreply@anthropic.com> * Extend localization coverage to UI components and common strings - Added comprehensive common translations (online/offline, success/error, etc.) - Localized status indicator component with all status states - Updated FileManagerLeftSidebar toast messages for rename/delete operations - Added translations for UI elements (close, toggle sidebar, etc.) - Expanded placeholder translations for form inputs - Added Chinese translations for all new common strings - Improved consistency across component status messages Co-Authored-By: Claude <noreply@anthropic.com> * Complete Chinese localization for remaining UI components - Add comprehensive Chinese translations for Host Manager component - Translate all form labels, buttons, and descriptions - Add translations for SSH configuration warnings and instructions - Localize tunnel connection settings and port forwarding options - Localize SSH Tools panel - Translate key recording functionality - Add translations for settings and configuration options - Translate homepage welcome messages and navigation elements - Add Chinese translations for login success messages - Localize "Updates & Releases" section title - Translate sidebar "Host Manager" button - Fix translation key display issues - Remove duplicate translation keys in both language files - Ensure all components properly reference translation keys - Fix hosts.tunnelConnections key mapping This completes the full Chinese localization of the Termix application, achieving near 100% UI translation coverage while maintaining English as the default language. * Complete final Chinese localization for Host Manager tunnel configuration - Add Chinese translations for authentication UI elements - Translate "Authentication", "Password", and "Key" tab labels - Localize SSH private key and key password fields - Add translations for key type selector - Localize tunnel connection configuration descriptions - Translate retry attempts and retry interval descriptions - Add dynamic tunnel forwarding description with port parameters - Localize endpoint SSH configuration labels - Fix missing translation keys - Add "upload" translation for file upload button - Ensure all FormLabel and FormDescription elements use translation keys This completes the comprehensive Chinese localization of the entire Termix application, achieving 100% UI translation coverage. * Fix PR feedback: Improve Profile section translations and UX - Fixed password reset translations in Profile section - Moved language selector from TopNavbar to Profile page - Added profile.selectPreferredLanguage translation key - Improved user experience for language preferences * Apply critical OIDC and notification system fixes while preserving i18n - Merge OIDC authentication fixes from3877e90: * Enhanced JWKS discovery mechanism with multiple backup URLs * Better support for non-standard OIDC providers (Authentik, etc.) * Improved error handling for "Failed to get user information" - Migrate to unified Sonner toast notification system: * Replace custom success/error state management * Remove redundant alert state variables * Consistent user feedback across all components - Improve code quality and function naming conventions - PRESERVE all existing i18n functionality and Chinese translations 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * Fix OIDC errors for "Failed to get user information" * Fix OIDC errors for "Failed to get user information" * Fix spelling error * Migrate everything to alert system, update user.ts for OIDC updates. * Fix OIDC errors for "Failed to get user information" * Fix OIDC errors for "Failed to get user information" * Fix spelling error * Migrate everything to alert system, update user.ts for OIDC updates. * Update env * Fix users.ts and schema for override * Convert web app to Electron desktop application - Add Electron main process with developer tools support - Create preload script for secure context bridge - Configure electron-builder for packaging - Update Vite config for Electron compatibility (base: './') - Add environment variable support for API host configuration - Fix i18n to use relative paths for Electron file protocol - Restore multi-port backend architecture (8081-8085) - Add enhanced backend startup script with port checking - Update package.json with Electron dependencies and build scripts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Complete Electron desktop application implementation - Add backend auto-start functionality in main process - Fix authentication token storage for Electron environment - Implement localStorage-based token management in Electron - Add proper Electron environment detection via preload script - Fix WebSocket connections for terminal functionality - Resolve font file loading issues in packaged application - Update API endpoints to work with backend auto-start - Streamline build scripts with unified electron:package command - Fix better-sqlite3 native module compatibility issues - Ensure all services start automatically in production mode 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Remove releases folder from git and force Desktop UI. * Improve mobile support with half-baked custom keyboard * Fix API routing * Upgrade mobile keyboard with more keys. * Add cross-platform support and clean up obsolete files - Add electron-packager scripts for Windows, macOS, and Linux - Include universal architecture support for macOS - Add electron:package:all for building all platforms - Remove obsolete start-backend.sh script (replaced by Electron auto-start) - Improve ignore patterns to exclude repo-images folder - Add platform-specific icon configurations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix build system by removing electron-builder dependency - Remove electron-builder and @electron/rebuild packages to resolve build errors - Clean up package.json scripts that depend on electron-builder - Fix merge conflict markers in AdminSettings.tsx and PasswordReset.tsx - All build commands now work correctly: - npm run build (frontend + backend) - npm run build:frontend - npm run build:backend - npm run electron:package (using electron-packager) The build system is now stable and functional without signing requirements. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * Mobile UI improvement * Electron dev (#185) * Add comprehensive Chinese internationalization support - Implemented i18n framework with react-i18next for multi-language support - Added Chinese (zh) and English (en) translation files with comprehensive coverage - Localized Admin interface, authentication flows, and error messages - Translated FileManager operations and UI elements - Updated HomepageAuth component with localized authentication messages - Localized LeftSidebar navigation and host management - Added language switcher component (shown after login only) - Configured default language as English with Chinese as secondary option - Localized TOTPSetup two-factor authentication interface - Updated Docker build to include translation files - Achieved 95%+ UI localization coverage across core components Co-Authored-By: Claude <noreply@anthropic.com> * Extend Chinese localization coverage to Host Manager components - Added comprehensive translations for HostManagerHostViewer component - Localized all host management UI text including import/export features - Translated error messages and confirmation dialogs for host operations - Added translations for HostManagerHostEditor validation messages - Localized connection details, organization settings, and form labels - Fixed syntax error in FileManagerOperations component - Achieved near-complete localization of SSH host management interface - Updated placeholders and tooltips for better user guidance Co-Authored-By: Claude <noreply@anthropic.com> * Complete comprehensive Chinese localization for Termix - Added full localization support for Tunnel components (connected/disconnected states, retry messages) - Localized all tunnel status messages and connection errors - Added translations for port forwarding UI elements - Verified Server, TopNavbar, and Tab components already have complete i18n support - Achieved 99%+ localization coverage across entire application - All core UI components now fully support Chinese and English languages This completes the comprehensive internationalization effort for the Termix SSH management platform. Co-Authored-By: Claude <noreply@anthropic.com> * Localize additional Host Manager components and authentication settings - Added translations for all authentication options (Password, Key, SSH Private Key) - Localized form labels in HostManagerHostEditor (Pin Connection, Enable Terminal/Tunnel/FileManager) - Translated Upload/Update Key button states - Localized Host Viewer and Add/Edit Host tab labels - Added Chinese translations for all host management settings - Fixed duplicate translation keys in JSON files Co-Authored-By: Claude <noreply@anthropic.com> * Extend localization coverage to UI components and common strings - Added comprehensive common translations (online/offline, success/error, etc.) - Localized status indicator component with all status states - Updated FileManagerLeftSidebar toast messages for rename/delete operations - Added translations for UI elements (close, toggle sidebar, etc.) - Expanded placeholder translations for form inputs - Added Chinese translations for all new common strings - Improved consistency across component status messages Co-Authored-By: Claude <noreply@anthropic.com> * Complete Chinese localization for remaining UI components - Add comprehensive Chinese translations for Host Manager component - Translate all form labels, buttons, and descriptions - Add translations for SSH configuration warnings and instructions - Localize tunnel connection settings and port forwarding options - Localize SSH Tools panel - Translate key recording functionality - Add translations for settings and configuration options - Translate homepage welcome messages and navigation elements - Add Chinese translations for login success messages - Localize "Updates & Releases" section title - Translate sidebar "Host Manager" button - Fix translation key display issues - Remove duplicate translation keys in both language files - Ensure all components properly reference translation keys - Fix hosts.tunnelConnections key mapping This completes the full Chinese localization of the Termix application, achieving near 100% UI translation coverage while maintaining English as the default language. * Complete final Chinese localization for Host Manager tunnel configuration - Add Chinese translations for authentication UI elements - Translate "Authentication", "Password", and "Key" tab labels - Localize SSH private key and key password fields - Add translations for key type selector - Localize tunnel connection configuration descriptions - Translate retry attempts and retry interval descriptions - Add dynamic tunnel forwarding description with port parameters - Localize endpoint SSH configuration labels - Fix missing translation keys - Add "upload" translation for file upload button - Ensure all FormLabel and FormDescription elements use translation keys This completes the comprehensive Chinese localization of the entire Termix application, achieving 100% UI translation coverage. * Fix PR feedback: Improve Profile section translations and UX - Fixed password reset translations in Profile section - Moved language selector from TopNavbar to Profile page - Added profile.selectPreferredLanguage translation key - Improved user experience for language preferences * Apply critical OIDC and notification system fixes while preserving i18n - Merge OIDC authentication fixes from3877e90: * Enhanced JWKS discovery mechanism with multiple backup URLs * Better support for non-standard OIDC providers (Authentik, etc.) * Improved error handling for "Failed to get user information" - Migrate to unified Sonner toast notification system: * Replace custom success/error state management * Remove redundant alert state variables * Consistent user feedback across all components - Improve code quality and function naming conventions - PRESERVE all existing i18n functionality and Chinese translations 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com> * Fix OIDC errors for "Failed to get user information" * Fix OIDC errors for "Failed to get user information" * Fix spelling error * Migrate everything to alert system, update user.ts for OIDC updates. * Fix OIDC errors for "Failed to get user information" * Fix OIDC errors for "Failed to get user information" * Fix spelling error * Migrate everything to alert system, update user.ts for OIDC updates. * Update env * Fix users.ts and schema for override * Convert web app to Electron desktop application - Add Electron main process with developer tools support - Create preload script for secure context bridge - Configure electron-builder for packaging - Update Vite config for Electron compatibility (base: './') - Add environment variable support for API host configuration - Fix i18n to use relative paths for Electron file protocol - Restore multi-port backend architecture (8081-8085) - Add enhanced backend startup script with port checking - Update package.json with Electron dependencies and build scripts 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Complete Electron desktop application implementation - Add backend auto-start functionality in main process - Fix authentication token storage for Electron environment - Implement localStorage-based token management in Electron - Add proper Electron environment detection via preload script - Fix WebSocket connections for terminal functionality - Resolve font file loading issues in packaged application - Update API endpoints to work with backend auto-start - Streamline build scripts with unified electron:package command - Fix better-sqlite3 native module compatibility issues - Ensure all services start automatically in production mode 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Remove releases folder from git and force Desktop UI. * Improve mobile support with half-baked custom keyboard * Fix API routing * Upgrade mobile keyboard with more keys. * Add cross-platform support and clean up obsolete files - Add electron-packager scripts for Windows, macOS, and Linux - Include universal architecture support for macOS - Add electron:package:all for building all platforms - Remove obsolete start-backend.sh script (replaced by Electron auto-start) - Improve ignore patterns to exclude repo-images folder - Add platform-specific icon configurations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> * Fix build system by removing electron-builder dependency - Remove electron-builder and @electron/rebuild packages to resolve build errors - Clean up package.json scripts that depend on electron-builder - Fix merge conflict markers in AdminSettings.tsx and PasswordReset.tsx - All build commands now work correctly: - npm run build (frontend + backend) - npm run build:frontend - npm run build:backend - npm run electron:package (using electron-packager) The build system is now stable and functional without signing requirements. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> * Add navigation and hardcoded hosts * Update mobile sidebar to use API, add auth and tab system to mobile. * Update sidebar state * Mobile support (#190) * Add vibration to keyboard * Fix keyboard keys * Fix keyboard keys * Fix keyboard keys * Rename files, improve keyboard usability * Improve keyboard view and fix various issues with it * Add mobile chinese translation * Disable OS keyboard from appearing * Fix fit addon not resizing with "more" on keyboard * Disable OS keyboard on terminal load * Merge Luke and Zac * feat: add export option for ssh hosts (#173) (#187) * Update issue templates * feat: add export JSON option for SSH hosts (#173) --------- Co-authored-by: Karmaa <88517757+LukeGus@users.noreply.github.com> Co-authored-by: LukeGus <bugattiguy527@gmail.com> * feat(profile): display version number from .env in profile menu (#182) * feat(profile): display version number from .env in profile menu * Update version checking process --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * Add pretier * feat(auth): Add password visibility toggle to auth forms (#166) * added hide and unhide password button * Undo admin settings changes --------- Co-authored-by: LukeGus <bugattiguy527@gmail.com> * Re-added password input * Remove encrpytion, improve logging and merge interfaces. * Improve logging (backend and frontend) and added dedicde OIDC clear * feat: Added option to paste private key (#203) * Improve logging frontend/backend, fix host form being reversed. * Improve logging more, fix credentials sync issues, migrate more to be toasts * Improve logging more, fix credentials sync issues, migrate more to be toasts * More error to toast migration * Remove more inline styles and run npm updates * Update homepage appearing over everything and terminal incorrect bg * Improved server stat generation and UI by caching and supporting more platforms * Update mobile app with the same stat changes and remove rate limiting * Put user profle in its own tab, add code rabbit support * Improve code rabbit yaml * Update chinese translation and fix z indexs causing delay to hide * Bump vite from 7.1.3 to 7.1.5 (#204) Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.1.3 to 7.1.5. - [Release notes](https://github.com/vitejs/vite/releases) - [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md) - [Commits](https://github.com/vitejs/vite/commits/v7.1.5/packages/vite) --- updated-dependencies: - dependency-name: vite dependency-version: 7.1.5 dependency-type: direct:development ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Update read me * Update electron builder and fix mobile terminal background * Update logo, move translations, update electron building. * Remove backend from electon, switching to server manager * Add electron server configurator * Fix backend builder on Dockerfile * Fix langauge file for Dockerfile * Fix architecture issues in Dockerfile * Fix architecture issues in Dockerfile * Fix architecture issues in Dockerfile * Fix backend building for docker image * Add electron builder * Fix node starting in entrypoint and remove release from electron build * Remove double packaing in electron build * Fix folder nesting for electron gbuilder * Fix native module docker build (better-sql and bcrypt) * Fix api routes and missing translations and improve reconnection for terminals * Update read me for new installation method * Update CONTRIBUTING.md with color scheme * Fix terrminal not closing afer 3 tries * Fix electronm api routing, fikx ssh not connecting, and OIDC redirect errors * Fix more electron API issues (ssh/oidc), make server manager force API check, and login saving. * Add electron API routes * Fix more electron APi routes and issues * Hide admin settings on electron and fix server manager URl verification * Hide admin settings on electron and fix server manager URl verification * Fix admin setting visiblity on electron * Add links to docs in respective places * Migrate all getCookies to use main-axios. * Migrate all isElectron to use main-axios. * Clean up backend files * Clean up frontend files and read me translations * Run prettier * Fix terminal in web, and update translations and prep for release. * Update API to work on devs and remove random letter * Run prettier * Update read me for release * Update read me for release * Fixed delete issue (ready for release) * Ensure retention days for artifact upload are set --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: starry <115192496+sky22333@users.noreply.github.com> Co-authored-by: ZacharyZcR <PayasoNorahC@protonmail.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Shivam Kumar <155747305+maishivamhoo123@users.noreply.github.com> Co-authored-by: Abhilash Gandhamalla <150357125+AbhilashG12@users.noreply.github.com> Co-authored-by: jedi04 <78037206+jedi04@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This commit was merged in pull request #221.
This commit is contained in:
849
src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx
Normal file
849
src/ui/Desktop/Apps/Credentials/CredentialEditor.tsx
Normal file
@@ -0,0 +1,849 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
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 React, { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createCredential,
|
||||
updateCredential,
|
||||
getCredentials,
|
||||
getCredentialDetails,
|
||||
} from "@/ui/main-axios";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
Credential,
|
||||
CredentialEditorProps,
|
||||
CredentialData,
|
||||
} from "../../../../types/index.js";
|
||||
|
||||
export function CredentialEditor({
|
||||
editingCredential,
|
||||
onFormSubmit,
|
||||
}: CredentialEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [folders, setFolders] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fullCredentialDetails, setFullCredentialDetails] =
|
||||
useState<Credential | null>(null);
|
||||
|
||||
const [authTab, setAuthTab] = useState<"password" | "key">("password");
|
||||
const [keyInputMethod, setKeyInputMethod] = useState<"upload" | "paste">(
|
||||
"upload",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const credentialsData = await getCredentials();
|
||||
setCredentials(credentialsData);
|
||||
|
||||
const uniqueFolders = [
|
||||
...new Set(
|
||||
credentialsData
|
||||
.filter(
|
||||
(credential) =>
|
||||
credential.folder && credential.folder.trim() !== "",
|
||||
)
|
||||
.map((credential) => credential.folder!),
|
||||
),
|
||||
].sort() as string[];
|
||||
|
||||
setFolders(uniqueFolders);
|
||||
} catch (error) {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCredentialDetails = async () => {
|
||||
if (editingCredential) {
|
||||
try {
|
||||
const fullDetails = await getCredentialDetails(editingCredential.id);
|
||||
setFullCredentialDetails(fullDetails);
|
||||
} catch (error) {
|
||||
toast.error(t("credentials.failedToFetchCredentialDetails"));
|
||||
}
|
||||
} else {
|
||||
setFullCredentialDetails(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCredentialDetails();
|
||||
}, [editingCredential, t]);
|
||||
|
||||
const formSchema = z
|
||||
.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
folder: z.string().optional(),
|
||||
tags: z.array(z.string().min(1)).default([]),
|
||||
authType: z.enum(["password", "key"]),
|
||||
username: z.string().min(1),
|
||||
password: z.string().optional(),
|
||||
key: z.any().optional().nullable(),
|
||||
keyPassword: z.string().optional(),
|
||||
keyType: z
|
||||
.enum([
|
||||
"auto",
|
||||
"ssh-rsa",
|
||||
"ssh-ed25519",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ssh-dss",
|
||||
"ssh-rsa-sha2-256",
|
||||
"ssh-rsa-sha2-512",
|
||||
])
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.authType === "password") {
|
||||
if (!data.password || data.password.trim() === "") {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("credentials.passwordRequired"),
|
||||
path: ["password"],
|
||||
});
|
||||
}
|
||||
} else if (data.authType === "key") {
|
||||
if (!data.key && !editingCredential) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("credentials.sshKeyRequired"),
|
||||
path: ["key"],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof formSchema>;
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema) as any,
|
||||
defaultValues: {
|
||||
name: "",
|
||||
description: "",
|
||||
folder: "",
|
||||
tags: [],
|
||||
authType: "password",
|
||||
username: "",
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: "auto",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCredential && fullCredentialDetails) {
|
||||
const defaultAuthType = fullCredentialDetails.authType;
|
||||
setAuthTab(defaultAuthType);
|
||||
|
||||
setTimeout(() => {
|
||||
const formData = {
|
||||
name: fullCredentialDetails.name || "",
|
||||
description: fullCredentialDetails.description || "",
|
||||
folder: fullCredentialDetails.folder || "",
|
||||
tags: fullCredentialDetails.tags || [],
|
||||
authType: defaultAuthType as "password" | "key",
|
||||
username: fullCredentialDetails.username || "",
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: "auto" as const,
|
||||
};
|
||||
|
||||
if (defaultAuthType === "password") {
|
||||
formData.password = fullCredentialDetails.password || "";
|
||||
} else if (defaultAuthType === "key") {
|
||||
formData.key = "existing_key";
|
||||
formData.keyPassword = fullCredentialDetails.keyPassword || "";
|
||||
formData.keyType =
|
||||
(fullCredentialDetails.keyType as any) || ("auto" as const);
|
||||
}
|
||||
|
||||
form.reset(formData);
|
||||
setTagInput("");
|
||||
}, 100);
|
||||
} else if (!editingCredential) {
|
||||
setAuthTab("password");
|
||||
form.reset({
|
||||
name: "",
|
||||
description: "",
|
||||
folder: "",
|
||||
tags: [],
|
||||
authType: "password",
|
||||
username: "",
|
||||
password: "",
|
||||
key: null,
|
||||
keyPassword: "",
|
||||
keyType: "auto",
|
||||
});
|
||||
setTagInput("");
|
||||
}
|
||||
}, [editingCredential?.id, fullCredentialDetails, form]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
try {
|
||||
if (!data.name || data.name.trim() === "") {
|
||||
data.name = data.username;
|
||||
}
|
||||
|
||||
const submitData: CredentialData = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
folder: data.folder,
|
||||
tags: data.tags,
|
||||
authType: data.authType,
|
||||
username: data.username,
|
||||
keyType: data.keyType,
|
||||
};
|
||||
|
||||
submitData.password = null;
|
||||
submitData.key = null;
|
||||
submitData.keyPassword = null;
|
||||
submitData.keyType = null;
|
||||
|
||||
if (data.authType === "password") {
|
||||
submitData.password = data.password;
|
||||
} else if (data.authType === "key") {
|
||||
if (data.key instanceof File) {
|
||||
const keyContent = await data.key.text();
|
||||
submitData.key = keyContent;
|
||||
} else if (data.key === "existing_key") {
|
||||
delete submitData.key;
|
||||
} else {
|
||||
submitData.key = data.key;
|
||||
}
|
||||
submitData.keyPassword = data.keyPassword;
|
||||
submitData.keyType = data.keyType;
|
||||
}
|
||||
|
||||
if (editingCredential) {
|
||||
await updateCredential(editingCredential.id, submitData);
|
||||
toast.success(
|
||||
t("credentials.credentialUpdatedSuccessfully", { name: data.name }),
|
||||
);
|
||||
} else {
|
||||
await createCredential(submitData);
|
||||
toast.success(
|
||||
t("credentials.credentialAddedSuccessfully", { name: data.name }),
|
||||
);
|
||||
}
|
||||
|
||||
if (onFormSubmit) {
|
||||
onFormSubmit();
|
||||
}
|
||||
|
||||
window.dispatchEvent(new CustomEvent("credentials:changed"));
|
||||
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
toast.error(t("credentials.failedToSaveCredential"));
|
||||
}
|
||||
};
|
||||
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
const [folderDropdownOpen, setFolderDropdownOpen] = useState(false);
|
||||
const folderInputRef = useRef<HTMLInputElement>(null);
|
||||
const folderDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const folderValue = form.watch("folder");
|
||||
const filteredFolders = React.useMemo(() => {
|
||||
if (!folderValue) return folders;
|
||||
return folders.filter((f) =>
|
||||
f.toLowerCase().includes(folderValue.toLowerCase()),
|
||||
);
|
||||
}, [folderValue, folders]);
|
||||
|
||||
const handleFolderClick = (folder: string) => {
|
||||
form.setValue("folder", folder);
|
||||
setFolderDropdownOpen(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
folderDropdownRef.current &&
|
||||
!folderDropdownRef.current.contains(event.target as Node) &&
|
||||
folderInputRef.current &&
|
||||
!folderInputRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setFolderDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (folderDropdownOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
} else {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [folderDropdownOpen]);
|
||||
|
||||
const keyTypeOptions = [
|
||||
{ value: "auto", label: t("hosts.autoDetect") },
|
||||
{ value: "ssh-rsa", label: t("hosts.rsa") },
|
||||
{ value: "ssh-ed25519", label: t("hosts.ed25519") },
|
||||
{ value: "ecdsa-sha2-nistp256", label: t("hosts.ecdsaNistP256") },
|
||||
{ value: "ecdsa-sha2-nistp384", label: t("hosts.ecdsaNistP384") },
|
||||
{ value: "ecdsa-sha2-nistp521", label: t("hosts.ecdsaNistP521") },
|
||||
{ value: "ssh-dss", label: t("hosts.dsa") },
|
||||
{ value: "ssh-rsa-sha2-256", label: t("hosts.rsaSha2256") },
|
||||
{ value: "ssh-rsa-sha2-512", label: t("hosts.rsaSha2512") },
|
||||
];
|
||||
|
||||
const [keyTypeDropdownOpen, setKeyTypeDropdownOpen] = useState(false);
|
||||
const keyTypeButtonRef = useRef<HTMLButtonElement>(null);
|
||||
const keyTypeDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
keyTypeDropdownOpen &&
|
||||
keyTypeDropdownRef.current &&
|
||||
!keyTypeDropdownRef.current.contains(event.target as Node) &&
|
||||
keyTypeButtonRef.current &&
|
||||
!keyTypeButtonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", onClickOutside);
|
||||
return () => document.removeEventListener("mousedown", onClickOutside);
|
||||
}, [keyTypeDropdownOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 flex flex-col h-full min-h-0 w-full"
|
||||
key={editingCredential?.id || "new"}
|
||||
>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
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">
|
||||
<TabsList>
|
||||
<TabsTrigger value="general">
|
||||
{t("credentials.general")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="authentication">
|
||||
{t("credentials.authentication")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="general" className="pt-2">
|
||||
<FormLabel className="mb-3 font-bold">
|
||||
{t("credentials.basicInformation")}
|
||||
</FormLabel>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t("credentials.credentialName")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.credentialName")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="username"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-6">
|
||||
<FormLabel>{t("credentials.username")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.username")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormLabel className="mb-3 mt-3 font-bold">
|
||||
{t("credentials.organization")}
|
||||
</FormLabel>
|
||||
<div className="grid grid-cols-26 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-10">
|
||||
<FormLabel>{t("credentials.description")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("placeholders.description")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="folder"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-10 relative">
|
||||
<FormLabel>{t("credentials.folder")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
ref={folderInputRef}
|
||||
placeholder={t("placeholders.folder")}
|
||||
className="min-h-[40px]"
|
||||
autoComplete="off"
|
||||
value={field.value}
|
||||
onFocus={() => setFolderDropdownOpen(true)}
|
||||
onChange={(e) => {
|
||||
field.onChange(e);
|
||||
setFolderDropdownOpen(true);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
{folderDropdownOpen && filteredFolders.length > 0 && (
|
||||
<div
|
||||
ref={folderDropdownRef}
|
||||
className="absolute top-full left-0 z-50 mt-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
{filteredFolders.map((folder) => (
|
||||
<Button
|
||||
key={folder}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded px-2 py-1.5 hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
||||
onClick={() => handleFolderClick(folder)}
|
||||
>
|
||||
{folder}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tags"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-10 overflow-visible">
|
||||
<FormLabel>{t("credentials.tags")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex flex-wrap items-center gap-1 border border-input rounded-md px-3 py-2 bg-dark-bg-input focus-within:ring-2 ring-ring min-h-[40px]">
|
||||
{(field.value || []).map(
|
||||
(tag: string, idx: number) => (
|
||||
<span
|
||||
key={`${tag}-${idx}`}
|
||||
className="flex items-center bg-gray-200 text-gray-800 rounded-full px-2 py-0.5 text-xs"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 text-gray-500 hover:text-red-500 focus:outline-none"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const newTags = (
|
||||
field.value || []
|
||||
).filter(
|
||||
(_: string, i: number) => i !== idx,
|
||||
);
|
||||
field.onChange(newTags);
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
),
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 min-w-[60px] border-none outline-none bg-transparent p-0 h-6 text-sm"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " " && tagInput.trim() !== "") {
|
||||
e.preventDefault();
|
||||
const currentTags = field.value || [];
|
||||
if (!currentTags.includes(tagInput.trim())) {
|
||||
field.onChange([
|
||||
...currentTags,
|
||||
tagInput.trim(),
|
||||
]);
|
||||
}
|
||||
setTagInput("");
|
||||
} else if (
|
||||
e.key === "Enter" &&
|
||||
tagInput.trim() !== ""
|
||||
) {
|
||||
e.preventDefault();
|
||||
const currentTags = field.value || [];
|
||||
if (!currentTags.includes(tagInput.trim())) {
|
||||
field.onChange([
|
||||
...currentTags,
|
||||
tagInput.trim(),
|
||||
]);
|
||||
}
|
||||
setTagInput("");
|
||||
} else if (
|
||||
e.key === "Backspace" &&
|
||||
tagInput === "" &&
|
||||
(field.value || []).length > 0
|
||||
) {
|
||||
const currentTags = field.value || [];
|
||||
field.onChange(currentTags.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
placeholder={t("credentials.addTagsSpaceToAdd")}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="authentication">
|
||||
<FormLabel className="mb-3 font-bold">
|
||||
{t("credentials.authentication")}
|
||||
</FormLabel>
|
||||
<Tabs
|
||||
value={authTab}
|
||||
onValueChange={(value) => {
|
||||
const newAuthType = value as "password" | "key";
|
||||
setAuthTab(newAuthType);
|
||||
form.setValue("authType", newAuthType);
|
||||
|
||||
form.setValue("password", "");
|
||||
form.setValue("key", null);
|
||||
form.setValue("keyPassword", "");
|
||||
form.setValue("keyType", "auto");
|
||||
|
||||
if (newAuthType === "password") {
|
||||
} else if (newAuthType === "key") {
|
||||
}
|
||||
}}
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="password">
|
||||
{t("credentials.password")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="key">
|
||||
{t("credentials.key")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="password">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("credentials.password")}</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t("placeholders.password")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="key">
|
||||
<Tabs
|
||||
value={keyInputMethod}
|
||||
onValueChange={(value) => {
|
||||
setKeyInputMethod(value as "upload" | "paste");
|
||||
if (value === "upload") {
|
||||
form.setValue("key", null);
|
||||
} else {
|
||||
form.setValue("key", "");
|
||||
}
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground">
|
||||
<TabsTrigger value="upload">
|
||||
{t("hosts.uploadFile")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="paste">
|
||||
{t("hosts.pasteKey")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="upload" className="mt-4">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>
|
||||
{t("credentials.sshPrivateKey")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative inline-block">
|
||||
<input
|
||||
id="key-upload"
|
||||
type="file"
|
||||
accept=".pem,.key,.txt,.ppk"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
field.onChange(file || null);
|
||||
}}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="justify-start text-left"
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={
|
||||
field.value?.name ||
|
||||
t("credentials.upload")
|
||||
}
|
||||
>
|
||||
{field.value === "existing_key"
|
||||
? t("hosts.existingKey")
|
||||
: field.value
|
||||
? editingCredential
|
||||
? t("credentials.updateKey")
|
||||
: field.value.name
|
||||
: t("credentials.upload")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-15 gap-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-8">
|
||||
<FormLabel>
|
||||
{t("credentials.keyPassword")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t("placeholders.keyPassword")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative col-span-3">
|
||||
<FormLabel>
|
||||
{t("credentials.keyType")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={keyTypeButtonRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
|
||||
onClick={() =>
|
||||
setKeyTypeDropdownOpen((open) => !open)
|
||||
}
|
||||
>
|
||||
{keyTypeOptions.find(
|
||||
(opt) => opt.value === field.value,
|
||||
)?.label || t("credentials.keyTypeRSA")}
|
||||
</Button>
|
||||
{keyTypeDropdownOpen && (
|
||||
<div
|
||||
ref={keyTypeDropdownRef}
|
||||
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
{keyTypeOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-dark-bg text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
||||
onClick={() => {
|
||||
field.onChange(opt.value);
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="paste" className="mt-4">
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="key"
|
||||
render={({ field }) => (
|
||||
<FormItem className="mb-4">
|
||||
<FormLabel>
|
||||
{t("credentials.sshPrivateKey")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<textarea
|
||||
placeholder={t(
|
||||
"placeholders.pastePrivateKey",
|
||||
)}
|
||||
className="flex min-h-[120px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
value={
|
||||
typeof field.value === "string"
|
||||
? field.value
|
||||
: ""
|
||||
}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.value)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-15 gap-4 mt-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-8">
|
||||
<FormLabel>
|
||||
{t("credentials.keyPassword")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<PasswordInput
|
||||
placeholder={t("placeholders.keyPassword")}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="keyType"
|
||||
render={({ field }) => (
|
||||
<FormItem className="relative col-span-3">
|
||||
<FormLabel>
|
||||
{t("credentials.keyType")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={keyTypeButtonRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-2 bg-dark-bg border border-input text-foreground"
|
||||
onClick={() =>
|
||||
setKeyTypeDropdownOpen((open) => !open)
|
||||
}
|
||||
>
|
||||
{keyTypeOptions.find(
|
||||
(opt) => opt.value === field.value,
|
||||
)?.label || t("credentials.keyTypeRSA")}
|
||||
</Button>
|
||||
{keyTypeDropdownOpen && (
|
||||
<div
|
||||
ref={keyTypeDropdownRef}
|
||||
className="absolute bottom-full left-0 z-50 mb-1 w-full bg-dark-bg border border-input rounded-md shadow-lg max-h-40 overflow-y-auto p-1"
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-1 p-0">
|
||||
{keyTypeOptions.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded-md px-2 py-1.5 bg-dark-bg text-foreground hover:bg-white/15 focus:bg-white/20 focus:outline-none"
|
||||
onClick={() => {
|
||||
field.onChange(opt.value);
|
||||
setKeyTypeDropdownOpen(false);
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
<footer className="shrink-0 w-full pb-0">
|
||||
<Separator className="p-0.25" />
|
||||
<Button className="translate-y-2" type="submit" variant="outline">
|
||||
{editingCredential
|
||||
? t("credentials.updateCredential")
|
||||
: t("credentials.addCredential")}
|
||||
</Button>
|
||||
</footer>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
src/ui/Desktop/Apps/Credentials/CredentialSelector.tsx
Normal file
226
src/ui/Desktop/Apps/Credentials/CredentialSelector.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { FormControl, FormItem, FormLabel } from "@/components/ui/form.tsx";
|
||||
import { getCredentials } from "@/ui/main-axios.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Credential } from "../../../../types";
|
||||
|
||||
interface CredentialSelectorProps {
|
||||
value?: number | null;
|
||||
onValueChange: (credentialId: number | null) => void;
|
||||
onCredentialSelect?: (credential: Credential | null) => void;
|
||||
}
|
||||
|
||||
export function CredentialSelector({
|
||||
value,
|
||||
onValueChange,
|
||||
onCredentialSelect,
|
||||
}: CredentialSelectorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCredentials = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getCredentials();
|
||||
const credentialsArray = Array.isArray(data)
|
||||
? data
|
||||
: data.credentials || data.data || [];
|
||||
setCredentials(credentialsArray);
|
||||
} catch (error) {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error(t("credentials.failedToFetchCredentials"));
|
||||
setCredentials([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCredentials();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.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 selectedCredential = credentials.find((c) => c.id === value);
|
||||
|
||||
const filteredCredentials = credentials.filter((credential) => {
|
||||
if (!searchQuery) return true;
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
return (
|
||||
credential.name.toLowerCase().includes(searchLower) ||
|
||||
credential.username.toLowerCase().includes(searchLower) ||
|
||||
(credential.folder &&
|
||||
credential.folder.toLowerCase().includes(searchLower))
|
||||
);
|
||||
});
|
||||
|
||||
const handleCredentialSelect = (credential: Credential) => {
|
||||
onValueChange(credential.id);
|
||||
if (onCredentialSelect) {
|
||||
onCredentialSelect(credential);
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
onValueChange(null);
|
||||
if (onCredentialSelect) {
|
||||
onCredentialSelect(null);
|
||||
}
|
||||
setDropdownOpen(false);
|
||||
setSearchQuery("");
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{t("hosts.selectCredential")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<Button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full justify-between text-left rounded-lg px-3 py-2 bg-muted/50 focus:bg-background focus:ring-1 focus:ring-ring border border-border text-foreground transition-all duration-200"
|
||||
onClick={() => setDropdownOpen(!dropdownOpen)}
|
||||
>
|
||||
{loading ? (
|
||||
t("common.loading")
|
||||
) : value === "existing_credential" ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<span className="font-medium">
|
||||
{t("hosts.existingCredential")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedCredential ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div>
|
||||
<span className="font-medium">{selectedCredential.name}</span>
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
({selectedCredential.username} •{" "}
|
||||
{selectedCredential.authType})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
t("hosts.selectCredentialPlaceholder")
|
||||
)}
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
|
||||
{dropdownOpen && (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className="absolute top-full left-0 z-50 mt-1 w-full bg-card border border-border rounded-lg shadow-lg max-h-80 overflow-hidden backdrop-blur-sm"
|
||||
>
|
||||
<div className="p-2 border-b border-border">
|
||||
<Input
|
||||
placeholder={t("credentials.searchCredentials")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto p-2">
|
||||
{loading ? (
|
||||
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : filteredCredentials.length === 0 ? (
|
||||
<div className="p-3 text-center text-sm text-muted-foreground">
|
||||
{searchQuery
|
||||
? t("credentials.noCredentialsMatchFilters")
|
||||
: t("credentials.noCredentialsYet")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2.5">
|
||||
{value && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full justify-start text-left rounded-lg px-3 py-2 text-destructive hover:bg-destructive/10 transition-colors duration-200"
|
||||
onClick={handleClear}
|
||||
>
|
||||
{t("common.clear")}
|
||||
</Button>
|
||||
)}
|
||||
{filteredCredentials.map((credential) => (
|
||||
<Button
|
||||
key={credential.id}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className={`w-full justify-start text-left rounded-lg px-3 py-7 hover:bg-muted focus:bg-muted focus:outline-none transition-colors duration-200 ${
|
||||
credential.id === value ? "bg-muted" : ""
|
||||
}`}
|
||||
onClick={() => handleCredentialSelect(credential)}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">
|
||||
{credential.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">
|
||||
{credential.username} • {credential.authType}
|
||||
{credential.description &&
|
||||
` • ${credential.description}`}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
533
src/ui/Desktop/Apps/Credentials/CredentialViewer.tsx
Normal file
533
src/ui/Desktop/Apps/Credentials/CredentialViewer.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import {
|
||||
Key,
|
||||
User,
|
||||
Calendar,
|
||||
Hash,
|
||||
Folder,
|
||||
Edit3,
|
||||
Copy,
|
||||
Shield,
|
||||
Clock,
|
||||
Server,
|
||||
Eye,
|
||||
EyeOff,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
import { getCredentialDetails, getCredentialHosts } from "@/ui/main-axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
Credential,
|
||||
HostInfo,
|
||||
CredentialViewerProps,
|
||||
} from "../../../types/index.js";
|
||||
|
||||
const CredentialViewer: React.FC<CredentialViewerProps> = ({
|
||||
credential,
|
||||
onClose,
|
||||
onEdit,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [credentialDetails, setCredentialDetails] = useState<Credential | null>(
|
||||
null,
|
||||
);
|
||||
const [hostsUsing, setHostsUsing] = useState<HostInfo[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showSensitive, setShowSensitive] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "security" | "usage">(
|
||||
"overview",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentialDetails();
|
||||
fetchHostsUsing();
|
||||
}, [credential.id]);
|
||||
|
||||
const fetchCredentialDetails = async () => {
|
||||
try {
|
||||
const response = await getCredentialDetails(credential.id);
|
||||
setCredentialDetails(response);
|
||||
} catch (error) {
|
||||
toast.error(t("credentials.failedToFetchCredentialDetails"));
|
||||
}
|
||||
};
|
||||
|
||||
const fetchHostsUsing = async () => {
|
||||
try {
|
||||
const response = await getCredentialHosts(credential.id);
|
||||
setHostsUsing(response);
|
||||
} catch (error) {
|
||||
toast.error(t("credentials.failedToFetchHostsUsing"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleSensitiveVisibility = (field: string) => {
|
||||
setShowSensitive((prev) => ({
|
||||
...prev,
|
||||
[field]: !prev[field],
|
||||
}));
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, fieldName: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
toast.success(t("copiedToClipboard", { field: fieldName }));
|
||||
} catch (error) {
|
||||
toast.error(t("credentials.failedToCopy"));
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString();
|
||||
};
|
||||
|
||||
const getAuthIcon = (authType: string) => {
|
||||
return authType === "password" ? (
|
||||
<Key className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
) : (
|
||||
<Shield className="h-5 w-5 text-zinc-500 dark:text-zinc-400" />
|
||||
);
|
||||
};
|
||||
|
||||
const renderSensitiveField = (
|
||||
value: string | undefined,
|
||||
fieldName: string,
|
||||
label: string,
|
||||
isMultiline = false,
|
||||
) => {
|
||||
if (!value) return null;
|
||||
|
||||
const isVisible = showSensitive[fieldName];
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleSensitiveVisibility(fieldName)}
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(value, label)}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`p-3 rounded-md bg-zinc-800 dark:bg-zinc-800 ${isMultiline ? "" : "min-h-[2.5rem]"}`}
|
||||
>
|
||||
{isVisible ? (
|
||||
<pre
|
||||
className={`text-sm ${isMultiline ? "whitespace-pre-wrap" : "whitespace-nowrap"} font-mono`}
|
||||
>
|
||||
{value}
|
||||
</pre>
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{"•".repeat(isMultiline ? 50 : 20)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading || !credentialDetails) {
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[600px] max-w-[50vw]">
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-zinc-600"></div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={true} onOpenChange={onClose}>
|
||||
<SheetContent className="w-[600px] max-w-[50vw] overflow-y-auto">
|
||||
<SheetHeader className="space-y-6 pb-8">
|
||||
<SheetTitle className="flex items-center space-x-4">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
{getAuthIcon(credentialDetails.authType)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-xl font-semibold">
|
||||
{credentialDetails.name}
|
||||
</div>
|
||||
<div className="text-sm font-normal text-zinc-600 dark:text-zinc-400 mt-1">
|
||||
{credentialDetails.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-zinc-300 dark:border-zinc-600 text-zinc-600 dark:text-zinc-400"
|
||||
>
|
||||
{credentialDetails.authType}
|
||||
</Badge>
|
||||
{credentialDetails.keyType && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-zinc-100 dark:bg-zinc-800 text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
{credentialDetails.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-10">
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex space-x-2 p-2 bg-zinc-100 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg">
|
||||
<Button
|
||||
variant={activeTab === "overview" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab("overview")}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<FileText className="h-4 w-4 mr-2" />
|
||||
{t("credentials.overview")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === "security" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab("security")}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
{t("credentials.security")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={activeTab === "usage" ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setActiveTab("usage")}
|
||||
className="flex-1 h-10"
|
||||
>
|
||||
<Server className="h-4 w-4 mr-2" />
|
||||
{t("credentials.usage")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="grid gap-10 lg:grid-cols-2">
|
||||
<Card className="border-zinc-200 dark:border-zinc-700">
|
||||
<CardHeader className="pb-8">
|
||||
<CardTitle className="text-lg font-semibold">
|
||||
{t("credentials.basicInformation")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-8">
|
||||
<div className="flex items-center space-x-5">
|
||||
<div className="p-2 rounded-lg bg-zinc-100 dark:bg-zinc-800">
|
||||
<User className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t("common.username")}
|
||||
</div>
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{credentialDetails.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.folder && (
|
||||
<div className="flex items-center space-x-4">
|
||||
<Folder className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t("common.folder")}
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
{credentialDetails.folder}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialDetails.tags.length > 0 && (
|
||||
<div className="flex items-start space-x-4">
|
||||
<Hash className="h-4 w-4 text-zinc-500 dark:text-zinc-400 mt-1" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400 mb-3">
|
||||
{t("hosts.tags")}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{credentialDetails.tags.map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="text-xs"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t("credentials.created")}
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
{formatDate(credentialDetails.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<Calendar className="h-4 w-4 text-zinc-500 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t("credentials.lastModified")}
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
{formatDate(credentialDetails.updatedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
{t("credentials.usageStatistics")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="text-center p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<div className="text-3xl font-bold text-zinc-600 dark:text-zinc-400">
|
||||
{credentialDetails.usageCount}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-600 dark:text-zinc-400">
|
||||
{t("credentials.timesUsed")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.lastUsed && (
|
||||
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Clock className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t("credentials.lastUsed")}
|
||||
</div>
|
||||
<div className="font-medium">
|
||||
{formatDate(credentialDetails.lastUsed)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-4 p-4 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{t("credentials.connectedHosts")}
|
||||
</div>
|
||||
<div className="font-medium">{hostsUsing.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "security" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Shield className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<span>{t("credentials.securityDetails")}</span>
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("credentials.securityDetailsDescription")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-zinc-600 dark:text-zinc-400" />
|
||||
<div>
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200">
|
||||
{t("credentials.credentialSecured")}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300">
|
||||
{t("credentials.credentialSecuredDescription")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{credentialDetails.authType === "password" && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-4">
|
||||
{t("credentials.passwordAuthentication")}
|
||||
</h3>
|
||||
{renderSensitiveField(
|
||||
credentialDetails.password,
|
||||
"password",
|
||||
t("common.password"),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentialDetails.authType === "key" && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="font-semibold mb-2">
|
||||
{t("credentials.keyAuthentication")}
|
||||
</h3>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-zinc-700 dark:text-zinc-300 mb-3">
|
||||
{t("credentials.keyType")}
|
||||
</div>
|
||||
<Badge variant="outline" className="text-sm">
|
||||
{credentialDetails.keyType?.toUpperCase() ||
|
||||
t("unknown").toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderSensitiveField(
|
||||
credentialDetails.key,
|
||||
"key",
|
||||
t("credentials.privateKey"),
|
||||
true,
|
||||
)}
|
||||
|
||||
{credentialDetails.keyPassword &&
|
||||
renderSensitiveField(
|
||||
credentialDetails.keyPassword,
|
||||
"keyPassword",
|
||||
t("credentials.keyPassphrase"),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-start space-x-4 p-6 bg-zinc-900/20 dark:bg-zinc-900/20 rounded-lg">
|
||||
<AlertTriangle className="h-5 w-5 text-zinc-600 dark:text-zinc-400 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<div className="font-medium text-zinc-800 dark:text-zinc-200 mb-2">
|
||||
{t("credentials.securityReminder")}
|
||||
</div>
|
||||
<div className="text-zinc-700 dark:text-zinc-300">
|
||||
{t("credentials.securityReminderText")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === "usage" && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center space-x-2">
|
||||
<Server className="h-5 w-5 text-zinc-600 dark:text-zinc-400" />
|
||||
<span>{t("credentials.hostsUsingCredential")}</span>
|
||||
<Badge variant="secondary">{hostsUsing.length}</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hostsUsing.length === 0 ? (
|
||||
<div className="text-center py-10 text-zinc-500 dark:text-zinc-400">
|
||||
<Server className="h-12 w-12 mx-auto mb-6 text-zinc-300 dark:text-zinc-600" />
|
||||
<p>{t("credentials.noHostsUsingCredential")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-3">
|
||||
{hostsUsing.map((host) => (
|
||||
<div
|
||||
key={host.id}
|
||||
className="flex items-center justify-between p-4 border rounded-lg hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2 bg-zinc-100 dark:bg-zinc-800 rounded">
|
||||
<Server className="h-4 w-4 text-zinc-600 dark:text-zinc-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{host.name || `${host.ip}:${host.port}`}
|
||||
</div>
|
||||
<div className="text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{host.ip}:{host.port}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{formatDate(host.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SheetFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<Button onClick={onEdit}>
|
||||
<Edit3 className="h-4 w-4 mr-2" />
|
||||
{t("credentials.editCredential")}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default CredentialViewer;
|
||||
692
src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx
Normal file
692
src/ui/Desktop/Apps/Credentials/CredentialsManager.tsx
Normal file
@@ -0,0 +1,692 @@
|
||||
import React, { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import {
|
||||
Search,
|
||||
Key,
|
||||
Folder,
|
||||
Edit,
|
||||
Trash2,
|
||||
Shield,
|
||||
Pin,
|
||||
Tag,
|
||||
Info,
|
||||
FolderMinus,
|
||||
Pencil,
|
||||
X,
|
||||
Check,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
getCredentials,
|
||||
deleteCredential,
|
||||
updateCredential,
|
||||
renameCredentialFolder,
|
||||
} from "@/ui/main-axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import CredentialViewer from "./CredentialViewer";
|
||||
import type {
|
||||
Credential,
|
||||
CredentialsManagerProps,
|
||||
} from "../../../../types/index.js";
|
||||
|
||||
export function CredentialsManager({
|
||||
onEditCredential,
|
||||
}: CredentialsManagerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [showViewer, setShowViewer] = useState(false);
|
||||
const [viewingCredential, setViewingCredential] = useState<Credential | null>(
|
||||
null,
|
||||
);
|
||||
const [draggedCredential, setDraggedCredential] = useState<Credential | null>(
|
||||
null,
|
||||
);
|
||||
const [dragOverFolder, setDragOverFolder] = useState<string | null>(null);
|
||||
const [editingFolder, setEditingFolder] = useState<string | null>(null);
|
||||
const [editingFolderName, setEditingFolderName] = useState("");
|
||||
const [operationLoading, setOperationLoading] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCredentials();
|
||||
}, []);
|
||||
|
||||
const fetchCredentials = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await getCredentials();
|
||||
setCredentials(data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(t("credentials.failedToFetchCredentials"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (credential: Credential) => {
|
||||
if (onEditCredential) {
|
||||
onEditCredential(credential);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (credentialId: number, credentialName: string) => {
|
||||
confirmWithToast(
|
||||
t("credentials.confirmDeleteCredential", { name: credentialName }),
|
||||
async () => {
|
||||
try {
|
||||
await deleteCredential(credentialId);
|
||||
toast.success(
|
||||
t("credentials.credentialDeletedSuccessfully", {
|
||||
name: credentialName,
|
||||
}),
|
||||
);
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent("credentials:changed"));
|
||||
} catch (err: any) {
|
||||
if (err.response?.data?.details) {
|
||||
toast.error(
|
||||
`${err.response.data.error}\n${err.response.data.details}`,
|
||||
);
|
||||
} else {
|
||||
toast.error(t("credentials.failedToDeleteCredential"));
|
||||
}
|
||||
}
|
||||
},
|
||||
"destructive",
|
||||
);
|
||||
};
|
||||
|
||||
const handleRemoveFromFolder = async (credential: Credential) => {
|
||||
confirmWithToast(
|
||||
t("credentials.confirmRemoveFromFolder", {
|
||||
name: credential.name || credential.username,
|
||||
folder: credential.folder,
|
||||
}),
|
||||
async () => {
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
const updatedCredential = { ...credential, folder: "" };
|
||||
await updateCredential(credential.id, updatedCredential);
|
||||
toast.success(
|
||||
t("credentials.removedFromFolder", {
|
||||
name: credential.name || credential.username,
|
||||
}),
|
||||
);
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent("credentials:changed"));
|
||||
} catch (err) {
|
||||
toast.error(t("credentials.failedToRemoveFromFolder"));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const handleFolderRename = async (oldName: string) => {
|
||||
if (!editingFolderName.trim() || editingFolderName === oldName) {
|
||||
setEditingFolder(null);
|
||||
setEditingFolderName("");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
await renameCredentialFolder(oldName, editingFolderName.trim());
|
||||
toast.success(
|
||||
t("credentials.folderRenamed", {
|
||||
oldName,
|
||||
newName: editingFolderName.trim(),
|
||||
}),
|
||||
);
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent("credentials:changed"));
|
||||
setEditingFolder(null);
|
||||
setEditingFolderName("");
|
||||
} catch (err) {
|
||||
toast.error(t("credentials.failedToRenameFolder"));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startFolderEdit = (folderName: string) => {
|
||||
setEditingFolder(folderName);
|
||||
setEditingFolderName(folderName);
|
||||
};
|
||||
|
||||
const cancelFolderEdit = () => {
|
||||
setEditingFolder(null);
|
||||
setEditingFolderName("");
|
||||
};
|
||||
|
||||
const handleDragStart = (e: React.DragEvent, credential: Credential) => {
|
||||
setDraggedCredential(credential);
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", "");
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
setDraggedCredential(null);
|
||||
setDragOverFolder(null);
|
||||
dragCounter.current = 0;
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
};
|
||||
|
||||
const handleDragEnter = (e: React.DragEvent, folderName: string) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current++;
|
||||
setDragOverFolder(folderName);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) {
|
||||
setDragOverFolder(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent, targetFolder: string) => {
|
||||
e.preventDefault();
|
||||
dragCounter.current = 0;
|
||||
setDragOverFolder(null);
|
||||
|
||||
if (!draggedCredential) return;
|
||||
|
||||
const newFolder =
|
||||
targetFolder === t("credentials.uncategorized") ? "" : targetFolder;
|
||||
|
||||
if (draggedCredential.folder === newFolder) {
|
||||
setDraggedCredential(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setOperationLoading(true);
|
||||
const updatedCredential = { ...draggedCredential, folder: newFolder };
|
||||
await updateCredential(draggedCredential.id, updatedCredential);
|
||||
toast.success(
|
||||
t("credentials.movedToFolder", {
|
||||
name: draggedCredential.name || draggedCredential.username,
|
||||
folder: targetFolder,
|
||||
}),
|
||||
);
|
||||
await fetchCredentials();
|
||||
window.dispatchEvent(new CustomEvent("credentials:changed"));
|
||||
} catch (err) {
|
||||
toast.error(t("credentials.failedToMoveToFolder"));
|
||||
} finally {
|
||||
setOperationLoading(false);
|
||||
setDraggedCredential(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredAndSortedCredentials = useMemo(() => {
|
||||
let filtered = credentials;
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = credentials.filter((credential) => {
|
||||
const searchableText = [
|
||||
credential.name || "",
|
||||
credential.username,
|
||||
credential.description || "",
|
||||
...(credential.tags || []),
|
||||
credential.authType,
|
||||
credential.keyType || "",
|
||||
]
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return searchableText.includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
return filtered.sort((a, b) => {
|
||||
const aName = a.name || a.username;
|
||||
const bName = b.name || b.username;
|
||||
return aName.localeCompare(bName);
|
||||
});
|
||||
}, [credentials, searchQuery]);
|
||||
|
||||
const credentialsByFolder = useMemo(() => {
|
||||
const grouped: { [key: string]: Credential[] } = {};
|
||||
|
||||
filteredAndSortedCredentials.forEach((credential) => {
|
||||
const folder = credential.folder || t("credentials.uncategorized");
|
||||
if (!grouped[folder]) {
|
||||
grouped[folder] = [];
|
||||
}
|
||||
grouped[folder].push(credential);
|
||||
});
|
||||
|
||||
const sortedFolders = Object.keys(grouped).sort((a, b) => {
|
||||
if (a === t("credentials.uncategorized")) return -1;
|
||||
if (b === t("credentials.uncategorized")) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const sortedGrouped: { [key: string]: Credential[] } = {};
|
||||
sortedFolders.forEach((folder) => {
|
||||
sortedGrouped[folder] = grouped[folder];
|
||||
});
|
||||
|
||||
return sortedGrouped;
|
||||
}, [filteredAndSortedCredentials, t]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-2"></div>
|
||||
<p className="text-muted-foreground">
|
||||
{t("credentials.loadingCredentials")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<Button onClick={fetchCredentials} variant="outline">
|
||||
{t("credentials.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (credentials.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("credentials.sshCredentials")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t("credentials.credentialsCount", { count: 0 })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={fetchCredentials} variant="outline" size="sm">
|
||||
{t("credentials.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center flex-1">
|
||||
<div className="text-center">
|
||||
<Key className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
{t("credentials.noCredentials")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t("credentials.noCredentialsMessage")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">
|
||||
{t("credentials.sshCredentials")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t("credentials.credentialsCount", {
|
||||
count: filteredAndSortedCredentials.length,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={fetchCredentials} variant="outline" size="sm">
|
||||
{t("credentials.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder={t("placeholders.searchCredentials")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="space-y-2 pb-20">
|
||||
{Object.entries(credentialsByFolder).map(
|
||||
([folder, folderCredentials]) => (
|
||||
<div
|
||||
key={folder}
|
||||
className={`border rounded-md transition-all duration-200 ${
|
||||
dragOverFolder === folder
|
||||
? "border-blue-500 bg-blue-500/10"
|
||||
: ""
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={(e) => handleDragEnter(e, folder)}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={(e) => handleDrop(e, folder)}
|
||||
>
|
||||
<Accordion
|
||||
type="multiple"
|
||||
defaultValue={Object.keys(credentialsByFolder)}
|
||||
>
|
||||
<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" />
|
||||
{editingFolder === folder ? (
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Input
|
||||
value={editingFolderName}
|
||||
onChange={(e) =>
|
||||
setEditingFolderName(e.target.value)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter")
|
||||
handleFolderRename(folder);
|
||||
if (e.key === "Escape") cancelFolderEdit();
|
||||
}}
|
||||
className="h-6 text-sm px-2 flex-1"
|
||||
autoFocus
|
||||
disabled={operationLoading}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleFolderRename(folder);
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
cancelFolderEdit();
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<span
|
||||
className="font-medium cursor-pointer hover:text-blue-400 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (folder !== t("credentials.uncategorized")) {
|
||||
startFolderEdit(folder);
|
||||
}
|
||||
}}
|
||||
title={
|
||||
folder !== t("credentials.uncategorized")
|
||||
? "Click to rename folder"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{folder}
|
||||
</span>
|
||||
{folder !== t("credentials.uncategorized") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startFolderEdit(folder);
|
||||
}}
|
||||
className="h-4 w-4 p-0 opacity-50 hover:opacity-100 transition-opacity"
|
||||
title="Rename folder"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{folderCredentials.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="p-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{folderCredentials.map((credential) => (
|
||||
<TooltipProvider key={credential.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
handleDragStart(e, credential)
|
||||
}
|
||||
onDragEnd={handleDragEnd}
|
||||
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 relative ${
|
||||
draggedCredential?.id === credential.id
|
||||
? "opacity-50 scale-95"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleEdit(credential)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1">
|
||||
<h3 className="font-medium truncate text-sm">
|
||||
{credential.name ||
|
||||
`${credential.username}`}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.username}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{credential.authType === "password"
|
||||
? t("credentials.password")
|
||||
: t("credentials.sshKey")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1 flex-shrink-0 ml-1">
|
||||
{credential.folder &&
|
||||
credential.folder !== "" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveFromFolder(
|
||||
credential,
|
||||
);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-orange-500 hover:text-orange-700 hover:bg-orange-500/10"
|
||||
disabled={operationLoading}
|
||||
>
|
||||
<FolderMinus className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Remove from folder "
|
||||
{credential.folder}"
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(credential);
|
||||
}}
|
||||
className="h-5 w-5 p-0 hover:bg-blue-500/10"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit credential</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(
|
||||
credential.id,
|
||||
credential.name ||
|
||||
credential.username,
|
||||
);
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700 hover:bg-red-500/10"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete credential</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
{credential.tags &&
|
||||
credential.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{credential.tags
|
||||
.slice(0, 6)
|
||||
.map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Tag className="h-2 w-2 mr-0.5" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{credential.tags.length > 6 && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
+{credential.tags.length - 6}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
{credential.authType === "password" ? (
|
||||
<Key className="h-2 w-2 mr-0.5" />
|
||||
) : (
|
||||
<Shield className="h-2 w-2 mr-0.5" />
|
||||
)}
|
||||
{credential.authType}
|
||||
</Badge>
|
||||
{credential.authType === "key" &&
|
||||
credential.keyType && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
{credential.keyType}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-center">
|
||||
<p className="font-medium">
|
||||
Click to edit credential
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Drag to move between folders
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{showViewer && viewingCredential && (
|
||||
<CredentialViewer
|
||||
credential={viewingCredential}
|
||||
onClose={() => setShowViewer(false)}
|
||||
onEdit={() => {
|
||||
setShowViewer(false);
|
||||
handleEdit(viewingCredential);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx
Normal file
26
src/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from "react";
|
||||
import { FileManagerTabList } from "./FileManagerTabList.tsx";
|
||||
|
||||
interface FileManagerTopNavbarProps {
|
||||
tabs: { id: string | number; title: string }[];
|
||||
activeTab: string | number;
|
||||
setActiveTab: (tab: string | number) => void;
|
||||
closeTab: (tab: string | number) => void;
|
||||
onHomeClick: () => void;
|
||||
}
|
||||
|
||||
export function FIleManagerTopNavbar(
|
||||
props: FileManagerTopNavbarProps,
|
||||
): React.ReactElement {
|
||||
const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props;
|
||||
|
||||
return (
|
||||
<FileManagerTabList
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
closeTab={closeTab}
|
||||
onHomeClick={onHomeClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
713
src/ui/Desktop/Apps/File Manager/FileManager.tsx
Normal file
713
src/ui/Desktop/Apps/File Manager/FileManager.tsx
Normal file
@@ -0,0 +1,713 @@
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { FileManagerLeftSidebar } from "@/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx";
|
||||
import { FileManagerHomeView } from "@/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx";
|
||||
import { FileManagerFileEditor } from "@/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx";
|
||||
import { FileManagerOperations } from "@/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { FIleManagerTopNavbar } from "@/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { Save, RefreshCw, Settings, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
getFileManagerRecent,
|
||||
getFileManagerPinned,
|
||||
getFileManagerShortcuts,
|
||||
addFileManagerRecent,
|
||||
removeFileManagerRecent,
|
||||
addFileManagerPinned,
|
||||
removeFileManagerPinned,
|
||||
addFileManagerShortcut,
|
||||
removeFileManagerShortcut,
|
||||
readSSHFile,
|
||||
writeSSHFile,
|
||||
getSSHStatus,
|
||||
connectSSH,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { SSHHost, Tab } from "../../../types/index.js";
|
||||
|
||||
export function FileManager({
|
||||
onSelectView,
|
||||
initialHost = null,
|
||||
onClose,
|
||||
}: {
|
||||
onSelectView?: (view: string) => void;
|
||||
embedded?: boolean;
|
||||
initialHost?: SSHHost | null;
|
||||
onClose?: () => void;
|
||||
}): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [tabs, setTabs] = useState<Tab[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<string | number>("home");
|
||||
const [recent, setRecent] = useState<any[]>([]);
|
||||
const [pinned, setPinned] = useState<any[]>([]);
|
||||
const [shortcuts, setShortcuts] = useState<any[]>([]);
|
||||
|
||||
const [currentHost, setCurrentHost] = useState<SSHHost | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const [showOperations, setShowOperations] = useState(false);
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
|
||||
const [deletingItem, setDeletingItem] = useState<any | null>(null);
|
||||
|
||||
const sidebarRef = useRef<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialHost && (!currentHost || currentHost.id !== initialHost.id)) {
|
||||
setCurrentHost(initialHost);
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const path = initialHost.defaultPath || "/";
|
||||
if (sidebarRef.current && sidebarRef.current.openFolder) {
|
||||
sidebarRef.current.openFolder(initialHost, path);
|
||||
}
|
||||
} catch (e) {}
|
||||
}, 0);
|
||||
}
|
||||
}, [initialHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentHost) {
|
||||
fetchHomeData();
|
||||
} else {
|
||||
setRecent([]);
|
||||
setPinned([]);
|
||||
setShortcuts([]);
|
||||
}
|
||||
}, [currentHost]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "home" && currentHost) {
|
||||
const interval = setInterval(() => {
|
||||
fetchHomeData();
|
||||
}, 2000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [activeTab, currentHost]);
|
||||
|
||||
async function fetchHomeData() {
|
||||
if (!currentHost) return;
|
||||
|
||||
try {
|
||||
const homeDataPromise = Promise.all([
|
||||
getFileManagerRecent(currentHost.id),
|
||||
getFileManagerPinned(currentHost.id),
|
||||
getFileManagerShortcuts(currentHost.id),
|
||||
]);
|
||||
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(t("fileManager.fetchHomeDataTimeout"))),
|
||||
15000,
|
||||
),
|
||||
);
|
||||
|
||||
const [recentRes, pinnedRes, shortcutsRes] = (await Promise.race([
|
||||
homeDataPromise,
|
||||
timeoutPromise,
|
||||
])) as [any, any, any];
|
||||
|
||||
const recentWithPinnedStatus = (recentRes || []).map((file) => ({
|
||||
...file,
|
||||
type: "file",
|
||||
isPinned: (pinnedRes || []).some(
|
||||
(pinnedFile) =>
|
||||
pinnedFile.path === file.path && pinnedFile.name === file.name,
|
||||
),
|
||||
}));
|
||||
|
||||
const pinnedWithType = (pinnedRes || []).map((file) => ({
|
||||
...file,
|
||||
type: "file",
|
||||
}));
|
||||
|
||||
setRecent(recentWithPinnedStatus);
|
||||
setPinned(pinnedWithType);
|
||||
setShortcuts(
|
||||
(shortcutsRes || []).map((shortcut) => ({
|
||||
...shortcut,
|
||||
type: "directory",
|
||||
})),
|
||||
);
|
||||
} catch (err: any) {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error(t("fileManager.failedToFetchHomeData"));
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const formatErrorMessage = (err: any, defaultMessage: string): string => {
|
||||
if (typeof err === "object" && err !== null && "response" in err) {
|
||||
const axiosErr = err as any;
|
||||
if (axiosErr.response?.status === 403) {
|
||||
return `${t("fileManager.permissionDenied")}. ${defaultMessage}. ${t("fileManager.checkDockerLogs")}.`;
|
||||
} else if (axiosErr.response?.status === 500) {
|
||||
const backendError =
|
||||
axiosErr.response?.data?.error ||
|
||||
t("fileManager.internalServerError");
|
||||
return `${t("fileManager.serverError")} (500): ${backendError}. ${t("fileManager.checkDockerLogs")}.`;
|
||||
} else if (axiosErr.response?.data?.error) {
|
||||
const backendError = axiosErr.response.data.error;
|
||||
return `${axiosErr.response?.status ? `${t("fileManager.error")} ${axiosErr.response.status}: ` : ""}${backendError}. ${t("fileManager.checkDockerLogs")}.`;
|
||||
} else {
|
||||
return `${t("fileManager.requestFailed")} ${axiosErr.response?.status || t("fileManager.unknown")}. ${t("fileManager.checkDockerLogs")}.`;
|
||||
}
|
||||
} else if (err instanceof Error) {
|
||||
return `${err.message}. ${t("fileManager.checkDockerLogs")}.`;
|
||||
} else {
|
||||
return `${defaultMessage}. ${t("fileManager.checkDockerLogs")}.`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFile = async (file: any) => {
|
||||
const tabId = file.path;
|
||||
|
||||
if (!tabs.find((t) => t.id === tabId)) {
|
||||
const currentSshSessionId = currentHost?.id.toString();
|
||||
|
||||
setTabs([
|
||||
...tabs,
|
||||
{
|
||||
id: tabId,
|
||||
title: file.name,
|
||||
fileName: file.name,
|
||||
content: "",
|
||||
filePath: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentSshSessionId,
|
||||
loading: true,
|
||||
},
|
||||
]);
|
||||
try {
|
||||
const res = await readSSHFile(currentSshSessionId, file.path);
|
||||
setTabs((tabs) =>
|
||||
tabs.map((t) =>
|
||||
t.id === tabId
|
||||
? {
|
||||
...t,
|
||||
content: res.content,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
await addFileManagerRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentSshSessionId,
|
||||
hostId: currentHost?.id,
|
||||
});
|
||||
} catch (err: any) {
|
||||
const errorMessage = formatErrorMessage(
|
||||
err,
|
||||
t("fileManager.cannotReadFile"),
|
||||
);
|
||||
toast.error(errorMessage);
|
||||
setTabs((tabs) =>
|
||||
tabs.map((t) => (t.id === tabId ? { ...t, loading: false } : t)),
|
||||
);
|
||||
}
|
||||
}
|
||||
setActiveTab(tabId);
|
||||
};
|
||||
|
||||
const handleRemoveRecent = async (file: any) => {
|
||||
try {
|
||||
await removeFileManagerRecent({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id,
|
||||
});
|
||||
fetchHomeData();
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
const handlePinFile = async (file: any) => {
|
||||
try {
|
||||
await addFileManagerPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id,
|
||||
});
|
||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||
sidebarRef.current.fetchFiles();
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
const handleUnpinFile = async (file: any) => {
|
||||
try {
|
||||
await removeFileManagerPinned({
|
||||
name: file.name,
|
||||
path: file.path,
|
||||
isSSH: true,
|
||||
sshSessionId: file.sshSessionId,
|
||||
hostId: currentHost?.id,
|
||||
});
|
||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||
sidebarRef.current.fetchFiles();
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
const handleOpenShortcut = async (shortcut: any) => {
|
||||
if (sidebarRef.current?.isOpeningShortcut) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (sidebarRef.current && sidebarRef.current.openFolder) {
|
||||
try {
|
||||
sidebarRef.current.isOpeningShortcut = true;
|
||||
|
||||
const normalizedPath = shortcut.path.startsWith("/")
|
||||
? shortcut.path
|
||||
: `/${shortcut.path}`;
|
||||
|
||||
await sidebarRef.current.openFolder(currentHost, normalizedPath);
|
||||
} catch (err) {
|
||||
} finally {
|
||||
if (sidebarRef.current) {
|
||||
sidebarRef.current.isOpeningShortcut = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddShortcut = async (folderPath: string) => {
|
||||
try {
|
||||
const name = folderPath.split("/").pop() || folderPath;
|
||||
await addFileManagerShortcut({
|
||||
name,
|
||||
path: folderPath,
|
||||
isSSH: true,
|
||||
sshSessionId: currentHost?.id.toString(),
|
||||
hostId: currentHost?.id,
|
||||
});
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
const handleRemoveShortcut = async (shortcut: any) => {
|
||||
try {
|
||||
await removeFileManagerShortcut({
|
||||
name: shortcut.name,
|
||||
path: shortcut.path,
|
||||
isSSH: true,
|
||||
sshSessionId: currentHost?.id.toString(),
|
||||
hostId: currentHost?.id,
|
||||
});
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
const closeTab = (tabId: string | number) => {
|
||||
const idx = tabs.findIndex((t) => t.id === tabId);
|
||||
const newTabs = tabs.filter((t) => t.id !== tabId);
|
||||
setTabs(newTabs);
|
||||
if (activeTab === tabId) {
|
||||
if (newTabs.length > 0) setActiveTab(newTabs[Math.max(0, idx - 1)].id);
|
||||
else setActiveTab("home");
|
||||
}
|
||||
};
|
||||
|
||||
const setTabContent = (tabId: string | number, content: string) => {
|
||||
setTabs((tabs) =>
|
||||
tabs.map((t) =>
|
||||
t.id === tabId
|
||||
? {
|
||||
...t,
|
||||
content,
|
||||
dirty: true,
|
||||
error: undefined,
|
||||
success: undefined,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const handleSave = async (tab: Tab) => {
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
if (!tab.sshSessionId) {
|
||||
throw new Error(t("fileManager.noSshSessionId"));
|
||||
}
|
||||
|
||||
if (!tab.filePath) {
|
||||
throw new Error(t("fileManager.noFilePath"));
|
||||
}
|
||||
|
||||
if (!currentHost?.id) {
|
||||
throw new Error(t("fileManager.noCurrentHost"));
|
||||
}
|
||||
|
||||
try {
|
||||
const statusPromise = getSSHStatus(tab.sshSessionId);
|
||||
const statusTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(t("fileManager.sshStatusCheckTimeout"))),
|
||||
10000,
|
||||
),
|
||||
);
|
||||
|
||||
const status = (await Promise.race([
|
||||
statusPromise,
|
||||
statusTimeoutPromise,
|
||||
])) as { connected: boolean };
|
||||
|
||||
if (!status.connected) {
|
||||
const connectPromise = connectSSH(tab.sshSessionId, {
|
||||
hostId: currentHost.id,
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username,
|
||||
password: currentHost.password,
|
||||
sshKey: currentHost.key,
|
||||
keyPassword: currentHost.keyPassword,
|
||||
authType: currentHost.authType,
|
||||
credentialId: currentHost.credentialId,
|
||||
userId: currentHost.userId,
|
||||
});
|
||||
const connectTimeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error(t("fileManager.sshReconnectionTimeout"))),
|
||||
15000,
|
||||
),
|
||||
);
|
||||
|
||||
await Promise.race([connectPromise, connectTimeoutPromise]);
|
||||
}
|
||||
} catch (statusErr) {}
|
||||
|
||||
const savePromise = writeSSHFile(
|
||||
tab.sshSessionId,
|
||||
tab.filePath,
|
||||
tab.content,
|
||||
);
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => {
|
||||
reject(new Error(t("fileManager.saveOperationTimeout")));
|
||||
}, 30000),
|
||||
);
|
||||
|
||||
const result = await Promise.race([savePromise, timeoutPromise]);
|
||||
setTabs((tabs) =>
|
||||
tabs.map((t) =>
|
||||
t.id === tab.id
|
||||
? {
|
||||
...t,
|
||||
loading: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
|
||||
if (result?.toast) {
|
||||
toast[result.toast.type](result.toast.message);
|
||||
} else {
|
||||
toast.success(t("fileManager.fileSavedSuccessfully"));
|
||||
}
|
||||
|
||||
Promise.allSettled([
|
||||
(async () => {
|
||||
try {
|
||||
await addFileManagerRecent({
|
||||
name: tab.fileName,
|
||||
path: tab.filePath,
|
||||
isSSH: true,
|
||||
sshSessionId: tab.sshSessionId,
|
||||
hostId: currentHost.id,
|
||||
});
|
||||
} catch (recentErr) {}
|
||||
})(),
|
||||
]).then(() => {});
|
||||
} catch (err) {
|
||||
let errorMessage = formatErrorMessage(
|
||||
err,
|
||||
t("fileManager.cannotSaveFile"),
|
||||
);
|
||||
|
||||
if (
|
||||
errorMessage.includes("timed out") ||
|
||||
errorMessage.includes("timeout")
|
||||
) {
|
||||
errorMessage = t("fileManager.saveTimeout");
|
||||
}
|
||||
|
||||
toast.error(`${t("fileManager.failedToSaveFile")}: ${errorMessage}`);
|
||||
setTabs((tabs) =>
|
||||
tabs.map((t) =>
|
||||
t.id === tab.id
|
||||
? {
|
||||
...t,
|
||||
loading: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHostChange = (_host: SSHHost | null) => {};
|
||||
|
||||
const handleOperationComplete = () => {
|
||||
if (sidebarRef.current && sidebarRef.current.fetchFiles) {
|
||||
sidebarRef.current.fetchFiles();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccess = (message: string) => {
|
||||
toast.success(message);
|
||||
};
|
||||
|
||||
const handleError = (error: string) => {
|
||||
toast.error(error);
|
||||
};
|
||||
|
||||
const updateCurrentPath = (newPath: string) => {
|
||||
setCurrentPath(newPath);
|
||||
};
|
||||
|
||||
const handleDeleteFromSidebar = (item: any) => {
|
||||
setDeletingItem(item);
|
||||
};
|
||||
|
||||
const performDelete = async (item: any) => {
|
||||
if (!currentHost?.id) return;
|
||||
|
||||
try {
|
||||
const { deleteSSHItem } = await import("@/ui/main-axios.ts");
|
||||
const response = await deleteSSHItem(
|
||||
currentHost.id.toString(),
|
||||
item.path,
|
||||
item.type === "directory",
|
||||
);
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
toast.success(
|
||||
`${item.type === "directory" ? t("fileManager.folder") : t("fileManager.file")} ${t("fileManager.deletedSuccessfully")}`,
|
||||
);
|
||||
}
|
||||
|
||||
setDeletingItem(null);
|
||||
handleOperationComplete();
|
||||
} catch (error: any) {
|
||||
handleError(
|
||||
error?.response?.data?.error || t("fileManager.failedToDeleteItem"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentHost) {
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden rounded-md">
|
||||
<div className="absolute top-0 left-0 w-64 h-full z-[20]">
|
||||
<FileManagerLeftSidebar
|
||||
onSelectView={onSelectView || (() => {})}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
host={initialHost as SSHHost}
|
||||
onOperationComplete={handleOperationComplete}
|
||||
onError={handleError}
|
||||
onSuccess={handleSuccess}
|
||||
onPathChange={updateCurrentPath}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-0 left-64 right-0 bottom-0 flex items-center justify-center bg-dark-bg-darkest">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">
|
||||
{t("fileManager.connectToServer")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
{t("fileManager.selectServerToEdit")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 overflow-hidden rounded-md">
|
||||
<div className="absolute top-0 left-0 w-64 h-full z-[20]">
|
||||
<FileManagerLeftSidebar
|
||||
onSelectView={onSelectView || (() => {})}
|
||||
onOpenFile={handleOpenFile}
|
||||
tabs={tabs}
|
||||
ref={sidebarRef}
|
||||
host={currentHost as SSHHost}
|
||||
onOperationComplete={handleOperationComplete}
|
||||
onError={handleError}
|
||||
onSuccess={handleSuccess}
|
||||
onPathChange={updateCurrentPath}
|
||||
onDeleteItem={handleDeleteFromSidebar}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-0 left-64 right-0 h-[50px] z-[30]">
|
||||
<div className="flex items-center w-full bg-dark-bg border-b-2 border-dark-border h-[50px] relative">
|
||||
<div className="h-full p-1 pr-2 border-r-2 border-dark-border w-[calc(100%-6rem)] flex items-center overflow-x-auto overflow-y-hidden gap-2 thin-scrollbar">
|
||||
<FIleManagerTopNavbar
|
||||
tabs={tabs.map((t) => ({ id: t.id, title: t.title }))}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
closeTab={closeTab}
|
||||
onHomeClick={() => {
|
||||
setActiveTab("home");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-2 flex-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowOperations(!showOperations)}
|
||||
className={cn(
|
||||
"w-[30px] h-[30px]",
|
||||
showOperations ? "bg-dark-hover border-dark-border-hover" : "",
|
||||
)}
|
||||
title={t("fileManager.fileOperations")}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="p-0.25 w-px h-[30px] bg-dark-border"></div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const tab = tabs.find((t) => t.id === activeTab);
|
||||
if (tab && !isSaving) handleSave(tab);
|
||||
}}
|
||||
disabled={
|
||||
activeTab === "home" ||
|
||||
!tabs.find((t) => t.id === activeTab)?.dirty ||
|
||||
isSaving
|
||||
}
|
||||
className={cn(
|
||||
"w-[30px] h-[30px]",
|
||||
activeTab === "home" ||
|
||||
!tabs.find((t) => t.id === activeTab)?.dirty ||
|
||||
isSaving
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
{isSaving ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-[44px] left-64 right-0 bottom-0 overflow-hidden z-[10] bg-dark-bg-very-light flex flex-col">
|
||||
<div className="flex h-full">
|
||||
<div className="flex-1">
|
||||
{activeTab === "home" ? (
|
||||
<FileManagerHomeView
|
||||
recent={recent}
|
||||
pinned={pinned}
|
||||
shortcuts={shortcuts}
|
||||
onOpenFile={handleOpenFile}
|
||||
onRemoveRecent={handleRemoveRecent}
|
||||
onPinFile={handlePinFile}
|
||||
onUnpinFile={handleUnpinFile}
|
||||
onOpenShortcut={handleOpenShortcut}
|
||||
onRemoveShortcut={handleRemoveShortcut}
|
||||
onAddShortcut={handleAddShortcut}
|
||||
/>
|
||||
) : (
|
||||
(() => {
|
||||
const tab = tabs.find((t) => t.id === activeTab);
|
||||
if (!tab) return null;
|
||||
return (
|
||||
<div className="flex flex-col h-full flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0">
|
||||
<FileManagerFileEditor
|
||||
content={tab.content}
|
||||
fileName={tab.fileName}
|
||||
onContentChange={(content) =>
|
||||
setTabContent(tab.id, content)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</div>
|
||||
{showOperations && (
|
||||
<div className="w-80 border-l-2 border-dark-border bg-dark-bg-darkest overflow-y-auto">
|
||||
<FileManagerOperations
|
||||
currentPath={currentPath}
|
||||
sshSessionId={currentHost?.id.toString() || null}
|
||||
onOperationComplete={handleOperationComplete}
|
||||
onError={handleError}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{deletingItem && (
|
||||
<div className="fixed inset-0 z-[99999]">
|
||||
<div className="absolute inset-0 bg-black/60"></div>
|
||||
|
||||
<div className="relative h-full flex items-center justify-center">
|
||||
<div className="bg-dark-bg border-2 border-dark-border rounded-lg p-6 max-w-md mx-4 shadow-2xl">
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Trash2 className="w-5 h-5 text-red-400" />
|
||||
{t("fileManager.confirmDelete")}
|
||||
</h3>
|
||||
<p className="text-white mb-4">
|
||||
{t("fileManager.confirmDeleteMessage", {
|
||||
name: deletingItem.name,
|
||||
})}
|
||||
{deletingItem.type === "directory" &&
|
||||
` ${t("fileManager.deleteDirectoryWarning")}`}
|
||||
</p>
|
||||
<p className="text-red-400 text-sm mb-6">
|
||||
{t("fileManager.actionCannotBeUndone")}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => performDelete(deletingItem)}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeletingItem(null)}
|
||||
className="flex-1"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
338
src/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx
Normal file
338
src/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx
Normal file
@@ -0,0 +1,338 @@
|
||||
import React, { useEffect } from "react";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { loadLanguage } from "@uiw/codemirror-extensions-langs";
|
||||
import { hyperLink } from "@uiw/codemirror-extensions-hyper-link";
|
||||
import { oneDark } from "@codemirror/theme-one-dark";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
|
||||
interface FileManagerCodeEditorProps {
|
||||
content: string;
|
||||
fileName: string;
|
||||
onContentChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function FileManagerFileEditor({
|
||||
content,
|
||||
fileName,
|
||||
onContentChange,
|
||||
}: FileManagerCodeEditorProps) {
|
||||
function getLanguageName(filename: string): string {
|
||||
if (!filename || typeof filename !== "string") {
|
||||
return "text";
|
||||
}
|
||||
const lastDotIndex = filename.lastIndexOf(".");
|
||||
if (lastDotIndex === -1) {
|
||||
return "text";
|
||||
}
|
||||
const ext = filename.slice(lastDotIndex + 1).toLowerCase();
|
||||
|
||||
switch (ext) {
|
||||
case "ng":
|
||||
return "angular";
|
||||
case "apl":
|
||||
return "apl";
|
||||
case "asc":
|
||||
return "asciiArmor";
|
||||
case "ast":
|
||||
return "asterisk";
|
||||
case "bf":
|
||||
return "brainfuck";
|
||||
case "c":
|
||||
return "c";
|
||||
case "ceylon":
|
||||
return "ceylon";
|
||||
case "clj":
|
||||
return "clojure";
|
||||
case "cmake":
|
||||
return "cmake";
|
||||
case "cob":
|
||||
case "cbl":
|
||||
return "cobol";
|
||||
case "coffee":
|
||||
return "coffeescript";
|
||||
case "lisp":
|
||||
return "commonLisp";
|
||||
case "cpp":
|
||||
case "cc":
|
||||
case "cxx":
|
||||
return "cpp";
|
||||
case "cr":
|
||||
return "crystal";
|
||||
case "cs":
|
||||
return "csharp";
|
||||
case "css":
|
||||
return "css";
|
||||
case "cypher":
|
||||
return "cypher";
|
||||
case "d":
|
||||
return "d";
|
||||
case "dart":
|
||||
return "dart";
|
||||
case "diff":
|
||||
case "patch":
|
||||
return "diff";
|
||||
case "dockerfile":
|
||||
return "dockerfile";
|
||||
case "dtd":
|
||||
return "dtd";
|
||||
case "dylan":
|
||||
return "dylan";
|
||||
case "ebnf":
|
||||
return "ebnf";
|
||||
case "ecl":
|
||||
return "ecl";
|
||||
case "eiffel":
|
||||
return "eiffel";
|
||||
case "elm":
|
||||
return "elm";
|
||||
case "erl":
|
||||
return "erlang";
|
||||
case "factor":
|
||||
return "factor";
|
||||
case "fcl":
|
||||
return "fcl";
|
||||
case "fs":
|
||||
return "forth";
|
||||
case "f90":
|
||||
case "for":
|
||||
return "fortran";
|
||||
case "s":
|
||||
return "gas";
|
||||
case "feature":
|
||||
return "gherkin";
|
||||
case "go":
|
||||
return "go";
|
||||
case "groovy":
|
||||
return "groovy";
|
||||
case "hs":
|
||||
return "haskell";
|
||||
case "hx":
|
||||
return "haxe";
|
||||
case "html":
|
||||
case "htm":
|
||||
return "html";
|
||||
case "http":
|
||||
return "http";
|
||||
case "idl":
|
||||
return "idl";
|
||||
case "java":
|
||||
return "java";
|
||||
case "js":
|
||||
case "mjs":
|
||||
case "cjs":
|
||||
return "javascript";
|
||||
case "jinja2":
|
||||
case "j2":
|
||||
return "jinja2";
|
||||
case "json":
|
||||
return "json";
|
||||
case "jsx":
|
||||
return "jsx";
|
||||
case "jl":
|
||||
return "julia";
|
||||
case "kt":
|
||||
case "kts":
|
||||
return "kotlin";
|
||||
case "less":
|
||||
return "less";
|
||||
case "lezer":
|
||||
return "lezer";
|
||||
case "liquid":
|
||||
return "liquid";
|
||||
case "litcoffee":
|
||||
return "livescript";
|
||||
case "lua":
|
||||
return "lua";
|
||||
case "md":
|
||||
return "markdown";
|
||||
case "nb":
|
||||
case "mat":
|
||||
return "mathematica";
|
||||
case "mbox":
|
||||
return "mbox";
|
||||
case "mmd":
|
||||
return "mermaid";
|
||||
case "mrc":
|
||||
return "mirc";
|
||||
case "moo":
|
||||
return "modelica";
|
||||
case "mscgen":
|
||||
return "mscgen";
|
||||
case "m":
|
||||
return "mumps";
|
||||
case "sql":
|
||||
return "mysql";
|
||||
case "nc":
|
||||
return "nesC";
|
||||
case "nginx":
|
||||
return "nginx";
|
||||
case "nix":
|
||||
return "nix";
|
||||
case "nsi":
|
||||
return "nsis";
|
||||
case "nt":
|
||||
return "ntriples";
|
||||
case "mm":
|
||||
return "objectiveCpp";
|
||||
case "octave":
|
||||
return "octave";
|
||||
case "oz":
|
||||
return "oz";
|
||||
case "pas":
|
||||
return "pascal";
|
||||
case "pl":
|
||||
case "pm":
|
||||
return "perl";
|
||||
case "pgsql":
|
||||
return "pgsql";
|
||||
case "php":
|
||||
return "php";
|
||||
case "pig":
|
||||
return "pig";
|
||||
case "ps1":
|
||||
return "powershell";
|
||||
case "properties":
|
||||
return "properties";
|
||||
case "proto":
|
||||
return "protobuf";
|
||||
case "pp":
|
||||
return "puppet";
|
||||
case "py":
|
||||
return "python";
|
||||
case "q":
|
||||
return "q";
|
||||
case "r":
|
||||
return "r";
|
||||
case "rb":
|
||||
return "ruby";
|
||||
case "rs":
|
||||
return "rust";
|
||||
case "sas":
|
||||
return "sas";
|
||||
case "sass":
|
||||
case "scss":
|
||||
return "sass";
|
||||
case "scala":
|
||||
return "scala";
|
||||
case "scm":
|
||||
return "scheme";
|
||||
case "shader":
|
||||
return "shader";
|
||||
case "sh":
|
||||
case "bash":
|
||||
return "shell";
|
||||
case "siv":
|
||||
return "sieve";
|
||||
case "st":
|
||||
return "smalltalk";
|
||||
case "sol":
|
||||
return "solidity";
|
||||
case "solr":
|
||||
return "solr";
|
||||
case "rq":
|
||||
return "sparql";
|
||||
case "xlsx":
|
||||
case "ods":
|
||||
case "csv":
|
||||
return "spreadsheet";
|
||||
case "nut":
|
||||
return "squirrel";
|
||||
case "tex":
|
||||
return "stex";
|
||||
case "styl":
|
||||
return "stylus";
|
||||
case "svelte":
|
||||
return "svelte";
|
||||
case "swift":
|
||||
return "swift";
|
||||
case "tcl":
|
||||
return "tcl";
|
||||
case "textile":
|
||||
return "textile";
|
||||
case "tiddlywiki":
|
||||
return "tiddlyWiki";
|
||||
case "tiki":
|
||||
return "tiki";
|
||||
case "toml":
|
||||
return "toml";
|
||||
case "troff":
|
||||
return "troff";
|
||||
case "tsx":
|
||||
return "tsx";
|
||||
case "ttcn":
|
||||
return "ttcn";
|
||||
case "ttl":
|
||||
case "turtle":
|
||||
return "turtle";
|
||||
case "ts":
|
||||
return "typescript";
|
||||
case "vb":
|
||||
return "vb";
|
||||
case "vbs":
|
||||
return "vbscript";
|
||||
case "vm":
|
||||
return "velocity";
|
||||
case "v":
|
||||
return "verilog";
|
||||
case "vhd":
|
||||
case "vhdl":
|
||||
return "vhdl";
|
||||
case "vue":
|
||||
return "vue";
|
||||
case "wat":
|
||||
return "wast";
|
||||
case "webidl":
|
||||
return "webIDL";
|
||||
case "xq":
|
||||
case "xquery":
|
||||
return "xQuery";
|
||||
case "xml":
|
||||
return "xml";
|
||||
case "yacas":
|
||||
return "yacas";
|
||||
case "yaml":
|
||||
case "yml":
|
||||
return "yaml";
|
||||
case "z80":
|
||||
return "z80";
|
||||
default:
|
||||
return "text";
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflowX = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflowX = "";
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full relative overflow-hidden flex flex-col">
|
||||
<div className="w-full h-full overflow-auto flex-1 flex flex-col config-codemirror-scroll-wrapper">
|
||||
<CodeMirror
|
||||
value={content}
|
||||
extensions={[
|
||||
loadLanguage(getLanguageName(fileName || "untitled.txt") as any) ||
|
||||
[],
|
||||
hyperLink,
|
||||
oneDark,
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
backgroundColor: "var(--color-dark-bg-darkest) !important",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--color-dark-bg) !important",
|
||||
},
|
||||
}),
|
||||
]}
|
||||
onChange={(value: any) => onContentChange(value)}
|
||||
theme={undefined}
|
||||
height="100%"
|
||||
basicSetup={{ lineNumbers: true }}
|
||||
className="min-h-full min-w-full flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
234
src/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx
Normal file
234
src/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Trash2, Folder, File, Plus, Pin } from "lucide-react";
|
||||
import {
|
||||
Tabs,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
TabsContent,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem, ShortcutItem } from "../../../types/index";
|
||||
|
||||
interface FileManagerHomeViewProps {
|
||||
recent: FileItem[];
|
||||
pinned: FileItem[];
|
||||
shortcuts: ShortcutItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onRemoveRecent: (file: FileItem) => void;
|
||||
onPinFile: (file: FileItem) => void;
|
||||
onUnpinFile: (file: FileItem) => void;
|
||||
onOpenShortcut: (shortcut: ShortcutItem) => void;
|
||||
onRemoveShortcut: (shortcut: ShortcutItem) => void;
|
||||
onAddShortcut: (path: string) => void;
|
||||
}
|
||||
|
||||
export function FileManagerHomeView({
|
||||
recent,
|
||||
pinned,
|
||||
shortcuts,
|
||||
onOpenFile,
|
||||
onRemoveRecent,
|
||||
onPinFile,
|
||||
onUnpinFile,
|
||||
onOpenShortcut,
|
||||
onRemoveShortcut,
|
||||
onAddShortcut,
|
||||
}: FileManagerHomeViewProps) {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<"recent" | "pinned" | "shortcuts">("recent");
|
||||
const [newShortcut, setNewShortcut] = useState("");
|
||||
|
||||
const renderFileCard = (
|
||||
file: FileItem,
|
||||
onRemove: () => void,
|
||||
onPin?: () => void,
|
||||
isPinned = false,
|
||||
) => (
|
||||
<div
|
||||
key={file.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenFile(file)}
|
||||
>
|
||||
{file.type === "directory" ? (
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{file.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{onPin && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||
onClick={onPin}
|
||||
>
|
||||
<Pin
|
||||
className={`w-3 h-3 ${isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderShortcutCard = (shortcut: ShortcutItem) => (
|
||||
<div
|
||||
key={shortcut.path}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded hover:border-dark-border-hover transition-colors"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() => onOpenShortcut(shortcut)}
|
||||
>
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-white break-words leading-tight">
|
||||
{shortcut.path}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 px-1.5 bg-dark-bg-button hover:bg-dark-hover rounded-md"
|
||||
onClick={() => onRemoveShortcut(shortcut)}
|
||||
>
|
||||
<Trash2 className="w-3 h-3 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 flex flex-col gap-4 h-full bg-dark-bg-darkest">
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as "recent" | "pinned" | "shortcuts")}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="mb-4 bg-dark-bg border-2 border-dark-border">
|
||||
<TabsTrigger
|
||||
value="recent"
|
||||
className="data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
{t("fileManager.recent")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="pinned"
|
||||
className="data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
{t("fileManager.pinned")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="shortcuts"
|
||||
className="data-[state=active]:bg-dark-bg-button"
|
||||
>
|
||||
{t("fileManager.folderShortcuts")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="recent" className="mt-0">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{recent.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("fileManager.noRecentFiles")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
recent.map((file) =>
|
||||
renderFileCard(
|
||||
file,
|
||||
() => onRemoveRecent(file),
|
||||
() => (file.isPinned ? onUnpinFile(file) : onPinFile(file)),
|
||||
file.isPinned,
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="pinned" className="mt-0">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{pinned.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-8 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("fileManager.noPinnedFiles")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
pinned.map((file) =>
|
||||
renderFileCard(file, undefined, () => onUnpinFile(file), true),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="shortcuts" className="mt-0">
|
||||
<div className="flex items-center gap-3 mb-4 p-3 bg-dark-bg border-2 border-dark-border rounded-lg">
|
||||
<Input
|
||||
placeholder={t("fileManager.enterFolderPath")}
|
||||
value={newShortcut}
|
||||
onChange={(e) => setNewShortcut(e.target.value)}
|
||||
className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-8 px-2 bg-dark-bg-button border-2 !border-dark-border hover:bg-dark-hover rounded-md"
|
||||
onClick={() => {
|
||||
if (newShortcut.trim()) {
|
||||
onAddShortcut(newShortcut.trim());
|
||||
setNewShortcut("");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{shortcuts.length === 0 ? (
|
||||
<div className="flex items-center justify-center py-4 col-span-full">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("fileManager.noShortcuts")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
shortcuts.map((shortcut) => renderShortcutCard(shortcut))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
630
src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx
Normal file
630
src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx
Normal file
@@ -0,0 +1,630 @@
|
||||
import React, {
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import {
|
||||
Folder,
|
||||
File,
|
||||
ArrowUp,
|
||||
Pin,
|
||||
MoreVertical,
|
||||
Trash2,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area.tsx";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
listSSHFiles,
|
||||
renameSSHItem,
|
||||
deleteSSHItem,
|
||||
getFileManagerPinned,
|
||||
addFileManagerPinned,
|
||||
removeFileManagerPinned,
|
||||
getSSHStatus,
|
||||
connectSSH,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type { SSHHost } from "../../../types/index.js";
|
||||
|
||||
const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar(
|
||||
{
|
||||
onOpenFile,
|
||||
tabs,
|
||||
host,
|
||||
onOperationComplete,
|
||||
onPathChange,
|
||||
onDeleteItem,
|
||||
}: {
|
||||
onSelectView?: (view: string) => void;
|
||||
onOpenFile: (file: any) => void;
|
||||
tabs: any[];
|
||||
host: SSHHost;
|
||||
onOperationComplete?: () => void;
|
||||
onError?: (error: string) => void;
|
||||
onSuccess?: (message: string) => void;
|
||||
onPathChange?: (path: string) => void;
|
||||
onDeleteItem?: (item: any) => void;
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
const [files, setFiles] = useState<any[]>([]);
|
||||
const pathInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
const [debouncedSearch, setDebouncedSearch] = useState("");
|
||||
const [fileSearch, setFileSearch] = useState("");
|
||||
const [debouncedFileSearch, setDebouncedFileSearch] = useState("");
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(search), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [search]);
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200);
|
||||
return () => clearTimeout(handler);
|
||||
}, [fileSearch]);
|
||||
|
||||
const [sshSessionId, setSshSessionId] = useState<string | null>(null);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [connectingSSH, setConnectingSSH] = useState(false);
|
||||
const [connectionCache, setConnectionCache] = useState<
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
sessionId: string;
|
||||
timestamp: number;
|
||||
}
|
||||
>
|
||||
>({});
|
||||
const [fetchingFiles, setFetchingFiles] = useState(false);
|
||||
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
visible: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
item: any;
|
||||
}>({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
item: null,
|
||||
});
|
||||
|
||||
const [renamingItem, setRenamingItem] = useState<{
|
||||
item: any;
|
||||
newName: string;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const nextPath = host?.defaultPath || "/";
|
||||
setCurrentPath(nextPath);
|
||||
onPathChange?.(nextPath);
|
||||
(async () => {
|
||||
await connectToSSH(host);
|
||||
})();
|
||||
}, [host?.id]);
|
||||
|
||||
async function connectToSSH(server: SSHHost): Promise<string | null> {
|
||||
const sessionId = server.id.toString();
|
||||
|
||||
const cached = connectionCache[sessionId];
|
||||
if (cached && Date.now() - cached.timestamp < 30000) {
|
||||
setSshSessionId(cached.sessionId);
|
||||
return cached.sessionId;
|
||||
}
|
||||
|
||||
if (connectingSSH) {
|
||||
return null;
|
||||
}
|
||||
|
||||
setConnectingSSH(true);
|
||||
|
||||
try {
|
||||
if (!server.password && !server.key) {
|
||||
toast.error(t("common.noAuthCredentials"));
|
||||
return null;
|
||||
}
|
||||
|
||||
const connectionConfig = {
|
||||
hostId: server.id,
|
||||
ip: server.ip,
|
||||
port: server.port,
|
||||
username: server.username,
|
||||
password: server.password,
|
||||
sshKey: server.key,
|
||||
keyPassword: server.keyPassword,
|
||||
authType: server.authType,
|
||||
credentialId: server.credentialId,
|
||||
userId: server.userId,
|
||||
};
|
||||
|
||||
await connectSSH(sessionId, connectionConfig);
|
||||
|
||||
setSshSessionId(sessionId);
|
||||
|
||||
setConnectionCache((prev) => ({
|
||||
...prev,
|
||||
[sessionId]: { sessionId, timestamp: Date.now() },
|
||||
}));
|
||||
|
||||
return sessionId;
|
||||
} catch (err: any) {
|
||||
toast.error(
|
||||
err?.response?.data?.error || t("fileManager.failedToConnectSSH"),
|
||||
);
|
||||
setSshSessionId(null);
|
||||
return null;
|
||||
} finally {
|
||||
setConnectingSSH(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFiles() {
|
||||
if (fetchingFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingFiles(true);
|
||||
setFiles([]);
|
||||
setFilesLoading(true);
|
||||
|
||||
try {
|
||||
let pinnedFiles: any[] = [];
|
||||
try {
|
||||
if (host) {
|
||||
pinnedFiles = await getFileManagerPinned(host.id);
|
||||
}
|
||||
} catch (err) {}
|
||||
|
||||
if (host && sshSessionId) {
|
||||
let res: any[] = [];
|
||||
|
||||
try {
|
||||
const status = await getSSHStatus(sshSessionId);
|
||||
if (!status.connected) {
|
||||
const newSessionId = await connectToSSH(host);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
} else {
|
||||
throw new Error(t("fileManager.failedToReconnectSSH"));
|
||||
}
|
||||
} else {
|
||||
res = await listSSHFiles(sshSessionId, currentPath);
|
||||
}
|
||||
} catch (sessionErr) {
|
||||
const newSessionId = await connectToSSH(host);
|
||||
if (newSessionId) {
|
||||
setSshSessionId(newSessionId);
|
||||
res = await listSSHFiles(newSessionId, currentPath);
|
||||
} else {
|
||||
throw sessionErr;
|
||||
}
|
||||
}
|
||||
|
||||
const processedFiles = (res || []).map((f: any) => {
|
||||
const filePath =
|
||||
currentPath + (currentPath.endsWith("/") ? "" : "/") + f.name;
|
||||
const isPinned = pinnedFiles.some(
|
||||
(pinned) => pinned.path === filePath,
|
||||
);
|
||||
return {
|
||||
...f,
|
||||
path: filePath,
|
||||
isPinned,
|
||||
isSSH: true,
|
||||
sshSessionId: sshSessionId,
|
||||
};
|
||||
});
|
||||
|
||||
setFiles(processedFiles);
|
||||
}
|
||||
} catch (err: any) {
|
||||
setFiles([]);
|
||||
toast.error(
|
||||
err?.response?.data?.error ||
|
||||
err?.message ||
|
||||
t("fileManager.failedToListFiles"),
|
||||
);
|
||||
} finally {
|
||||
setFilesLoading(false);
|
||||
setFetchingFiles(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (host && sshSessionId && !connectingSSH && !fetchingFiles) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
fetchFiles();
|
||||
}, 100);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [currentPath, host, sshSessionId]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
openFolder: async (_server: SSHHost, path: string) => {
|
||||
if (connectingSSH || fetchingFiles) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPath === path) {
|
||||
setTimeout(() => fetchFiles(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
setFetchingFiles(false);
|
||||
setFilesLoading(false);
|
||||
setFiles([]);
|
||||
|
||||
setCurrentPath(path);
|
||||
onPathChange?.(path);
|
||||
if (!sshSessionId) {
|
||||
const sessionId = await connectToSSH(host);
|
||||
if (sessionId) setSshSessionId(sessionId);
|
||||
}
|
||||
},
|
||||
fetchFiles: () => {
|
||||
if (host && sshSessionId) {
|
||||
fetchFiles();
|
||||
}
|
||||
},
|
||||
getCurrentPath: () => currentPath,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (pathInputRef.current) {
|
||||
pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth;
|
||||
}
|
||||
}, [currentPath]);
|
||||
|
||||
const filteredFiles = files.filter((file) => {
|
||||
const q = debouncedFileSearch.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return file.name.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent, item: any) => {
|
||||
e.preventDefault();
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
const menuWidth = 160;
|
||||
const menuHeight = 80;
|
||||
|
||||
let x = e.clientX;
|
||||
let y = e.clientY;
|
||||
|
||||
if (x + menuWidth > viewportWidth) {
|
||||
x = e.clientX - menuWidth;
|
||||
}
|
||||
|
||||
if (y + menuHeight > viewportHeight) {
|
||||
y = e.clientY - menuHeight;
|
||||
}
|
||||
|
||||
if (x < 0) {
|
||||
x = 0;
|
||||
}
|
||||
|
||||
if (y < 0) {
|
||||
y = 0;
|
||||
}
|
||||
|
||||
setContextMenu({
|
||||
visible: true,
|
||||
x,
|
||||
y,
|
||||
item,
|
||||
});
|
||||
};
|
||||
|
||||
const closeContextMenu = () => {
|
||||
setContextMenu({ visible: false, x: 0, y: 0, item: null });
|
||||
};
|
||||
|
||||
const handleRename = async (item: any, newName: string) => {
|
||||
if (!sshSessionId || !newName.trim() || newName === item.name) {
|
||||
setRenamingItem(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await renameSSHItem(sshSessionId, item.path, newName.trim());
|
||||
toast.success(
|
||||
`${item.type === "directory" ? t("common.folder") : t("common.file")} ${t("common.renamedSuccessfully")}`,
|
||||
);
|
||||
setRenamingItem(null);
|
||||
if (onOperationComplete) {
|
||||
onOperationComplete();
|
||||
} else {
|
||||
fetchFiles();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(
|
||||
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const startRename = (item: any) => {
|
||||
setRenamingItem({ item, newName: item.name });
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
const startDelete = (item: any) => {
|
||||
onDeleteItem?.(item);
|
||||
closeContextMenu();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = () => closeContextMenu();
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
return () => document.removeEventListener("click", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handlePathChange = (newPath: string) => {
|
||||
setCurrentPath(newPath);
|
||||
onPathChange?.(newPath);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-[256px] max-w-[256px]">
|
||||
<div className="flex flex-col flex-grow min-h-0">
|
||||
<div className="flex-1 w-full h-full flex flex-col bg-dark-bg-darkest border-r-2 border-dark-border overflow-hidden p-0 relative min-h-0">
|
||||
{host && (
|
||||
<div className="flex flex-col h-full w-full max-w-[260px]">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b-2 border-dark-border bg-dark-bg z-20 max-w-[260px]">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="h-9 w-9 bg-dark-bg border-2 border-dark-border rounded-md hover:bg-dark-hover focus:outline-none focus:ring-2 focus:ring-ring"
|
||||
onClick={() => {
|
||||
let path = currentPath;
|
||||
if (path && path !== "/" && path !== "") {
|
||||
if (path.endsWith("/")) path = path.slice(0, -1);
|
||||
const lastSlash = path.lastIndexOf("/");
|
||||
if (lastSlash > 0) {
|
||||
handlePathChange(path.slice(0, lastSlash));
|
||||
} else {
|
||||
handlePathChange("/");
|
||||
}
|
||||
} else {
|
||||
handlePathChange("/");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</Button>
|
||||
<Input
|
||||
ref={pathInputRef}
|
||||
value={currentPath}
|
||||
onChange={(e) => handlePathChange(e.target.value)}
|
||||
className="flex-1 bg-dark-bg border-2 border-dark-border-hover text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-dark-border-light"
|
||||
/>
|
||||
</div>
|
||||
<div className="px-2 py-2 border-b-1 border-dark-border bg-dark-bg">
|
||||
<Input
|
||||
placeholder={t("fileManager.searchFilesAndFolders")}
|
||||
className="w-full h-7 text-sm bg-dark-bg-button border-2 border-dark-border-hover text-white placeholder:text-muted-foreground rounded-md"
|
||||
autoComplete="off"
|
||||
value={fileSearch}
|
||||
onChange={(e) => setFileSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 w-full bg-dark-bg-darkest border-t-1 border-dark-border">
|
||||
<ScrollArea className="h-full w-full bg-dark-bg-darkest">
|
||||
<div className="p-2 pb-0">
|
||||
{connectingSSH || filesLoading ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : filteredFiles.length === 0 ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("fileManager.noFilesOrFoldersFound")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{filteredFiles.map((item: any) => {
|
||||
const isOpen = (tabs || []).some(
|
||||
(t: any) => t.id === item.path,
|
||||
);
|
||||
const isRenaming =
|
||||
renamingItem?.item?.path === item.path;
|
||||
const isDeleting = false;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.path}
|
||||
className={cn(
|
||||
"flex items-center gap-2 px-3 py-2 bg-dark-bg border-2 border-dark-border rounded group max-w-[220px] mb-2 relative",
|
||||
isOpen &&
|
||||
"opacity-60 cursor-not-allowed pointer-events-none",
|
||||
)}
|
||||
onContextMenu={(e) =>
|
||||
!isOpen && handleContextMenu(e, item)
|
||||
}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{item.type === "directory" ? (
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<Input
|
||||
value={renamingItem.newName}
|
||||
onChange={(e) =>
|
||||
setRenamingItem((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
newName: e.target.value,
|
||||
}
|
||||
: null,
|
||||
)
|
||||
}
|
||||
className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleRename(
|
||||
item,
|
||||
renamingItem.newName,
|
||||
);
|
||||
} else if (e.key === "Escape") {
|
||||
setRenamingItem(null);
|
||||
}
|
||||
}}
|
||||
onBlur={() =>
|
||||
handleRename(item, renamingItem.newName)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer min-w-0"
|
||||
onClick={() =>
|
||||
!isOpen &&
|
||||
(item.type === "directory"
|
||||
? handlePathChange(item.path)
|
||||
: onOpenFile({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
isSSH: item.isSSH,
|
||||
sshSessionId: item.sshSessionId,
|
||||
}))
|
||||
}
|
||||
>
|
||||
{item.type === "directory" ? (
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<span className="text-sm text-white truncate flex-1 min-w-0">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{item.type === "file" && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
disabled={isOpen}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (item.isPinned) {
|
||||
await removeFileManagerPinned({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hostId: host?.id,
|
||||
isSSH: true,
|
||||
sshSessionId:
|
||||
host?.id.toString(),
|
||||
});
|
||||
setFiles(
|
||||
files.map((f) =>
|
||||
f.path === item.path
|
||||
? {
|
||||
...f,
|
||||
isPinned: false,
|
||||
}
|
||||
: f,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await addFileManagerPinned({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
hostId: host?.id,
|
||||
isSSH: true,
|
||||
sshSessionId:
|
||||
host?.id.toString(),
|
||||
});
|
||||
setFiles(
|
||||
files.map((f) =>
|
||||
f.path === item.path
|
||||
? {
|
||||
...f,
|
||||
isPinned: true,
|
||||
}
|
||||
: f,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {}
|
||||
}}
|
||||
>
|
||||
<Pin
|
||||
className={`w-1 h-1 ${item.isPinned ? "text-yellow-400 fill-current" : "text-muted-foreground"}`}
|
||||
/>
|
||||
</Button>
|
||||
)}
|
||||
{!isOpen && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleContextMenu(e, item);
|
||||
}}
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{contextMenu.visible && contextMenu.item && (
|
||||
<div
|
||||
className="fixed z-[99998] bg-dark-bg border-2 border-dark-border rounded-lg shadow-xl py-1 min-w-[160px]"
|
||||
style={{
|
||||
left: contextMenu.x,
|
||||
top: contextMenu.y,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="w-full px-3 py-2 text-left text-sm text-white hover:bg-dark-hover flex items-center gap-2"
|
||||
onClick={() => startRename(contextMenu.item)}
|
||||
>
|
||||
<Edit3 className="w-4 h-4" />
|
||||
Rename
|
||||
</button>
|
||||
<button
|
||||
className="w-full px-3 py-2 text-left text-sm text-red-400 hover:bg-dark-hover flex items-center gap-2"
|
||||
onClick={() => startDelete(contextMenu.item)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { FileManagerLeftSidebar };
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Card } from "@/components/ui/card.tsx";
|
||||
import { Folder, File, Trash2, Pin } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface SSHConnection {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
isPinned?: boolean;
|
||||
}
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
type: "file" | "directory" | "link";
|
||||
path: string;
|
||||
isStarred?: boolean;
|
||||
}
|
||||
|
||||
interface FileManagerLeftSidebarVileViewerProps {
|
||||
sshConnections: SSHConnection[];
|
||||
onAddSSH: () => void;
|
||||
onConnectSSH: (conn: SSHConnection) => void;
|
||||
onEditSSH: (conn: SSHConnection) => void;
|
||||
onDeleteSSH: (conn: SSHConnection) => void;
|
||||
onPinSSH: (conn: SSHConnection) => void;
|
||||
currentPath: string;
|
||||
files: FileItem[];
|
||||
onOpenFile: (file: FileItem) => void;
|
||||
onOpenFolder: (folder: FileItem) => void;
|
||||
onStarFile: (file: FileItem) => void;
|
||||
onDeleteFile: (file: FileItem) => void;
|
||||
isLoading?: boolean;
|
||||
error?: string;
|
||||
isSSHMode: boolean;
|
||||
onSwitchToLocal: () => void;
|
||||
onSwitchToSSH: (conn: SSHConnection) => void;
|
||||
currentSSH?: SSHConnection;
|
||||
}
|
||||
|
||||
export function FileManagerLeftSidebarFileViewer({
|
||||
currentPath,
|
||||
files,
|
||||
onOpenFile,
|
||||
onOpenFolder,
|
||||
onStarFile,
|
||||
onDeleteFile,
|
||||
isLoading,
|
||||
error,
|
||||
isSSHMode,
|
||||
}: FileManagerLeftSidebarVileViewerProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 bg-dark-bg-darkest p-2 overflow-y-auto">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground font-semibold">
|
||||
{isSSHMode ? t("common.sshPath") : t("common.localPath")}
|
||||
</span>
|
||||
<span className="text-xs text-white truncate">{currentPath}</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("common.loading")}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-xs text-red-500">{error}</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{files.map((item) => (
|
||||
<Card
|
||||
key={item.path}
|
||||
className="flex items-center gap-2 px-2 py-1 bg-dark-bg border-2 border-dark-border rounded"
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 cursor-pointer"
|
||||
onClick={() =>
|
||||
item.type === "directory"
|
||||
? onOpenFolder(item)
|
||||
: onOpenFile(item)
|
||||
}
|
||||
>
|
||||
{item.type === "directory" ? (
|
||||
<Folder className="w-4 h-4 text-blue-400" />
|
||||
) : (
|
||||
<File className="w-4 h-4 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm text-white truncate">
|
||||
{item.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onStarFile(item)}
|
||||
>
|
||||
<Pin
|
||||
className={`w-4 h-4 ${item.isStarred ? "text-yellow-400" : "text-muted-foreground"}`}
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-7 w-7"
|
||||
onClick={() => onDeleteFile(item)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
{files.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
No files or folders found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
805
src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx
Normal file
805
src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx
Normal file
@@ -0,0 +1,805 @@
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Input } from "@/components/ui/input.tsx";
|
||||
import { Card } from "@/components/ui/card.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import {
|
||||
Upload,
|
||||
FilePlus,
|
||||
FolderPlus,
|
||||
Trash2,
|
||||
Edit3,
|
||||
X,
|
||||
AlertCircle,
|
||||
FileText,
|
||||
Folder,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileManagerOperationsProps } from "../../../types/index.js";
|
||||
|
||||
export function FileManagerOperations({
|
||||
currentPath,
|
||||
sshSessionId,
|
||||
onOperationComplete,
|
||||
onError,
|
||||
onSuccess,
|
||||
}: FileManagerOperationsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showCreateFile, setShowCreateFile] = useState(false);
|
||||
const [showCreateFolder, setShowCreateFolder] = useState(false);
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showRename, setShowRename] = useState(false);
|
||||
|
||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||
const [newFileName, setNewFileName] = useState("");
|
||||
const [newFolderName, setNewFolderName] = useState("");
|
||||
const [deletePath, setDeletePath] = useState("");
|
||||
const [deleteIsDirectory, setDeleteIsDirectory] = useState(false);
|
||||
const [renamePath, setRenamePath] = useState("");
|
||||
const [renameIsDirectory, setRenameIsDirectory] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showTextLabels, setShowTextLabels] = useState(true);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const checkContainerWidth = () => {
|
||||
if (containerRef.current) {
|
||||
const width = containerRef.current.offsetWidth;
|
||||
setShowTextLabels(width > 240);
|
||||
}
|
||||
};
|
||||
|
||||
checkContainerWidth();
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkContainerWidth);
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleFileUpload = async () => {
|
||||
if (!uploadFile || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { toast } = await import("sonner");
|
||||
const loadingToast = toast.loading(
|
||||
t("fileManager.uploadingFile", { name: uploadFile.name }),
|
||||
);
|
||||
|
||||
try {
|
||||
const content = await uploadFile.text();
|
||||
const { uploadSSHFile } = await import("@/ui/main-axios.ts");
|
||||
|
||||
const response = await uploadSSHFile(
|
||||
sshSessionId,
|
||||
currentPath,
|
||||
uploadFile.name,
|
||||
content,
|
||||
);
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(
|
||||
t("fileManager.fileUploadedSuccessfully", { name: uploadFile.name }),
|
||||
);
|
||||
}
|
||||
|
||||
setShowUpload(false);
|
||||
setUploadFile(null);
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
toast.dismiss(loadingToast);
|
||||
onError(
|
||||
error?.response?.data?.error || t("fileManager.failedToUploadFile"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFile = async () => {
|
||||
if (!newFileName.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { toast } = await import("sonner");
|
||||
const loadingToast = toast.loading(
|
||||
t("fileManager.creatingFile", { name: newFileName.trim() }),
|
||||
);
|
||||
|
||||
try {
|
||||
const { createSSHFile } = await import("@/ui/main-axios.ts");
|
||||
|
||||
const response = await createSSHFile(
|
||||
sshSessionId,
|
||||
currentPath,
|
||||
newFileName.trim(),
|
||||
);
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(
|
||||
t("fileManager.fileCreatedSuccessfully", {
|
||||
name: newFileName.trim(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setShowCreateFile(false);
|
||||
setNewFileName("");
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
toast.dismiss(loadingToast);
|
||||
onError(
|
||||
error?.response?.data?.error || t("fileManager.failedToCreateFile"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderName.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { toast } = await import("sonner");
|
||||
const loadingToast = toast.loading(
|
||||
t("fileManager.creatingFolder", { name: newFolderName.trim() }),
|
||||
);
|
||||
|
||||
try {
|
||||
const { createSSHFolder } = await import("@/ui/main-axios.ts");
|
||||
|
||||
const response = await createSSHFolder(
|
||||
sshSessionId,
|
||||
currentPath,
|
||||
newFolderName.trim(),
|
||||
);
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(
|
||||
t("fileManager.folderCreatedSuccessfully", {
|
||||
name: newFolderName.trim(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setShowCreateFolder(false);
|
||||
setNewFolderName("");
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
toast.dismiss(loadingToast);
|
||||
onError(
|
||||
error?.response?.data?.error || t("fileManager.failedToCreateFolder"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deletePath || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { toast } = await import("sonner");
|
||||
const loadingToast = toast.loading(
|
||||
t("fileManager.deletingItem", {
|
||||
type: deleteIsDirectory
|
||||
? t("fileManager.folder")
|
||||
: t("fileManager.file"),
|
||||
name: deletePath.split("/").pop(),
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const { deleteSSHItem } = await import("@/ui/main-axios.ts");
|
||||
|
||||
const response = await deleteSSHItem(
|
||||
sshSessionId,
|
||||
deletePath,
|
||||
deleteIsDirectory,
|
||||
);
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(
|
||||
t("fileManager.itemDeletedSuccessfully", {
|
||||
type: deleteIsDirectory
|
||||
? t("fileManager.folder")
|
||||
: t("fileManager.file"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setShowDelete(false);
|
||||
setDeletePath("");
|
||||
setDeleteIsDirectory(false);
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
toast.dismiss(loadingToast);
|
||||
onError(
|
||||
error?.response?.data?.error || t("fileManager.failedToDeleteItem"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!renamePath || !newName.trim() || !sshSessionId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
const { toast } = await import("sonner");
|
||||
const loadingToast = toast.loading(
|
||||
t("fileManager.renamingItem", {
|
||||
type: renameIsDirectory
|
||||
? t("fileManager.folder")
|
||||
: t("fileManager.file"),
|
||||
oldName: renamePath.split("/").pop(),
|
||||
newName: newName.trim(),
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const { renameSSHItem } = await import("@/ui/main-axios.ts");
|
||||
|
||||
const response = await renameSSHItem(
|
||||
sshSessionId,
|
||||
renamePath,
|
||||
newName.trim(),
|
||||
);
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
|
||||
if (response?.toast) {
|
||||
toast[response.toast.type](response.toast.message);
|
||||
} else {
|
||||
onSuccess(
|
||||
t("fileManager.itemRenamedSuccessfully", {
|
||||
type: renameIsDirectory
|
||||
? t("fileManager.folder")
|
||||
: t("fileManager.file"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
setShowRename(false);
|
||||
setRenamePath("");
|
||||
setRenameIsDirectory(false);
|
||||
setNewName("");
|
||||
onOperationComplete();
|
||||
} catch (error: any) {
|
||||
toast.dismiss(loadingToast);
|
||||
onError(
|
||||
error?.response?.data?.error || t("fileManager.failedToRenameItem"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openFileDialog = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setUploadFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const resetStates = () => {
|
||||
setShowUpload(false);
|
||||
setShowCreateFile(false);
|
||||
setShowCreateFolder(false);
|
||||
setShowDelete(false);
|
||||
setShowRename(false);
|
||||
setUploadFile(null);
|
||||
setNewFileName("");
|
||||
setNewFolderName("");
|
||||
setDeletePath("");
|
||||
setDeleteIsDirectory(false);
|
||||
setRenamePath("");
|
||||
setRenameIsDirectory(false);
|
||||
setNewName("");
|
||||
};
|
||||
|
||||
if (!sshSessionId) {
|
||||
return (
|
||||
<div className="p-4 text-center">
|
||||
<AlertCircle className="w-8 h-8 text-muted-foreground mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("fileManager.connectToSsh")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="p-4 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowUpload(true)}
|
||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
||||
title={t("fileManager.uploadFile")}
|
||||
>
|
||||
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||
{showTextLabels && (
|
||||
<span className="truncate">{t("fileManager.uploadFile")}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFile(true)}
|
||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
||||
title={t("fileManager.newFile")}
|
||||
>
|
||||
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||
{showTextLabels && (
|
||||
<span className="truncate">{t("fileManager.newFile")}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFolder(true)}
|
||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
||||
title={t("fileManager.newFolder")}
|
||||
>
|
||||
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||
{showTextLabels && (
|
||||
<span className="truncate">{t("fileManager.newFolder")}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowRename(true)}
|
||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover"
|
||||
title={t("fileManager.rename")}
|
||||
>
|
||||
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||
{showTextLabels && (
|
||||
<span className="truncate">{t("fileManager.rename")}</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowDelete(true)}
|
||||
className="h-10 bg-dark-bg border-2 border-dark-border hover:border-dark-border-hover hover:bg-dark-hover col-span-2"
|
||||
title={t("fileManager.deleteItem")}
|
||||
>
|
||||
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")} />
|
||||
{showTextLabels && (
|
||||
<span className="truncate">{t("fileManager.deleteItem")}</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-dark-bg-light border-2 border-dark-border-medium rounded-md p-3">
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-muted-foreground block mb-1">
|
||||
{t("fileManager.currentPath")}:
|
||||
</span>
|
||||
<span className="text-white font-mono text-xs break-all leading-relaxed">
|
||||
{currentPath}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator className="p-0.25 bg-dark-border" />
|
||||
|
||||
{showUpload && (
|
||||
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
|
||||
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{t("fileManager.uploadFileTitle")}
|
||||
</span>
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground break-words">
|
||||
{t("fileManager.maxFileSize")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowUpload(false)}
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="border-2 border-dashed border-dark-border-hover rounded-lg p-4 text-center">
|
||||
{uploadFile ? (
|
||||
<div className="space-y-3">
|
||||
<FileText className="w-12 h-12 text-blue-400 mx-auto" />
|
||||
<p className="text-white font-medium text-sm break-words px-2">
|
||||
{uploadFile.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(uploadFile.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setUploadFile(null)}
|
||||
className="w-full text-sm h-8"
|
||||
>
|
||||
{t("fileManager.removeFile")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Upload className="w-12 h-12 text-muted-foreground mx-auto" />
|
||||
<p className="text-white text-sm break-words px-2">
|
||||
{t("fileManager.clickToSelectFile")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={openFileDialog}
|
||||
className="w-full text-sm h-8"
|
||||
>
|
||||
{t("fileManager.chooseFile")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
accept="*/*"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleFileUpload}
|
||||
disabled={!uploadFile || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading
|
||||
? t("fileManager.uploading")
|
||||
: t("fileManager.uploadFile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowUpload(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showCreateFile && (
|
||||
<Card className="bg-dark-bg border-2 border-dark-border p-3 sm:p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{t("fileManager.createNewFile")}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFile(false)}
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
{t("fileManager.fileName")}
|
||||
</label>
|
||||
<Input
|
||||
value={newFileName}
|
||||
onChange={(e) => setNewFileName(e.target.value)}
|
||||
placeholder={t("placeholders.fileName")}
|
||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFile()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleCreateFile}
|
||||
disabled={!newFileName.trim() || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading
|
||||
? t("fileManager.creating")
|
||||
: t("fileManager.createFile")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateFile(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showCreateFolder && (
|
||||
<Card className="bg-dark-bg border-2 border-dark-border p-3">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<FolderPlus className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{t("fileManager.createNewFolder")}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowCreateFolder(false)}
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
{t("fileManager.folderName")}
|
||||
</label>
|
||||
<Input
|
||||
value={newFolderName}
|
||||
onChange={(e) => setNewFolderName(e.target.value)}
|
||||
placeholder={t("placeholders.folderName")}
|
||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleCreateFolder}
|
||||
disabled={!newFolderName.trim() || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading
|
||||
? t("fileManager.creating")
|
||||
: t("fileManager.createFolder")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateFolder(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showDelete && (
|
||||
<Card className="bg-dark-bg border-2 border-dark-border p-3">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{t("fileManager.deleteItem")}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowDelete(false)}
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2 text-red-300">
|
||||
<AlertCircle className="w-5 h-5 flex-shrink-0" />
|
||||
<span className="text-sm font-medium break-words">
|
||||
{t("fileManager.warningCannotUndo")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
{t("fileManager.itemPath")}
|
||||
</label>
|
||||
<Input
|
||||
value={deletePath}
|
||||
onChange={(e) => setDeletePath(e.target.value)}
|
||||
placeholder={t("placeholders.fullPath")}
|
||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="deleteIsDirectory"
|
||||
checked={deleteIsDirectory}
|
||||
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
|
||||
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<label
|
||||
htmlFor="deleteIsDirectory"
|
||||
className="text-sm text-white break-words"
|
||||
>
|
||||
{t("fileManager.thisIsDirectory")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleDelete}
|
||||
disabled={!deletePath || isLoading}
|
||||
variant="destructive"
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading
|
||||
? t("fileManager.deleting")
|
||||
: t("fileManager.deleteItem")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDelete(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{showRename && (
|
||||
<Card className="bg-dark-bg border-2 border-dark-border p-3">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||
<Edit3 className="w-6 h-6 flex-shrink-0" />
|
||||
<span className="break-words">
|
||||
{t("fileManager.renameItem")}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowRename(false)}
|
||||
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
{t("fileManager.currentPathLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={renamePath}
|
||||
onChange={(e) => setRenamePath(e.target.value)}
|
||||
placeholder={t("placeholders.currentPath")}
|
||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-white mb-2 block">
|
||||
{t("fileManager.newName")}
|
||||
</label>
|
||||
<Input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("placeholders.newName")}
|
||||
className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleRename()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="renameIsDirectory"
|
||||
checked={renameIsDirectory}
|
||||
onChange={(e) => setRenameIsDirectory(e.target.checked)}
|
||||
className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0"
|
||||
/>
|
||||
<label
|
||||
htmlFor="renameIsDirectory"
|
||||
className="text-sm text-white break-words"
|
||||
>
|
||||
{t("fileManager.thisIsDirectoryRename")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
onClick={handleRename}
|
||||
disabled={!renamePath || !newName.trim() || isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{isLoading
|
||||
? t("fileManager.renaming")
|
||||
: t("fileManager.renameItem")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowRename(false)}
|
||||
disabled={isLoading}
|
||||
className="w-full text-sm h-9"
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/ui/Desktop/Apps/File Manager/FileManagerTabList.tsx
Normal file
62
src/ui/Desktop/Apps/File Manager/FileManagerTabList.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { X, Home } from "lucide-react";
|
||||
|
||||
interface FileManagerTab {
|
||||
id: string | number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface FileManagerTabList {
|
||||
tabs: FileManagerTab[];
|
||||
activeTab: string | number;
|
||||
setActiveTab: (tab: string | number) => void;
|
||||
closeTab: (tab: string | number) => void;
|
||||
onHomeClick: () => void;
|
||||
}
|
||||
|
||||
export function FileManagerTabList({
|
||||
tabs,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
closeTab,
|
||||
onHomeClick,
|
||||
}: FileManagerTabList) {
|
||||
return (
|
||||
<div className="inline-flex items-center h-full gap-2">
|
||||
<Button
|
||||
onClick={onHomeClick}
|
||||
variant="outline"
|
||||
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-dark-border ${activeTab === "home" ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
</Button>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.id === activeTab;
|
||||
return (
|
||||
<div
|
||||
key={tab.id}
|
||||
className="inline-flex rounded-md shadow-sm"
|
||||
role="group"
|
||||
>
|
||||
<Button
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
variant="outline"
|
||||
className={`h-8 rounded-r-none !px-2 border-1 border-dark-border ${isActive ? "!bg-dark-bg-active !text-white !border-dark-border-active !hover:bg-dark-bg-active !active:bg-dark-bg-active !focus:bg-dark-bg-active !hover:text-white !active:text-white !focus:text-white" : ""}`}
|
||||
>
|
||||
{tab.title}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => closeTab(tab.id)}
|
||||
variant="outline"
|
||||
className="h-8 rounded-l-none p-0 !w-9 border-1 border-dark-border"
|
||||
>
|
||||
<X className="!w-4 !h-4" strokeWidth={2} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
142
src/ui/Desktop/Apps/Host Manager/HostManager.tsx
Normal file
142
src/ui/Desktop/Apps/Host Manager/HostManager.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import React, { useState } from "react";
|
||||
import { HostManagerViewer } from "@/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx";
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { HostManagerEditor } from "@/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx";
|
||||
import { CredentialsManager } from "@/ui/Desktop/Apps/Credentials/CredentialsManager.tsx";
|
||||
import { CredentialEditor } from "@/ui/Desktop/Apps/Credentials/CredentialEditor.tsx";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { SSHHost, HostManagerProps } from "../../../types/index";
|
||||
|
||||
export function HostManager({
|
||||
onSelectView,
|
||||
isTopbarOpen,
|
||||
}: HostManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState("host_viewer");
|
||||
const [editingHost, setEditingHost] = useState<SSHHost | null>(null);
|
||||
|
||||
const [editingCredential, setEditingCredential] = useState<any | null>(null);
|
||||
const { state: sidebarState } = useSidebar();
|
||||
|
||||
const handleEditHost = (host: SSHHost) => {
|
||||
setEditingHost(host);
|
||||
setActiveTab("add_host");
|
||||
};
|
||||
|
||||
const handleFormSubmit = (updatedHost?: SSHHost) => {
|
||||
setEditingHost(null);
|
||||
setActiveTab("host_viewer");
|
||||
};
|
||||
|
||||
const handleEditCredential = (credential: any) => {
|
||||
setEditingCredential(credential);
|
||||
setActiveTab("add_credential");
|
||||
};
|
||||
|
||||
const handleCredentialFormSubmit = () => {
|
||||
setEditingCredential(null);
|
||||
setActiveTab("credentials");
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
if (value !== "add_host") {
|
||||
setEditingHost(null);
|
||||
}
|
||||
if (value !== "add_credential") {
|
||||
setEditingCredential(null);
|
||||
}
|
||||
};
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 26 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<div
|
||||
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,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<TabsList className="bg-dark-bg border-2 border-dark-border mt-1.5">
|
||||
<TabsTrigger value="host_viewer">
|
||||
{t("hosts.hostViewer")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="add_host">
|
||||
{editingHost ? t("hosts.editHost") : t("hosts.addHost")}
|
||||
</TabsTrigger>
|
||||
<div className="h-6 w-px bg-dark-border mx-1"></div>
|
||||
<TabsTrigger value="credentials">
|
||||
{t("credentials.credentialsViewer")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="add_credential">
|
||||
{editingCredential
|
||||
? t("credentials.editCredential")
|
||||
: t("credentials.addCredential")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent
|
||||
value="host_viewer"
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1" />
|
||||
<HostManagerViewer onEditHost={handleEditHost} />
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="add_host"
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1" />
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<HostManagerEditor
|
||||
editingHost={editingHost}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="credentials"
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1" />
|
||||
<div className="flex flex-col h-full min-h-0 overflow-auto">
|
||||
<CredentialsManager onEditCredential={handleEditCredential} />
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent
|
||||
value="add_credential"
|
||||
className="flex-1 flex flex-col h-full min-h-0"
|
||||
>
|
||||
<Separator className="p-0.25 -mt-0.5 mb-1" />
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<CredentialEditor
|
||||
editingCredential={editingCredential}
|
||||
onFormSubmit={handleCredentialFormSubmit}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1507
src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx
Normal file
1507
src/ui/Desktop/Apps/Host Manager/HostManagerEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1102
src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx
Normal file
1102
src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx
Normal file
File diff suppressed because it is too large
Load Diff
478
src/ui/Desktop/Apps/Server/Server.tsx
Normal file
478
src/ui/Desktop/Apps/Server/Server.tsx
Normal file
@@ -0,0 +1,478 @@
|
||||
import React from "react";
|
||||
import { useSidebar } from "@/components/ui/sidebar.tsx";
|
||||
import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Progress } from "@/components/ui/progress.tsx";
|
||||
import { Cpu, HardDrive, MemoryStick } from "lucide-react";
|
||||
import { Tunnel } from "@/ui/Desktop/Apps/Tunnel/Tunnel.tsx";
|
||||
import {
|
||||
getServerStatusById,
|
||||
getServerMetricsById,
|
||||
type ServerMetrics,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import { useTabs } from "@/ui/Desktop/Navigation/Tabs/TabContext.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ServerProps {
|
||||
hostConfig?: any;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
export function Server({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: ServerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const { addTab, tabs } = useTabs() as any;
|
||||
const [serverStatus, setServerStatus] = React.useState<"online" | "offline">(
|
||||
"offline",
|
||||
);
|
||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("serverStats.failedToFetchHostConfig"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const { getSSHHosts } = await import("@/ui/main-axios.ts");
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("serverStats.failedToFetchHostConfig"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
let intervalId: number | undefined;
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await getServerStatusById(currentHostConfig?.id);
|
||||
if (!cancelled) {
|
||||
setServerStatus(res?.status === "online" ? "online" : "offline");
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (!cancelled) {
|
||||
if (error?.response?.status === 503) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 504) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 404) {
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
}
|
||||
toast.error(t("serverStats.failedToFetchStatus"));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchMetrics = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
try {
|
||||
setIsLoadingMetrics(true);
|
||||
const data = await getServerMetricsById(currentHostConfig.id);
|
||||
if (!cancelled) {
|
||||
setMetrics(data);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setMetrics(null);
|
||||
toast.error(t("serverStats.failedToFetchMetrics"));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingMetrics(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (currentHostConfig?.id && isVisible) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
intervalId = window.setInterval(() => {
|
||||
if (isVisible) {
|
||||
fetchStatus();
|
||||
fetchMetrics();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (intervalId) window.clearInterval(intervalId);
|
||||
};
|
||||
}, [currentHostConfig?.id, isVisible]);
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = sidebarState === "collapsed" ? 16 : 8;
|
||||
const bottomMarginPx = 8;
|
||||
|
||||
const isFileManagerAlreadyOpen = React.useMemo(() => {
|
||||
if (!currentHostConfig) return false;
|
||||
return tabs.some(
|
||||
(tab: any) =>
|
||||
tab.type === "file_manager" &&
|
||||
tab.hostConfig?.id === currentHostConfig.id,
|
||||
);
|
||||
}, [tabs, currentHostConfig]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
marginLeft: leftMarginPx,
|
||||
marginRight: 17,
|
||||
marginTop: topMarginPx,
|
||||
marginBottom: bottomMarginPx,
|
||||
height: `calc(100vh - ${topMarginPx + bottomMarginPx}px)`,
|
||||
};
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-white overflow-hidden bg-transparent"
|
||||
: "bg-dark-bg text-white rounded-lg border-2 border-dark-border overflow-hidden";
|
||||
|
||||
return (
|
||||
<div style={wrapperStyle} className={containerClass}>
|
||||
<div className="h-full w-full flex flex-col">
|
||||
{/* Top Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between px-4 pt-3 pb-3 gap-3">
|
||||
<div className="flex items-center gap-4 min-w-0">
|
||||
<div className="min-w-0">
|
||||
<h1 className="font-bold text-lg truncate">
|
||||
{currentHostConfig?.folder} / {title}
|
||||
</h1>
|
||||
</div>
|
||||
<Status
|
||||
status={serverStatus}
|
||||
className="!bg-transparent !p-0.75 flex-shrink-0"
|
||||
>
|
||||
<StatusIndicator />
|
||||
</Status>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isRefreshing}
|
||||
onClick={async () => {
|
||||
if (currentHostConfig?.id) {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const res = await getServerStatusById(currentHostConfig.id);
|
||||
setServerStatus(
|
||||
res?.status === "online" ? "online" : "offline",
|
||||
);
|
||||
const data = await getServerMetricsById(
|
||||
currentHostConfig.id,
|
||||
);
|
||||
setMetrics(data);
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 503) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 504) {
|
||||
setServerStatus("offline");
|
||||
} else if (error?.response?.status === 404) {
|
||||
setServerStatus("offline");
|
||||
} else {
|
||||
setServerStatus("offline");
|
||||
}
|
||||
setMetrics(null);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
title={t("serverStats.refreshStatusAndMetrics")}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-4 h-4 border-2 border-gray-300 border-t-transparent rounded-full animate-spin"></div>
|
||||
{t("serverStats.refreshing")}
|
||||
</div>
|
||||
) : (
|
||||
t("serverStats.refreshStatus")
|
||||
)}
|
||||
</Button>
|
||||
{currentHostConfig?.enableFileManager && (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="font-semibold"
|
||||
disabled={isFileManagerAlreadyOpen}
|
||||
title={
|
||||
isFileManagerAlreadyOpen
|
||||
? t("serverStats.fileManagerAlreadyOpen")
|
||||
: t("serverStats.openFileManager")
|
||||
}
|
||||
onClick={() => {
|
||||
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||
const titleBase =
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name.trim()
|
||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
addTab({
|
||||
type: "file_manager",
|
||||
title: titleBase,
|
||||
hostConfig: currentHostConfig,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("nav.fileManager")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Separator className="p-0.25 w-full" />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker p-4">
|
||||
{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>
|
||||
</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 xl:grid-cols-3 gap-4 lg:gap-6">
|
||||
{/* CPU Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="h-5 w-5 text-blue-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.cpuUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.cpu?.percent;
|
||||
const cores = metrics?.cpu?.cores;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const coresText =
|
||||
typeof cores === "number"
|
||||
? t("serverStats.cpuCores", { count: cores })
|
||||
: t("serverStats.naCpus");
|
||||
return `${pctText} ${t("serverStats.of")} ${coresText}`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.cpu?.percent === "number"
|
||||
? metrics!.cpu!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{metrics?.cpu?.load
|
||||
? `Load: ${metrics.cpu.load[0].toFixed(2)}, ${metrics.cpu.load[1].toFixed(2)}, ${metrics.cpu.load[2].toFixed(2)}`
|
||||
: "Load: N/A"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Memory Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<MemoryStick className="h-5 w-5 text-green-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.memoryUsage")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.memory?.percent;
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const usedText =
|
||||
typeof used === "number"
|
||||
? `${used.toFixed(1)} GiB`
|
||||
: "N/A";
|
||||
const totalText =
|
||||
typeof total === "number"
|
||||
? `${total.toFixed(1)} GiB`
|
||||
: "N/A";
|
||||
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.memory?.percent === "number"
|
||||
? metrics!.memory!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.memory?.usedGiB;
|
||||
const total = metrics?.memory?.totalGiB;
|
||||
const free =
|
||||
typeof used === "number" && typeof total === "number"
|
||||
? (total - used).toFixed(1)
|
||||
: "N/A";
|
||||
return `Free: ${free} GiB`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disk Stats */}
|
||||
<div className="space-y-3 p-4 rounded-lg bg-dark-bg/50 border border-dark-border/50 hover:bg-dark-bg/70 transition-colors duration-200">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<HardDrive className="h-5 w-5 text-orange-400" />
|
||||
<h3 className="font-semibold text-lg text-white">
|
||||
{t("serverStats.rootStorageSpace")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-300">
|
||||
{(() => {
|
||||
const pct = metrics?.disk?.percent;
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
const pctText =
|
||||
typeof pct === "number" ? `${pct}%` : "N/A";
|
||||
const usedText = used ?? "N/A";
|
||||
const totalText = total ?? "N/A";
|
||||
return `${pctText} (${usedText} ${t("serverStats.of")} ${totalText})`;
|
||||
})()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Progress
|
||||
value={
|
||||
typeof metrics?.disk?.percent === "number"
|
||||
? metrics!.disk!.percent!
|
||||
: 0
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500">
|
||||
{(() => {
|
||||
const used = metrics?.disk?.usedHuman;
|
||||
const total = metrics?.disk?.totalHuman;
|
||||
return used && total
|
||||
? `Available: ${total}`
|
||||
: "Available: N/A";
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SSH Tunnels */}
|
||||
{currentHostConfig?.tunnelConnections &&
|
||||
currentHostConfig.tunnelConnections.length > 0 && (
|
||||
<div className="rounded-lg border-2 border-dark-border m-3 bg-dark-bg-darker h-[360px] overflow-hidden flex flex-col min-h-0">
|
||||
<Tunnel
|
||||
filterHostKey={
|
||||
currentHostConfig?.name &&
|
||||
currentHostConfig.name.trim() !== ""
|
||||
? currentHostConfig.name
|
||||
: `${currentHostConfig?.username}@${currentHostConfig?.ip}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="px-4 pt-2 pb-2 text-sm text-gray-500">
|
||||
{t("serverStats.feedbackMessage")}{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
634
src/ui/Desktop/Apps/Terminal/Terminal.tsx
Normal file
634
src/ui/Desktop/Apps/Terminal/Terminal.tsx
Normal file
@@ -0,0 +1,634 @@
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useImperativeHandle,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { Unicode11Addon } from "@xterm/addon-unicode11";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { getCookie, isElectron } from "@/ui/main-axios.ts";
|
||||
|
||||
interface SSHTerminalProps {
|
||||
hostConfig: any;
|
||||
isVisible: boolean;
|
||||
title?: string;
|
||||
showTitle?: boolean;
|
||||
splitScreen?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||
{ hostConfig, isVisible, splitScreen = false, onClose },
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const { instance: terminal, ref: xtermRef } = useXTerm();
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const webSocketRef = useRef<WebSocket | null>(null);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(false);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const isVisibleRef = useRef<boolean>(false);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttempts = useRef(0);
|
||||
const maxReconnectAttempts = 3;
|
||||
const isUnmountingRef = useRef(false);
|
||||
const shouldNotReconnectRef = useRef(false);
|
||||
const isReconnectingRef = useRef(false);
|
||||
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
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 DEBOUNCE_MS = 140;
|
||||
|
||||
useEffect(() => {
|
||||
isVisibleRef.current = isVisible;
|
||||
}, [isVisible]);
|
||||
|
||||
function hardRefresh() {
|
||||
try {
|
||||
if (terminal && typeof (terminal as any).refresh === "function") {
|
||||
(terminal as any).refresh(0, terminal.rows - 1);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function scheduleNotify(cols: number, rows: number) {
|
||||
if (!(cols > 0 && rows > 0)) return;
|
||||
pendingSizeRef.current = { cols, rows };
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
notifyTimerRef.current = setTimeout(() => {
|
||||
const next = pendingSizeRef.current;
|
||||
const last = lastSentSizeRef.current;
|
||||
if (!next) return;
|
||||
if (last && last.cols === next.cols && last.rows === next.rows) return;
|
||||
if (webSocketRef.current?.readyState === WebSocket.OPEN) {
|
||||
webSocketRef.current.send(
|
||||
JSON.stringify({ type: "resize", data: next }),
|
||||
);
|
||||
lastSentSizeRef.current = next;
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
disconnect: () => {
|
||||
isUnmountingRef.current = true;
|
||||
shouldNotReconnectRef.current = true;
|
||||
isReconnectingRef.current = false;
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
setIsConnected(false);
|
||||
setIsConnecting(false);
|
||||
},
|
||||
fit: () => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
},
|
||||
sendInput: (data: string) => {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
},
|
||||
notifyResize: () => {
|
||||
try {
|
||||
const cols = terminal?.cols ?? undefined;
|
||||
const rows = terminal?.rows ?? undefined;
|
||||
if (typeof cols === "number" && typeof rows === "number") {
|
||||
scheduleNotify(cols, rows);
|
||||
hardRefresh();
|
||||
}
|
||||
} catch (_) {}
|
||||
},
|
||||
refresh: () => hardRefresh(),
|
||||
}),
|
||||
[terminal],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("resize", handleWindowResize);
|
||||
return () => window.removeEventListener("resize", handleWindowResize);
|
||||
}, []);
|
||||
|
||||
function handleWindowResize() {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}
|
||||
|
||||
function getUseRightClickCopyPaste() {
|
||||
return getCookie("rightClickCopyPaste") === "true";
|
||||
}
|
||||
|
||||
function attemptReconnection() {
|
||||
if (
|
||||
isUnmountingRef.current ||
|
||||
shouldNotReconnectRef.current ||
|
||||
isReconnectingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectAttempts.current >= maxReconnectAttempts) {
|
||||
toast.error(t("terminal.maxReconnectAttemptsReached"));
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isReconnectingRef.current = true;
|
||||
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
|
||||
reconnectAttempts.current++;
|
||||
|
||||
toast.info(
|
||||
t("terminal.reconnecting", {
|
||||
attempt: reconnectAttempts.current,
|
||||
max: maxReconnectAttempts,
|
||||
}),
|
||||
);
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (isUnmountingRef.current || shouldNotReconnectRef.current) {
|
||||
isReconnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (reconnectAttempts.current > maxReconnectAttempts) {
|
||||
isReconnectingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (terminal && hostConfig) {
|
||||
terminal.clear();
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
connectToHost(cols, rows);
|
||||
}
|
||||
|
||||
isReconnectingRef.current = false;
|
||||
}, 2000 * reconnectAttempts.current);
|
||||
}
|
||||
|
||||
function connectToHost(cols: number, rows: number) {
|
||||
const isDev =
|
||||
process.env.NODE_ENV === "development" &&
|
||||
(window.location.port === "3000" ||
|
||||
window.location.port === "5173" ||
|
||||
window.location.port === "");
|
||||
|
||||
const wsUrl = isDev
|
||||
? "ws://localhost:8082"
|
||||
: isElectron()
|
||||
? (() => {
|
||||
const baseUrl =
|
||||
(window as any).configuredServerUrl || "http://127.0.0.1:8081";
|
||||
const wsProtocol = baseUrl.startsWith("https://")
|
||||
? "wss://"
|
||||
: "ws://";
|
||||
const wsHost = baseUrl.replace(/^https?:\/\//, "");
|
||||
return `${wsProtocol}${wsHost}/ssh/websocket/`;
|
||||
})()
|
||||
: `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
webSocketRef.current = ws;
|
||||
wasDisconnectedBySSH.current = false;
|
||||
setConnectionError(null);
|
||||
shouldNotReconnectRef.current = false;
|
||||
isReconnectingRef.current = false;
|
||||
setIsConnecting(true);
|
||||
|
||||
setupWebSocketListeners(ws, cols, rows);
|
||||
}
|
||||
|
||||
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
|
||||
ws.addEventListener("open", () => {
|
||||
connectionTimeoutRef.current = setTimeout(() => {
|
||||
if (!isConnected) {
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
toast.error(t("terminal.connectionTimeout"));
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
if (reconnectAttempts.current > 0) {
|
||||
attemptReconnection();
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connectToHost",
|
||||
data: { cols, rows, hostConfig },
|
||||
}),
|
||||
);
|
||||
terminal.onData((data) => {
|
||||
ws.send(JSON.stringify({ type: "input", data }));
|
||||
});
|
||||
|
||||
pingIntervalRef.current = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.type === "data") {
|
||||
terminal.write(msg.data);
|
||||
} else if (msg.type === "error") {
|
||||
const errorMessage = msg.message || t("terminal.unknownError");
|
||||
|
||||
if (
|
||||
errorMessage.toLowerCase().includes("auth") ||
|
||||
errorMessage.toLowerCase().includes("password") ||
|
||||
errorMessage.toLowerCase().includes("permission") ||
|
||||
errorMessage.toLowerCase().includes("denied") ||
|
||||
errorMessage.toLowerCase().includes("invalid") ||
|
||||
errorMessage.toLowerCase().includes("failed") ||
|
||||
errorMessage.toLowerCase().includes("incorrect")
|
||||
) {
|
||||
toast.error(t("terminal.authError", { message: errorMessage }));
|
||||
shouldNotReconnectRef.current = true;
|
||||
if (webSocketRef.current) {
|
||||
webSocketRef.current.close();
|
||||
}
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
errorMessage.toLowerCase().includes("connection") ||
|
||||
errorMessage.toLowerCase().includes("timeout") ||
|
||||
errorMessage.toLowerCase().includes("network")
|
||||
) {
|
||||
toast.error(
|
||||
t("terminal.connectionError", { message: errorMessage }),
|
||||
);
|
||||
setIsConnected(false);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
setIsConnecting(true);
|
||||
attemptReconnection();
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(t("terminal.error", { message: errorMessage }));
|
||||
} else if (msg.type === "connected") {
|
||||
setIsConnected(true);
|
||||
setIsConnecting(false);
|
||||
if (connectionTimeoutRef.current) {
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
connectionTimeoutRef.current = null;
|
||||
}
|
||||
if (reconnectAttempts.current > 0) {
|
||||
toast.success(t("terminal.reconnected"));
|
||||
}
|
||||
reconnectAttempts.current = 0;
|
||||
isReconnectingRef.current = false;
|
||||
} else if (msg.type === "disconnected") {
|
||||
wasDisconnectedBySSH.current = true;
|
||||
setIsConnected(false);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
setIsConnecting(true);
|
||||
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
|
||||
attemptReconnection();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(t("terminal.messageParseError"));
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", (event) => {
|
||||
setIsConnected(false);
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
setIsConnecting(true);
|
||||
if (
|
||||
!wasDisconnectedBySSH.current &&
|
||||
!isUnmountingRef.current &&
|
||||
!shouldNotReconnectRef.current
|
||||
) {
|
||||
attemptReconnection();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
setIsConnected(false);
|
||||
setConnectionError(t("terminal.websocketError"));
|
||||
if (terminal) {
|
||||
terminal.clear();
|
||||
}
|
||||
setIsConnecting(true);
|
||||
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
|
||||
attemptReconnection();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function writeTextToClipboard(text: string): Promise<void> {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.left = "-9999px";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
async function readTextFromClipboard(): Promise<string> {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch (_) {}
|
||||
return "";
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!terminal || !xtermRef.current || !hostConfig) return;
|
||||
|
||||
terminal.options = {
|
||||
cursorBlink: true,
|
||||
cursorStyle: "bar",
|
||||
scrollback: 10000,
|
||||
fontSize: 14,
|
||||
fontFamily:
|
||||
'"JetBrains Mono Nerd Font", "MesloLGS NF", "FiraCode Nerd Font", "Cascadia Code", "JetBrains Mono", Consolas, "Courier New", monospace',
|
||||
theme: { background: "#18181b", foreground: "#f7f7f7" },
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
windowsMode: false,
|
||||
macOptionIsMeta: false,
|
||||
macOptionClickForcesSelection: false,
|
||||
rightClickSelectsWord: false,
|
||||
fastScrollModifier: "alt",
|
||||
fastScrollSensitivity: 5,
|
||||
allowProposedApi: true,
|
||||
};
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const clipboardAddon = new ClipboardAddon();
|
||||
const unicode11Addon = new Unicode11Addon();
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(clipboardAddon);
|
||||
terminal.loadAddon(unicode11Addon);
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
terminal.open(xtermRef.current);
|
||||
|
||||
const element = xtermRef.current;
|
||||
const handleContextMenu = async (e: MouseEvent) => {
|
||||
if (!getUseRightClickCopyPaste()) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
try {
|
||||
if (terminal.hasSelection()) {
|
||||
const selection = terminal.getSelection();
|
||||
if (selection) {
|
||||
await writeTextToClipboard(selection);
|
||||
terminal.clearSelection();
|
||||
}
|
||||
} else {
|
||||
const pasteText = await readTextFromClipboard();
|
||||
if (pasteText) terminal.paste(pasteText);
|
||||
}
|
||||
} catch (_) {}
|
||||
};
|
||||
element?.addEventListener("contextmenu", handleContextMenu);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
resizeTimeout.current = setTimeout(() => {
|
||||
if (!isVisibleRef.current) return;
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
resizeObserver.observe(xtermRef.current);
|
||||
|
||||
const readyFonts =
|
||||
(document as any).fonts?.ready instanceof Promise
|
||||
? (document as any).fonts.ready
|
||||
: Promise.resolve();
|
||||
readyFonts.then(() => {
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
setTimeout(() => {
|
||||
fitAddon.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
setVisible(true);
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
|
||||
const cols = terminal.cols;
|
||||
const rows = terminal.rows;
|
||||
|
||||
connectToHost(cols, rows);
|
||||
}, 300);
|
||||
});
|
||||
|
||||
return () => {
|
||||
isUnmountingRef.current = true;
|
||||
shouldNotReconnectRef.current = true;
|
||||
isReconnectingRef.current = false;
|
||||
setIsConnecting(false);
|
||||
resizeObserver.disconnect();
|
||||
element?.removeEventListener("contextmenu", handleContextMenu);
|
||||
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
|
||||
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
|
||||
if (reconnectTimeoutRef.current)
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
if (connectionTimeoutRef.current)
|
||||
clearTimeout(connectionTimeoutRef.current);
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
webSocketRef.current?.close();
|
||||
};
|
||||
}, [xtermRef, terminal, hostConfig]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && fitAddonRef.current) {
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
|
||||
if (terminal && !splitScreen) {
|
||||
setTimeout(() => {
|
||||
terminal.focus();
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, [isVisible, splitScreen, terminal]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fitAddonRef.current) return;
|
||||
setTimeout(() => {
|
||||
fitAddonRef.current?.fit();
|
||||
if (terminal) scheduleNotify(terminal.cols, terminal.rows);
|
||||
hardRefresh();
|
||||
if (terminal && !splitScreen && isVisible) {
|
||||
terminal.focus();
|
||||
}
|
||||
}, 0);
|
||||
}, [splitScreen, isVisible, terminal]);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full m-1 relative">
|
||||
{/* Terminal */}
|
||||
<div
|
||||
ref={xtermRef}
|
||||
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? "opacity-100" : "opacity-0"} overflow-hidden`}
|
||||
onClick={() => {
|
||||
if (terminal && !splitScreen) {
|
||||
terminal.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Connecting State */}
|
||||
{isConnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-dark-bg">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = `
|
||||
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&display=swap');
|
||||
|
||||
/* Load NerdFonts locally */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono Nerd Font';
|
||||
src: url('./fonts/JetBrainsMonoNerdFont-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(180,180,180,0.7);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(120,120,120,0.9);
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(180,180,180,0.7) transparent;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font', 'Cascadia Code', 'JetBrains Mono', Consolas, "Courier New", monospace !important;
|
||||
font-variant-ligatures: contextual;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char {
|
||||
font-feature-settings: "liga" 1, "calt" 1;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char[data-char-code^="\\uE"] {
|
||||
font-family: 'JetBrains Mono Nerd Font', 'MesloLGS NF', 'FiraCode Nerd Font' !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
206
src/ui/Desktop/Apps/Tunnel/Tunnel.tsx
Normal file
206
src/ui/Desktop/Apps/Tunnel/Tunnel.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { TunnelViewer } from "@/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx";
|
||||
import {
|
||||
getSSHHosts,
|
||||
getTunnelStatuses,
|
||||
connectTunnel,
|
||||
disconnectTunnel,
|
||||
cancelTunnel,
|
||||
} from "@/ui/main-axios.ts";
|
||||
import type {
|
||||
SSHHost,
|
||||
TunnelConnection,
|
||||
TunnelStatus,
|
||||
SSHTunnelProps,
|
||||
} from "../../../types/index.js";
|
||||
|
||||
export function Tunnel({ filterHostKey }: SSHTunnelProps): React.ReactElement {
|
||||
const [allHosts, setAllHosts] = useState<SSHHost[]>([]);
|
||||
const [visibleHosts, setVisibleHosts] = useState<SSHHost[]>([]);
|
||||
const [tunnelStatuses, setTunnelStatuses] = useState<
|
||||
Record<string, TunnelStatus>
|
||||
>({});
|
||||
const [tunnelActions, setTunnelActions] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const prevVisibleHostRef = React.useRef<SSHHost | null>(null);
|
||||
|
||||
const haveTunnelConnectionsChanged = (
|
||||
a: TunnelConnection[] = [],
|
||||
b: TunnelConnection[] = [],
|
||||
): boolean => {
|
||||
if (a.length !== b.length) return true;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const x = a[i];
|
||||
const y = b[i];
|
||||
if (
|
||||
x.sourcePort !== y.sourcePort ||
|
||||
x.endpointPort !== y.endpointPort ||
|
||||
x.endpointHost !== y.endpointHost ||
|
||||
x.maxRetries !== y.maxRetries ||
|
||||
x.retryInterval !== y.retryInterval ||
|
||||
x.autoStart !== y.autoStart
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const fetchHosts = useCallback(async () => {
|
||||
const hostsData = await getSSHHosts();
|
||||
setAllHosts(hostsData);
|
||||
const nextVisible = filterHostKey
|
||||
? hostsData.filter((h) => {
|
||||
const key =
|
||||
h.name && h.name.trim() !== "" ? h.name : `${h.username}@${h.ip}`;
|
||||
return key === filterHostKey;
|
||||
})
|
||||
: hostsData;
|
||||
|
||||
const prev = prevVisibleHostRef.current;
|
||||
const curr = nextVisible[0] ?? null;
|
||||
let changed = false;
|
||||
if (!prev && curr) changed = true;
|
||||
else if (prev && !curr) changed = true;
|
||||
else if (prev && curr) {
|
||||
if (
|
||||
prev.id !== curr.id ||
|
||||
prev.name !== curr.name ||
|
||||
prev.ip !== curr.ip ||
|
||||
prev.port !== curr.port ||
|
||||
prev.username !== curr.username ||
|
||||
haveTunnelConnectionsChanged(
|
||||
prev.tunnelConnections,
|
||||
curr.tunnelConnections,
|
||||
)
|
||||
) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) {
|
||||
setVisibleHosts(nextVisible);
|
||||
prevVisibleHostRef.current = curr;
|
||||
}
|
||||
}, [filterHostKey]);
|
||||
|
||||
const fetchTunnelStatuses = useCallback(async () => {
|
||||
const statusData = await getTunnelStatuses();
|
||||
setTunnelStatuses(statusData);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchHosts();
|
||||
const interval = setInterval(fetchHosts, 5000);
|
||||
|
||||
const handleHostsChanged = () => {
|
||||
fetchHosts();
|
||||
};
|
||||
window.addEventListener(
|
||||
"ssh-hosts:changed",
|
||||
handleHostsChanged as EventListener,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener(
|
||||
"ssh-hosts:changed",
|
||||
handleHostsChanged as EventListener,
|
||||
);
|
||||
};
|
||||
}, [fetchHosts]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTunnelStatuses();
|
||||
const interval = setInterval(fetchTunnelStatuses, 500);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchTunnelStatuses]);
|
||||
|
||||
const handleTunnelAction = async (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
|
||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: true }));
|
||||
|
||||
try {
|
||||
if (action === "connect") {
|
||||
const endpointHost = allHosts.find(
|
||||
(h) =>
|
||||
h.name === tunnel.endpointHost ||
|
||||
`${h.username}@${h.ip}` === tunnel.endpointHost,
|
||||
);
|
||||
|
||||
if (!endpointHost) {
|
||||
throw new Error("Endpoint host not found");
|
||||
}
|
||||
|
||||
const tunnelConfig = {
|
||||
name: tunnelName,
|
||||
hostName: host.name || `${host.username}@${host.ip}`,
|
||||
sourceIP: host.ip,
|
||||
sourceSSHPort: host.port,
|
||||
sourceUsername: host.username,
|
||||
sourcePassword:
|
||||
host.authType === "password" ? host.password : undefined,
|
||||
sourceAuthMethod: host.authType,
|
||||
sourceSSHKey: host.authType === "key" ? host.key : undefined,
|
||||
sourceKeyPassword:
|
||||
host.authType === "key" ? host.keyPassword : undefined,
|
||||
sourceKeyType: host.authType === "key" ? host.keyType : undefined,
|
||||
sourceCredentialId: host.credentialId,
|
||||
sourceUserId: host.userId,
|
||||
endpointIP: endpointHost.ip,
|
||||
endpointSSHPort: endpointHost.port,
|
||||
endpointUsername: endpointHost.username,
|
||||
endpointPassword:
|
||||
endpointHost.authType === "password"
|
||||
? endpointHost.password
|
||||
: undefined,
|
||||
endpointAuthMethod: endpointHost.authType,
|
||||
endpointSSHKey:
|
||||
endpointHost.authType === "key" ? endpointHost.key : undefined,
|
||||
endpointKeyPassword:
|
||||
endpointHost.authType === "key"
|
||||
? endpointHost.keyPassword
|
||||
: undefined,
|
||||
endpointKeyType:
|
||||
endpointHost.authType === "key" ? endpointHost.keyType : undefined,
|
||||
endpointCredentialId: endpointHost.credentialId,
|
||||
endpointUserId: endpointHost.userId,
|
||||
sourcePort: tunnel.sourcePort,
|
||||
endpointPort: tunnel.endpointPort,
|
||||
maxRetries: tunnel.maxRetries,
|
||||
retryInterval: tunnel.retryInterval * 1000,
|
||||
autoStart: tunnel.autoStart,
|
||||
isPinned: host.pin,
|
||||
};
|
||||
|
||||
await connectTunnel(tunnelConfig);
|
||||
} else if (action === "disconnect") {
|
||||
await disconnectTunnel(tunnelName);
|
||||
} else if (action === "cancel") {
|
||||
await cancelTunnel(tunnelName);
|
||||
}
|
||||
|
||||
await fetchTunnelStatuses();
|
||||
} catch (err) {
|
||||
} finally {
|
||||
setTunnelActions((prev) => ({ ...prev, [tunnelName]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TunnelViewer
|
||||
hosts={visibleHosts}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={handleTunnelAction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
533
src/ui/Desktop/Apps/Tunnel/TunnelObject.tsx
Normal file
533
src/ui/Desktop/Apps/Tunnel/TunnelObject.tsx
Normal file
@@ -0,0 +1,533 @@
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button.tsx";
|
||||
import { Card } from "@/components/ui/card.tsx";
|
||||
import { Separator } from "@/components/ui/separator.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Loader2,
|
||||
Pin,
|
||||
Network,
|
||||
Tag,
|
||||
Play,
|
||||
Square,
|
||||
AlertCircle,
|
||||
Clock,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge.tsx";
|
||||
import type {
|
||||
TunnelStatus,
|
||||
SSHTunnelObjectProps,
|
||||
} from "../../../types/index.js";
|
||||
|
||||
export function TunnelObject({
|
||||
host,
|
||||
tunnelStatuses,
|
||||
tunnelActions,
|
||||
onTunnelAction,
|
||||
compact = false,
|
||||
bare = false,
|
||||
}: SSHTunnelObjectProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getTunnelStatus = (tunnelIndex: number): TunnelStatus | undefined => {
|
||||
const tunnel = host.tunnelConnections[tunnelIndex];
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
return tunnelStatuses[tunnelName];
|
||||
};
|
||||
|
||||
const getTunnelStatusDisplay = (status: TunnelStatus | undefined) => {
|
||||
if (!status)
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: t("tunnels.unknown"),
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted/50",
|
||||
borderColor: "border-border",
|
||||
};
|
||||
|
||||
const statusValue = status.status || "DISCONNECTED";
|
||||
|
||||
switch (statusValue.toUpperCase()) {
|
||||
case "CONNECTED":
|
||||
return {
|
||||
icon: <Wifi className="h-4 w-4" />,
|
||||
text: t("tunnels.connected"),
|
||||
color: "text-green-600 dark:text-green-400",
|
||||
bgColor: "bg-green-500/10 dark:bg-green-400/10",
|
||||
borderColor: "border-green-500/20 dark:border-green-400/20",
|
||||
};
|
||||
case "CONNECTING":
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
text: t("tunnels.connecting"),
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
|
||||
borderColor: "border-blue-500/20 dark:border-blue-400/20",
|
||||
};
|
||||
case "DISCONNECTING":
|
||||
return {
|
||||
icon: <Loader2 className="h-4 w-4 animate-spin" />,
|
||||
text: t("tunnels.disconnecting"),
|
||||
color: "text-orange-600 dark:text-orange-400",
|
||||
bgColor: "bg-orange-500/10 dark:bg-orange-400/10",
|
||||
borderColor: "border-orange-500/20 dark:border-orange-400/20",
|
||||
};
|
||||
case "DISCONNECTED":
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: t("tunnels.disconnected"),
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted/30",
|
||||
borderColor: "border-border",
|
||||
};
|
||||
case "WAITING":
|
||||
return {
|
||||
icon: <Clock className="h-4 w-4" />,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-500/10 dark:bg-blue-400/10",
|
||||
borderColor: "border-blue-500/20 dark:border-blue-400/20",
|
||||
};
|
||||
case "ERROR":
|
||||
case "FAILED":
|
||||
return {
|
||||
icon: <AlertCircle className="h-4 w-4" />,
|
||||
text: status.reason || t("tunnels.error"),
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-500/10 dark:bg-red-400/10",
|
||||
borderColor: "border-red-500/20 dark:border-red-400/20",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <WifiOff className="h-4 w-4" />,
|
||||
text: statusValue,
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted/30",
|
||||
borderColor: "border-border",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (bare) {
|
||||
return (
|
||||
<div className="w-full min-w-0">
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||
const status = getTunnelStatus(tunnelIndex);
|
||||
const statusDisplay = getTunnelStatusDisplay(status);
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
const isActionLoading = tunnelActions[tunnelName];
|
||||
const statusValue =
|
||||
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||
const isConnected = statusValue === "CONNECTED";
|
||||
const isConnecting = statusValue === "CONNECTING";
|
||||
const isDisconnecting = statusValue === "DISCONNECTING";
|
||||
const isRetrying = statusValue === "RETRYING";
|
||||
const isWaiting = statusValue === "WAITING";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tunnelIndex}
|
||||
className={`border rounded-lg p-3 min-w-0 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span
|
||||
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
|
||||
>
|
||||
{statusDisplay.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
{t("tunnels.port")} {tunnel.sourcePort} →{" "}
|
||||
{tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${statusDisplay.color} font-medium`}
|
||||
>
|
||||
{statusDisplay.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 flex-shrink-0 min-w-[120px]">
|
||||
{!isActionLoading ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction(
|
||||
"disconnect",
|
||||
host,
|
||||
tunnelIndex,
|
||||
)
|
||||
}
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.disconnect")}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("cancel", host, tunnelIndex)
|
||||
}
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("connect", host, tunnelIndex)
|
||||
}
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.connect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{isConnected
|
||||
? t("tunnels.disconnecting")
|
||||
: isRetrying || isWaiting
|
||||
? t("tunnels.canceling")
|
||||
: t("tunnels.connecting")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statusValue === "ERROR" || statusValue === "FAILED") &&
|
||||
status?.reason && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{t("tunnels.error")}:
|
||||
</div>
|
||||
{status.reason}
|
||||
{status.reason &&
|
||||
status.reason.includes("Max retries exhausted") && (
|
||||
<>
|
||||
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
{t("tunnels.checkDockerLogs")}{" "}
|
||||
<a
|
||||
href="https://discord.com/invite/jVQGdvHDrf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
Discord
|
||||
</a>{" "}
|
||||
or create a{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
GitHub issue
|
||||
</a>{" "}
|
||||
for help.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(statusValue === "RETRYING" ||
|
||||
statusValue === "WAITING") &&
|
||||
status?.retryCount &&
|
||||
status?.maxRetries && (
|
||||
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === "WAITING"
|
||||
? t("tunnels.waitingForRetry")
|
||||
: t("tunnels.retryingConnection")}
|
||||
</div>
|
||||
<div>
|
||||
{t("tunnels.attempt", {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries,
|
||||
})}
|
||||
{status.nextRetryIn && (
|
||||
<span>
|
||||
{" "}
|
||||
•{" "}
|
||||
{t("tunnels.nextRetryIn", {
|
||||
seconds: status.nextRetryIn,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full bg-card border-border shadow-sm hover:shadow-md transition-shadow relative group p-0">
|
||||
<div className="p-4">
|
||||
{!compact && (
|
||||
<div className="flex items-center justify-between gap-2 mb-3">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{host.pin && (
|
||||
<Pin className="h-4 w-4 text-yellow-500 flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-card-foreground truncate">
|
||||
{host.name || `${host.username}@${host.ip}`}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{host.ip}:{host.port} • {host.username}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && host.tags && host.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
{host.tags.slice(0, 3).map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
className="text-xs px-1 py-0"
|
||||
>
|
||||
<Tag className="h-2 w-2 mr-0.5" />
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{host.tags.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
+{host.tags.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!compact && <Separator className="mb-3" />}
|
||||
|
||||
<div className="space-y-3">
|
||||
{!compact && (
|
||||
<h4 className="text-sm font-medium text-card-foreground flex items-center gap-2">
|
||||
<Network className="h-4 w-4" />
|
||||
{t("tunnels.tunnelConnections")} ({host.tunnelConnections.length})
|
||||
</h4>
|
||||
)}
|
||||
{host.tunnelConnections && host.tunnelConnections.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{host.tunnelConnections.map((tunnel, tunnelIndex) => {
|
||||
const status = getTunnelStatus(tunnelIndex);
|
||||
const statusDisplay = getTunnelStatusDisplay(status);
|
||||
const tunnelName = `${host.name || `${host.username}@${host.ip}`}_${tunnel.sourcePort}_${tunnel.endpointPort}`;
|
||||
const isActionLoading = tunnelActions[tunnelName];
|
||||
const statusValue =
|
||||
status?.status?.toUpperCase() || "DISCONNECTED";
|
||||
const isConnected = statusValue === "CONNECTED";
|
||||
const isConnecting = statusValue === "CONNECTING";
|
||||
const isDisconnecting = statusValue === "DISCONNECTING";
|
||||
const isRetrying = statusValue === "RETRYING";
|
||||
const isWaiting = statusValue === "WAITING";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tunnelIndex}
|
||||
className={`border rounded-lg p-3 ${statusDisplay.bgColor} ${statusDisplay.borderColor}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0">
|
||||
<span
|
||||
className={`${statusDisplay.color} mt-0.5 flex-shrink-0`}
|
||||
>
|
||||
{statusDisplay.icon}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium break-words">
|
||||
{t("tunnels.port")} {tunnel.sourcePort} →{" "}
|
||||
{tunnel.endpointHost}:{tunnel.endpointPort}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${statusDisplay.color} font-medium`}
|
||||
>
|
||||
{statusDisplay.text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{!isActionLoading && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction(
|
||||
"disconnect",
|
||||
host,
|
||||
tunnelIndex,
|
||||
)
|
||||
}
|
||||
className="h-7 px-2 text-red-600 dark:text-red-400 border-red-500/30 dark:border-red-400/30 hover:bg-red-500/10 dark:hover:bg-red-400/10 hover:border-red-500/50 dark:hover:border-red-400/50 text-xs"
|
||||
>
|
||||
<Square className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.disconnect")}
|
||||
</Button>
|
||||
</>
|
||||
) : isRetrying || isWaiting ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("cancel", host, tunnelIndex)
|
||||
}
|
||||
className="h-7 px-2 text-orange-600 dark:text-orange-400 border-orange-500/30 dark:border-orange-400/30 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 hover:border-orange-500/50 dark:hover:border-orange-400/50 text-xs"
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.cancel")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
onTunnelAction("connect", host, tunnelIndex)
|
||||
}
|
||||
disabled={isConnecting || isDisconnecting}
|
||||
className="h-7 px-2 text-green-600 dark:text-green-400 border-green-500/30 dark:border-green-400/30 hover:bg-green-500/10 dark:hover:bg-green-400/10 hover:border-green-500/50 dark:hover:border-green-400/50 text-xs"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
{t("tunnels.connect")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isActionLoading && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="h-7 px-2 text-muted-foreground border-border text-xs"
|
||||
>
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
{isConnected
|
||||
? t("tunnels.disconnecting")
|
||||
: isRetrying || isWaiting
|
||||
? t("tunnels.canceling")
|
||||
: t("tunnels.connecting")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(statusValue === "ERROR" || statusValue === "FAILED") &&
|
||||
status?.reason && (
|
||||
<div className="mt-2 text-xs text-red-600 dark:text-red-400 bg-red-500/10 dark:bg-red-400/10 rounded px-3 py-2 border border-red-500/20 dark:border-red-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{t("tunnels.error")}:
|
||||
</div>
|
||||
{status.reason}
|
||||
{status.reason &&
|
||||
status.reason.includes("Max retries exhausted") && (
|
||||
<>
|
||||
<div className="mt-2 pt-2 border-t border-red-500/20 dark:border-red-400/20">
|
||||
{t("tunnels.checkDockerLogs")}{" "}
|
||||
<a
|
||||
href="https://discord.com/invite/jVQGdvHDrf"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
Discord
|
||||
</a>{" "}
|
||||
or create a{" "}
|
||||
<a
|
||||
href="https://github.com/LukeGus/Termix/issues/new"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
GitHub issue
|
||||
</a>{" "}
|
||||
for help.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(statusValue === "RETRYING" ||
|
||||
statusValue === "WAITING") &&
|
||||
status?.retryCount &&
|
||||
status?.maxRetries && (
|
||||
<div className="mt-2 text-xs text-yellow-700 dark:text-yellow-300 bg-yellow-500/10 dark:bg-yellow-400/10 rounded px-3 py-2 border border-yellow-500/20 dark:border-yellow-400/20">
|
||||
<div className="font-medium mb-1">
|
||||
{statusValue === "WAITING"
|
||||
? t("tunnels.waitingForRetry")
|
||||
: t("tunnels.retryingConnection")}
|
||||
</div>
|
||||
<div>
|
||||
{t("tunnels.attempt", {
|
||||
current: status.retryCount,
|
||||
max: status.maxRetries,
|
||||
})}
|
||||
{status.nextRetryIn && (
|
||||
<span>
|
||||
{" "}
|
||||
•{" "}
|
||||
{t("tunnels.nextRetryIn", {
|
||||
seconds: status.nextRetryIn,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
<Network className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{t("tunnels.noTunnelConnections")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
77
src/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx
Normal file
77
src/ui/Desktop/Apps/Tunnel/TunnelViewer.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from "react";
|
||||
import { TunnelObject } from "./TunnelObject.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type {
|
||||
SSHHost,
|
||||
TunnelConnection,
|
||||
TunnelStatus,
|
||||
} from "../../../types/index.js";
|
||||
|
||||
interface SSHTunnelViewerProps {
|
||||
hosts: SSHHost[];
|
||||
tunnelStatuses: Record<string, TunnelStatus>;
|
||||
tunnelActions: Record<string, boolean>;
|
||||
onTunnelAction: (
|
||||
action: "connect" | "disconnect" | "cancel",
|
||||
host: SSHHost,
|
||||
tunnelIndex: number,
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
export function TunnelViewer({
|
||||
hosts = [],
|
||||
tunnelStatuses = {},
|
||||
tunnelActions = {},
|
||||
onTunnelAction,
|
||||
}: SSHTunnelViewerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const activeHost: SSHHost | undefined =
|
||||
Array.isArray(hosts) && hosts.length > 0 ? hosts[0] : undefined;
|
||||
|
||||
if (
|
||||
!activeHost ||
|
||||
!activeHost.tunnelConnections ||
|
||||
activeHost.tunnelConnections.length === 0
|
||||
) {
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center text-center p-3">
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t("tunnels.noSshTunnels")}
|
||||
</h3>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
{t("tunnels.createFirstTunnelMessage")}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col overflow-hidden p-3 min-h-0">
|
||||
<div className="w-full flex-shrink-0 mb-2">
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
{t("tunnels.title")}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto pr-1">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(320px,1fr))] gap-3 auto-rows-min content-start w-full">
|
||||
{activeHost.tunnelConnections.map((t, idx) => (
|
||||
<TunnelObject
|
||||
key={`tunnel-${activeHost.id}-${t.endpointHost}-${t.sourcePort}-${t.endpointPort}`}
|
||||
host={{
|
||||
...activeHost,
|
||||
tunnelConnections: [activeHost.tunnelConnections[idx]],
|
||||
}}
|
||||
tunnelStatuses={tunnelStatuses}
|
||||
tunnelActions={tunnelActions}
|
||||
onTunnelAction={(action, _host, _index) =>
|
||||
onTunnelAction(action, activeHost, idx)
|
||||
}
|
||||
compact
|
||||
bare
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user