* 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 from 3877e90:
  * 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 from 3877e90:
  * 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:
Karmaa
2025-09-12 14:42:00 -05:00
committed by GitHub
parent d85fb26a5d
commit 5cd9de9ac5
187 changed files with 42370 additions and 20790 deletions

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

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

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

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

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