From bccfd596b8d7f95b5fdf47938dfebd7304696843 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Thu, 6 Nov 2025 11:21:21 +0800 Subject: [PATCH 01/42] fix: Resolve database encryption atomicity issues and enhance debugging (#430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Resolve database encryption atomicity issues and enhance debugging This commit addresses critical data corruption issues caused by non-atomic file writes during database encryption, and adds comprehensive diagnostic logging to help debug encryption-related failures. **Problem:** Users reported "Unsupported state or unable to authenticate data" errors when starting the application after system crashes or Docker container restarts. The root cause was non-atomic writes of encrypted database files: 1. Encrypted data file written (step 1) 2. Metadata file written (step 2) → If process crashes between steps 1 and 2, files become inconsistent → New IV/tag in data file, old IV/tag in metadata → GCM authentication fails on next startup → User data permanently inaccessible **Solution - Atomic Writes:** 1. Write-to-temp + atomic-rename pattern: - Write to temporary files (*.tmp-timestamp-pid) - Perform atomic rename operations - Clean up temp files on failure 2. Data integrity validation: - Add dataSize field to metadata - Verify file size before decryption - Early detection of corrupted writes 3. Enhanced error diagnostics: - Key fingerprints (SHA256 prefix) for verification - File modification timestamps - Detailed GCM auth failure messages - Automatic diagnostic info generation **Changes:** database-file-encryption.ts: - Implement atomic write pattern in encryptDatabaseFromBuffer - Implement atomic write pattern in encryptDatabaseFile - Add dataSize field to EncryptedFileMetadata interface - Validate file size before decryption in decryptDatabaseToBuffer - Enhanced error messages for GCM auth failures - Add getDiagnosticInfo() function for comprehensive debugging - Add debug logging for all encryption/decryption operations system-crypto.ts: - Add detailed logging for DATABASE_KEY initialization - Log key source (env var vs .env file) - Add key fingerprints to all log messages - Better error messages when key loading fails db/index.ts: - Automatically generate diagnostic info on decryption failure - Log detailed debugging information to help users troubleshoot **Debugging Info Added:** - Key initialization: source, fingerprint, length, path - Encryption: original size, encrypted size, IV/tag prefixes, temp paths - Decryption: file timestamps, metadata content, key fingerprint matching - Auth failures: .env file status, key availability, file consistency - File diagnostics: existence, readability, size validation, mtime comparison **Backward Compatibility:** - dataSize field is optional (metadata.dataSize?: number) - Old encrypted files without dataSize continue to work - No migration required **Testing:** - Compiled successfully - No breaking changes to existing APIs - Graceful handling of legacy v1 encrypted files Fixes data loss issues reported by users experiencing container restarts and system crashes during database saves. * fix: Cleanup PR * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/backend/utils/database-file-encryption.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: LukeGus Co-authored-by: Luke Gustafson <88517757+LukeGus@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .github/workflows/electron.yml | 1 - src/backend/database/db/index.ts | 26 ++ src/backend/utils/database-file-encryption.ts | 295 +++++++++++++++++- src/backend/utils/system-crypto.ts | 34 +- src/ui/desktop/authentication/Auth.tsx | 46 ++- 5 files changed, 366 insertions(+), 36 deletions(-) diff --git a/.github/workflows/electron.yml b/.github/workflows/electron.yml index 62bf5769..bd1a5b4c 100644 --- a/.github/workflows/electron.yml +++ b/.github/workflows/electron.yml @@ -746,7 +746,6 @@ jobs: mkdir -p homebrew-submission/Casks/t cp homebrew/termix.rb homebrew-submission/Casks/t/termix.rb - cp homebrew/README.md homebrew-submission/ sed -i '' "s/VERSION_PLACEHOLDER/$VERSION/g" homebrew-submission/Casks/t/termix.rb sed -i '' "s/CHECKSUM_PLACEHOLDER/$CHECKSUM/g" homebrew-submission/Casks/t/termix.rb diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 7b3b6138..8310ed6c 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -95,6 +95,32 @@ async function initializeDatabaseAsync(): Promise { databaseKeyLength: process.env.DATABASE_KEY?.length || 0, }); + try { + databaseLogger.info( + "Generating diagnostic information for database encryption failure", + { + operation: "db_encryption_diagnostic", + }, + ); + const diagnosticInfo = + DatabaseFileEncryption.getDiagnosticInfo(encryptedDbPath); + databaseLogger.error( + "Database encryption diagnostic completed - check logs above for details", + null, + { + operation: "db_encryption_diagnostic_completed", + filesConsistent: diagnosticInfo.validation.filesConsistent, + sizeMismatch: diagnosticInfo.validation.sizeMismatch, + }, + ); + } catch (diagError) { + databaseLogger.warn("Failed to generate diagnostic information", { + operation: "db_diagnostic_failed", + error: + diagError instanceof Error ? diagError.message : "Unknown error", + }); + } + throw new Error( `Database decryption failed: ${error instanceof Error ? error.message : "Unknown error"}. This prevents data loss.`, ); diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index 002464b0..7bbb406b 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -12,6 +12,7 @@ interface EncryptedFileMetadata { algorithm: string; keySource?: string; salt?: string; + dataSize?: number; } class DatabaseFileEncryption { @@ -25,6 +26,10 @@ class DatabaseFileEncryption { buffer: Buffer, targetPath: string, ): Promise { + const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`; + const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; + const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; + try { const key = await this.systemCrypto.getDatabaseKey(); @@ -38,6 +43,12 @@ class DatabaseFileEncryption { const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); const tag = cipher.getAuthTag(); + const keyFingerprint = crypto + .createHash("sha256") + .update(key) + .digest("hex") + .substring(0, 16); + const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), @@ -45,14 +56,35 @@ class DatabaseFileEncryption { fingerprint: "termix-v2-systemcrypto", algorithm: this.ALGORITHM, keySource: "SystemCrypto", + dataSize: encrypted.length, }; - const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; - fs.writeFileSync(targetPath, encrypted); - fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + fs.writeFileSync(tmpPath, encrypted); + fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); + + fs.renameSync(tmpPath, targetPath); + fs.renameSync(tmpMetadataPath, metadataPath); return targetPath; } catch (error) { + try { + if (fs.existsSync(tmpPath)) { + fs.unlinkSync(tmpPath); + } + if (fs.existsSync(tmpMetadataPath)) { + fs.unlinkSync(tmpMetadataPath); + } + } catch (cleanupError) { + databaseLogger.warn("Failed to cleanup temporary files", { + operation: "temp_file_cleanup_failed", + tmpPath, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }); + } + databaseLogger.error("Failed to encrypt database buffer", error, { operation: "database_buffer_encryption_failed", targetPath, @@ -74,6 +106,8 @@ class DatabaseFileEncryption { const encryptedPath = targetPath || `${sourcePath}${this.ENCRYPTED_FILE_SUFFIX}`; const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + const tmpPath = `${encryptedPath}.tmp-${Date.now()}-${process.pid}`; + const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; try { const sourceData = fs.readFileSync(sourcePath); @@ -93,6 +127,12 @@ class DatabaseFileEncryption { ]); const tag = cipher.getAuthTag(); + const keyFingerprint = crypto + .createHash("sha256") + .update(key) + .digest("hex") + .substring(0, 16); + const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), @@ -100,10 +140,14 @@ class DatabaseFileEncryption { fingerprint: "termix-v2-systemcrypto", algorithm: this.ALGORITHM, keySource: "SystemCrypto", + dataSize: encrypted.length, }; - fs.writeFileSync(encryptedPath, encrypted); - fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)); + fs.writeFileSync(tmpPath, encrypted); + fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); + + fs.renameSync(tmpPath, encryptedPath); + fs.renameSync(tmpMetadataPath, metadataPath); databaseLogger.info("Database file encrypted successfully", { operation: "database_file_encryption", @@ -111,11 +155,30 @@ class DatabaseFileEncryption { encryptedPath, fileSize: sourceData.length, encryptedSize: encrypted.length, + keyFingerprint, fingerprintPrefix: metadata.fingerprint, }); return encryptedPath; } catch (error) { + try { + if (fs.existsSync(tmpPath)) { + fs.unlinkSync(tmpPath); + } + if (fs.existsSync(tmpMetadataPath)) { + fs.unlinkSync(tmpMetadataPath); + } + } catch (cleanupError) { + databaseLogger.warn("Failed to cleanup temporary files", { + operation: "temp_file_cleanup_failed", + tmpPath, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }); + } + databaseLogger.error("Failed to encrypt database file", error, { operation: "database_file_encryption_failed", sourcePath, @@ -140,11 +203,34 @@ class DatabaseFileEncryption { } try { + const dataFileStats = fs.statSync(encryptedPath); + const metaFileStats = fs.statSync(metadataPath); + const metadataContent = fs.readFileSync(metadataPath, "utf8"); const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); const encryptedData = fs.readFileSync(encryptedPath); + if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + databaseLogger.error( + "Encrypted file size mismatch - possible corrupted write or mismatched metadata", + null, + { + operation: "database_file_size_mismatch", + encryptedPath, + actualSize: encryptedData.length, + expectedSize: metadata.dataSize, + difference: encryptedData.length - metadata.dataSize, + dataFileMtime: dataFileStats.mtime.toISOString(), + metaFileMtime: metaFileStats.mtime.toISOString(), + }, + ); + throw new Error( + `Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` + + `This indicates corrupted files or interrupted write operation.`, + ); + } + let key: Buffer; if (metadata.version === "v2") { key = await this.systemCrypto.getDatabaseKey(); @@ -167,6 +253,12 @@ class DatabaseFileEncryption { throw new Error(`Unsupported encryption version: ${metadata.version}`); } + const keyFingerprint = crypto + .createHash("sha256") + .update(key) + .digest("hex") + .substring(0, 16); + const decipher = crypto.createDecipheriv( metadata.algorithm, key, @@ -181,13 +273,64 @@ class DatabaseFileEncryption { return decryptedBuffer; } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Unknown error"; + const isAuthError = + errorMessage.includes("Unsupported state") || + errorMessage.includes("authenticate data") || + errorMessage.includes("auth"); + + if (isAuthError) { + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + + let envFileExists = false; + let envFileReadable = false; + try { + envFileExists = fs.existsSync(envPath); + if (envFileExists) { + fs.accessSync(envPath, fs.constants.R_OK); + envFileReadable = true; + } + } catch {} + + databaseLogger.error( + "Database decryption authentication failed - possible causes: wrong DATABASE_KEY, corrupted files, or interrupted write", + error, + { + operation: "database_buffer_decryption_auth_failed", + encryptedPath, + metadataPath, + dataDir, + envPath, + envFileExists, + envFileReadable, + hasEnvKey: !!process.env.DATABASE_KEY, + envKeyLength: process.env.DATABASE_KEY?.length || 0, + suggestion: + "Check if DATABASE_KEY in .env matches the key used for encryption", + }, + ); + throw new Error( + `Database decryption authentication failed. This usually means:\n` + + `1. DATABASE_KEY has changed or is missing from ${dataDir}/.env\n` + + `2. Encrypted file was corrupted during write (system crash/restart)\n` + + `3. Metadata file does not match encrypted data\n` + + `\nDebug info:\n` + + `- DATA_DIR: ${dataDir}\n` + + `- .env file exists: ${envFileExists}\n` + + `- .env file readable: ${envFileReadable}\n` + + `- DATABASE_KEY in environment: ${!!process.env.DATABASE_KEY}\n` + + `Original error: ${errorMessage}`, + ); + } + databaseLogger.error("Failed to decrypt database to buffer", error, { operation: "database_buffer_decryption_failed", encryptedPath, + errorMessage, }); - throw new Error( - `Database buffer decryption failed: ${error instanceof Error ? error.message : "Unknown error"}`, - ); + throw new Error(`Database buffer decryption failed: ${errorMessage}`); } } @@ -215,6 +358,23 @@ class DatabaseFileEncryption { const encryptedData = fs.readFileSync(encryptedPath); + if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + databaseLogger.error( + "Encrypted file size mismatch - possible corrupted write or mismatched metadata", + null, + { + operation: "database_file_size_mismatch", + encryptedPath, + actualSize: encryptedData.length, + expectedSize: metadata.dataSize, + }, + ); + throw new Error( + `Encrypted file size mismatch: expected ${metadata.dataSize} bytes but got ${encryptedData.length} bytes. ` + + `This indicates corrupted files or interrupted write operation.`, + ); + } + let key: Buffer; if (metadata.version === "v2") { key = await this.systemCrypto.getDatabaseKey(); @@ -322,6 +482,125 @@ class DatabaseFileEncryption { } } + static getDiagnosticInfo(encryptedPath: string): { + dataFile: { + exists: boolean; + size?: number; + mtime?: string; + readable?: boolean; + }; + metadataFile: { + exists: boolean; + size?: number; + mtime?: string; + readable?: boolean; + content?: EncryptedFileMetadata; + }; + environment: { + dataDir: string; + envPath: string; + envFileExists: boolean; + envFileReadable: boolean; + hasEnvKey: boolean; + envKeyLength: number; + }; + validation: { + filesConsistent: boolean; + sizeMismatch?: boolean; + expectedSize?: number; + actualSize?: number; + }; + } { + const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + + const result: ReturnType = { + dataFile: { exists: false }, + metadataFile: { exists: false }, + environment: { + dataDir, + envPath, + envFileExists: false, + envFileReadable: false, + hasEnvKey: !!process.env.DATABASE_KEY, + envKeyLength: process.env.DATABASE_KEY?.length || 0, + }, + validation: { + filesConsistent: false, + }, + }; + + try { + result.dataFile.exists = fs.existsSync(encryptedPath); + if (result.dataFile.exists) { + try { + fs.accessSync(encryptedPath, fs.constants.R_OK); + result.dataFile.readable = true; + const stats = fs.statSync(encryptedPath); + result.dataFile.size = stats.size; + result.dataFile.mtime = stats.mtime.toISOString(); + } catch { + result.dataFile.readable = false; + } + } + + result.metadataFile.exists = fs.existsSync(metadataPath); + if (result.metadataFile.exists) { + try { + fs.accessSync(metadataPath, fs.constants.R_OK); + result.metadataFile.readable = true; + const stats = fs.statSync(metadataPath); + result.metadataFile.size = stats.size; + result.metadataFile.mtime = stats.mtime.toISOString(); + + const content = fs.readFileSync(metadataPath, "utf8"); + result.metadataFile.content = JSON.parse(content); + } catch { + result.metadataFile.readable = false; + } + } + + result.environment.envFileExists = fs.existsSync(envPath); + if (result.environment.envFileExists) { + try { + fs.accessSync(envPath, fs.constants.R_OK); + result.environment.envFileReadable = true; + } catch {} + } + + if ( + result.dataFile.exists && + result.metadataFile.exists && + result.metadataFile.content + ) { + result.validation.filesConsistent = true; + + if (result.metadataFile.content.dataSize !== undefined) { + result.validation.expectedSize = result.metadataFile.content.dataSize; + result.validation.actualSize = result.dataFile.size; + result.validation.sizeMismatch = + result.metadataFile.content.dataSize !== result.dataFile.size; + if (result.validation.sizeMismatch) { + result.validation.filesConsistent = false; + } + } + } + } catch (error) { + databaseLogger.error("Failed to generate diagnostic info", error, { + operation: "diagnostic_info_failed", + encryptedPath, + }); + } + + databaseLogger.info("Database encryption diagnostic info", { + operation: "diagnostic_info_generated", + ...result, + }); + + return result; + } + static async createEncryptedBackup( databasePath: string, backupDir: string, diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index bd6aa727..156d0b33 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -51,17 +51,8 @@ class SystemCrypto { }, ); } - } catch (fileError) { - databaseLogger.warn("Failed to read .env file for JWT secret", { - operation: "jwt_init_file_read_failed", - error: - fileError instanceof Error ? fileError.message : "Unknown error", - }); - } + } catch (fileError) {} - databaseLogger.warn("Generating new JWT secret", { - operation: "jwt_generating_new_secret", - }); await this.generateAndGuideUser(); } catch (error) { databaseLogger.error("Failed to initialize JWT secret", error, { @@ -80,29 +71,44 @@ class SystemCrypto { async initializeDatabaseKey(): Promise { try { + const dataDir = process.env.DATA_DIR || "./db/data"; + const envPath = path.join(dataDir, ".env"); + const envKey = process.env.DATABASE_KEY; if (envKey && envKey.length >= 64) { this.databaseKey = Buffer.from(envKey, "hex"); + const keyFingerprint = crypto + .createHash("sha256") + .update(this.databaseKey) + .digest("hex") + .substring(0, 16); + return; } - const dataDir = process.env.DATA_DIR || "./db/data"; - const envPath = path.join(dataDir, ".env"); - try { const envContent = await fs.readFile(envPath, "utf8"); const dbKeyMatch = envContent.match(/^DATABASE_KEY=(.+)$/m); if (dbKeyMatch && dbKeyMatch[1] && dbKeyMatch[1].length >= 64) { this.databaseKey = Buffer.from(dbKeyMatch[1], "hex"); process.env.DATABASE_KEY = dbKeyMatch[1]; + + const keyFingerprint = crypto + .createHash("sha256") + .update(this.databaseKey) + .digest("hex") + .substring(0, 16); + return; + } else { } - } catch {} + } catch (fileError) {} await this.generateAndGuideDatabaseKey(); } catch (error) { databaseLogger.error("Failed to initialize database key", error, { operation: "db_key_init_failed", + dataDir: process.env.DATA_DIR || "./db/data", }); throw new Error("Database key initialization failed"); } diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index fb894adc..997d712b 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -24,11 +24,20 @@ import { verifyTOTPLogin, getServerConfig, isElectron, - logoutUser, } from "../../main-axios.ts"; import { ElectronServerConfig as ServerConfigComponent } from "@/ui/desktop/authentication/ElectronServerConfig.tsx"; import { ElectronLoginForm } from "@/ui/desktop/authentication/ElectronLoginForm.tsx"; +function getCookie(name: string): string | undefined { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop()?.split(";").shift(); +} + +interface ExtendedWindow extends Window { + IS_ELECTRON_WEBVIEW?: boolean; +} + interface AuthProps extends React.ComponentProps<"div"> { setLoggedIn: (loggedIn: boolean) => void; setIsAdmin: (isAdmin: boolean) => void; @@ -37,7 +46,6 @@ interface AuthProps extends React.ComponentProps<"div"> { loggedIn: boolean; authLoading: boolean; setDbError: (error: string | null) => void; - dbError?: string | null; onAuthSuccess: (authData: { isAdmin: boolean; username: string | null; @@ -54,21 +62,20 @@ export function Auth({ loggedIn, authLoading, setDbError, - dbError, onAuthSuccess, ...props }: AuthProps) { const { t } = useTranslation(); const isInElectronWebView = () => { - if ((window as any).IS_ELECTRON_WEBVIEW) { + if ((window as ExtendedWindow).IS_ELECTRON_WEBVIEW) { return true; } try { if (window.self !== window.top) { return true; } - } catch (e) { + } catch (_e) { return false; } return false; @@ -126,7 +133,7 @@ export function Auth({ userId: meRes.userId || null, }); toast.success(t("messages.loginSuccess")); - } catch (err) { + } catch (_err) { toast.error(t("errors.failedUserInfo")); } }, [ @@ -206,7 +213,7 @@ export function Auth({ .finally(() => { setDbHealthChecking(false); }); - }, [setDbError, firstUserToastShown, showServerConfig]); + }, [setDbError, firstUserToastShown, showServerConfig, t]); useEffect(() => { if (!registrationAllowed && !internalLoggedIn) { @@ -282,9 +289,9 @@ export function Auth({ ); setWebviewAuthSuccess(true); setTimeout(() => window.location.reload(), 100); - setLoading(false); - return; - } catch (e) {} + } catch (e) { + console.error("Error posting auth success message:", e); + } } const [meRes] = await Promise.all([getUserInfo()]); @@ -461,7 +468,9 @@ export function Auth({ setTimeout(() => window.location.reload(), 100); setTotpLoading(false); return; - } catch (e) {} + } catch (e) { + console.error("Error posting auth success message:", e); + } } setInternalLoggedIn(true); @@ -569,7 +578,9 @@ export function Auth({ setTimeout(() => window.location.reload(), 100); setOidcLoading(false); return; - } catch (e) {} + } catch (e) { + console.error("Error posting auth success message:", e); + } } } @@ -607,7 +618,16 @@ export function Auth({ setOidcLoading(false); }); } - }, []); + }, [ + onAuthSuccess, + setDbError, + setIsAdmin, + setLoggedIn, + setUserId, + setUsername, + t, + isInElectronWebView, + ]); const Spinner = ( Date: Wed, 5 Nov 2025 21:32:07 -0600 Subject: [PATCH 02/42] fix: Merge metadata and DB into 1 file --- src/backend/utils/database-file-encryption.ts | 150 +++++++++++++----- 1 file changed, 106 insertions(+), 44 deletions(-) diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index 7bbb406b..b4afbe7a 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -27,14 +27,11 @@ class DatabaseFileEncryption { targetPath: string, ): Promise { const tmpPath = `${targetPath}.tmp-${Date.now()}-${process.pid}`; - const tmpMetadataPath = `${tmpPath}${this.METADATA_FILE_SUFFIX}`; const metadataPath = `${targetPath}${this.METADATA_FILE_SUFFIX}`; try { const key = await this.systemCrypto.getDatabaseKey(); - const iv = crypto.randomBytes(16); - const cipher = crypto.createCipheriv( this.ALGORITHM, key, @@ -43,12 +40,6 @@ class DatabaseFileEncryption { const encrypted = Buffer.concat([cipher.update(buffer), cipher.final()]); const tag = cipher.getAuthTag(); - const keyFingerprint = crypto - .createHash("sha256") - .update(key) - .digest("hex") - .substring(0, 16); - const metadata: EncryptedFileMetadata = { iv: iv.toString("hex"), tag: tag.toString("hex"), @@ -59,11 +50,34 @@ class DatabaseFileEncryption { dataSize: encrypted.length, }; - fs.writeFileSync(tmpPath, encrypted); - fs.writeFileSync(tmpMetadataPath, JSON.stringify(metadata, null, 2)); + const metadataJson = JSON.stringify(metadata, null, 2); + const metadataBuffer = Buffer.from(metadataJson, "utf8"); + const metadataLengthBuffer = Buffer.alloc(4); + metadataLengthBuffer.writeUInt32BE(metadataBuffer.length, 0); + const finalBuffer = Buffer.concat([ + metadataLengthBuffer, + metadataBuffer, + encrypted, + ]); + + fs.writeFileSync(tmpPath, finalBuffer); fs.renameSync(tmpPath, targetPath); - fs.renameSync(tmpMetadataPath, metadataPath); + + try { + if (fs.existsSync(metadataPath)) { + fs.unlinkSync(metadataPath); + } + } catch (cleanupError) { + databaseLogger.warn("Failed to cleanup old metadata file", { + operation: "old_meta_cleanup_failed", + path: metadataPath, + error: + cleanupError instanceof Error + ? cleanupError.message + : "Unknown error", + }); + } return targetPath; } catch (error) { @@ -71,9 +85,6 @@ class DatabaseFileEncryption { if (fs.existsSync(tmpPath)) { fs.unlinkSync(tmpPath); } - if (fs.existsSync(tmpMetadataPath)) { - fs.unlinkSync(tmpMetadataPath); - } } catch (cleanupError) { databaseLogger.warn("Failed to cleanup temporary files", { operation: "temp_file_cleanup_failed", @@ -197,21 +208,54 @@ class DatabaseFileEncryption { ); } - const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; - if (!fs.existsSync(metadataPath)) { - throw new Error(`Metadata file does not exist: ${metadataPath}`); + let metadata: EncryptedFileMetadata; + let encryptedData: Buffer; + + const fileBuffer = fs.readFileSync(encryptedPath); + + try { + const metadataLength = fileBuffer.readUInt32BE(0); + const metadataEnd = 4 + metadataLength; + + if ( + metadataLength <= 0 || + metadataEnd > fileBuffer.length || + metadataEnd <= 4 + ) { + throw new Error("Invalid metadata length in single-file format"); + } + + const metadataJson = fileBuffer.slice(4, metadataEnd).toString("utf8"); + metadata = JSON.parse(metadataJson); + encryptedData = fileBuffer.slice(metadataEnd); + + if (!metadata.iv || !metadata.tag || !metadata.version) { + throw new Error("Invalid metadata structure in single-file format"); + } + } catch (singleFileError) { + const metadataPath = `${encryptedPath}${this.METADATA_FILE_SUFFIX}`; + if (!fs.existsSync(metadataPath)) { + throw new Error( + `Could not read database: Not a valid single-file format and metadata file is missing: ${metadataPath}. Error: ${singleFileError.message}`, + ); + } + + try { + const metadataContent = fs.readFileSync(metadataPath, "utf8"); + metadata = JSON.parse(metadataContent); + encryptedData = fileBuffer; + } catch (twoFileError) { + throw new Error( + `Failed to read database using both single-file and two-file formats. Error: ${twoFileError.message}`, + ); + } } try { - const dataFileStats = fs.statSync(encryptedPath); - const metaFileStats = fs.statSync(metadataPath); - - const metadataContent = fs.readFileSync(metadataPath, "utf8"); - const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); - - const encryptedData = fs.readFileSync(encryptedPath); - - if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + if ( + metadata.dataSize !== undefined && + encryptedData.length !== metadata.dataSize + ) { databaseLogger.error( "Encrypted file size mismatch - possible corrupted write or mismatched metadata", null, @@ -220,9 +264,6 @@ class DatabaseFileEncryption { encryptedPath, actualSize: encryptedData.length, expectedSize: metadata.dataSize, - difference: encryptedData.length - metadata.dataSize, - dataFileMtime: dataFileStats.mtime.toISOString(), - metaFileMtime: metaFileStats.mtime.toISOString(), }, ); throw new Error( @@ -253,12 +294,6 @@ class DatabaseFileEncryption { throw new Error(`Unsupported encryption version: ${metadata.version}`); } - const keyFingerprint = crypto - .createHash("sha256") - .update(key) - .digest("hex") - .substring(0, 16); - const decipher = crypto.createDecipheriv( metadata.algorithm, key, @@ -300,7 +335,6 @@ class DatabaseFileEncryption { { operation: "database_buffer_decryption_auth_failed", encryptedPath, - metadataPath, dataDir, envPath, envFileExists, @@ -358,7 +392,10 @@ class DatabaseFileEncryption { const encryptedData = fs.readFileSync(encryptedPath); - if (metadata.dataSize !== undefined && encryptedData.length !== metadata.dataSize) { + if ( + metadata.dataSize !== undefined && + encryptedData.length !== metadata.dataSize + ) { databaseLogger.error( "Encrypted file size mismatch - possible corrupted write or mismatched metadata", null, @@ -434,18 +471,43 @@ class DatabaseFileEncryption { } static isEncryptedDatabaseFile(filePath: string): boolean { - const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`; - - if (!fs.existsSync(filePath) || !fs.existsSync(metadataPath)) { + if (!fs.existsSync(filePath)) { return false; } + const metadataPath = `${filePath}${this.METADATA_FILE_SUFFIX}`; + if (fs.existsSync(metadataPath)) { + try { + const metadataContent = fs.readFileSync(metadataPath, "utf8"); + const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); + return ( + metadata.version === this.VERSION && + metadata.algorithm === this.ALGORITHM + ); + } catch { + return false; + } + } + try { - const metadataContent = fs.readFileSync(metadataPath, "utf8"); - const metadata: EncryptedFileMetadata = JSON.parse(metadataContent); + const fileBuffer = fs.readFileSync(filePath); + if (fileBuffer.length < 4) return false; + + const metadataLength = fileBuffer.readUInt32BE(0); + const metadataEnd = 4 + metadataLength; + + if (metadataLength <= 0 || metadataEnd > fileBuffer.length) { + return false; + } + + const metadataJson = fileBuffer.slice(4, metadataEnd).toString("utf8"); + const metadata: EncryptedFileMetadata = JSON.parse(metadataJson); + return ( metadata.version === this.VERSION && - metadata.algorithm === this.ALGORITHM + metadata.algorithm === this.ALGORITHM && + !!metadata.iv && + !!metadata.tag ); } catch { return false; -- 2.49.1 From 7d69a4b5a5ea86685de8b67bbe92f4b603ff6a74 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Thu, 6 Nov 2025 00:20:38 -0600 Subject: [PATCH 03/42] fix: Add initial command palette --- package-lock.json | 17 ++ package.json | 1 + src/components/ui/command.tsx | 184 ++++++++++++++++++ src/components/ui/dialog.tsx | 141 ++++++++++++++ src/ui/desktop/DesktopApp.tsx | 30 ++- .../apps/command-palette/CommandPalette.tsx | 88 +++++++++ 6 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/command.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/ui/desktop/apps/command-palette/CommandPalette.tsx diff --git a/package-lock.json b/package-lock.json index 011b4f07..61e5b7c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "chalk": "^4.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.0", @@ -6936,6 +6937,22 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/codem-isoboxer": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/codem-isoboxer/-/codem-isoboxer-0.3.10.tgz", diff --git a/package.json b/package.json index bd71b467..81d6971f 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "chalk": "^4.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.2.0", diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx new file mode 100644 index 00000000..ee7450af --- /dev/null +++ b/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client"; + +import * as React from "react"; +import { Command as CommandPrimitive } from "cmdk"; +import { SearchIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string; + description?: string; + className?: string; + showCloseButton?: boolean; +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ); +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ); +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +}; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 00000000..9ff99906 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,141 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Dialog({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ); +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +}; diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 1bedd2c1..39aa9540 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { LeftSidebar } from "@/ui/desktop/navigation/LeftSidebar.tsx"; import { Dashboard } from "@/ui/desktop/apps/dashboard/Dashboard.tsx"; import { AppView } from "@/ui/desktop/navigation/AppView.tsx"; @@ -11,6 +11,7 @@ import { TopNavbar } from "@/ui/desktop/navigation/TopNavbar.tsx"; import { AdminSettings } from "@/ui/desktop/admin/AdminSettings.tsx"; import { UserProfile } from "@/ui/desktop/user/UserProfile.tsx"; import { Toaster } from "@/components/ui/sonner.tsx"; +import { CommandPalette } from "@/ui/desktop/apps/command-palette/CommandPalette.tsx"; import { getUserInfo } from "@/ui/main-axios.ts"; function AppContent() { @@ -23,6 +24,29 @@ function AppContent() { return saved !== null ? JSON.parse(saved) : true; }); const { currentTab, tabs } = useTabs(); + const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); + + const lastShiftPressTime = useRef(0); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.code === "ShiftLeft") { + const now = Date.now(); + if (now - lastShiftPressTime.current < 300) { + setIsCommandPaletteOpen((isOpen) => !isOpen); + } + lastShiftPressTime.current = now; + } + if (event.key === "Escape") { + setIsCommandPaletteOpen(false); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, []); useEffect(() => { const checkAuth = () => { @@ -93,6 +117,10 @@ function AppContent() { return (
+ {!isAuthenticated && !authLoading && (
void; +}) { + const inputRef = useRef(null); + + useEffect(() => { + if (isOpen) { + inputRef.current?.focus(); + } + }, [isOpen]); + return ( +
setIsOpen(false)} + > + e.stopPropagation()} + > + + + + + + Calendar + + + + Search Emoji + + + + Calculator + + + + + + + Profile + ⌘P + + + + Billing + ⌘B + + + + Settings + ⌘S + + + + +
+ ); +} -- 2.49.1 From 4bd6e6c6faf3a8587b8ee2f8df23976c1ca61808 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 7 Nov 2025 05:50:37 +0100 Subject: [PATCH 04/42] Feature/german language support (#431) * Update translation.json Fixed some translation issues for German, made it more user friendly and common. * Update translation.json added updated block for serverStats * Update translation.json Added translations * Update translation.json Removed duplicate of "free":"Free" --- src/locales/de/translation.json | 45 ++++++++++++++++++++++++++------- src/locales/en/translation.json | 1 - 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index c407191b..ed288f3e 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -187,7 +187,7 @@ "commandsWillBeSent": "Befehle werden an {{count}} ausgewählte Terminals gesendet.", "settings": "Einstellungen", "enableRightClickCopyPaste": "Rechtsklick-Kopieren\/Einfügen aktivieren", - "shareIdeas": "Haben Sie Ideen, was als nächstes für SSH-Tools kommen sollte? Teilen Sie diese mit uns" + "shareIdeas": "Haben Sie Vorschläge welche weiteren SSH-Tools ergänzt werden sollen? Dann teilen Sie diese gerne mit uns auf" }, "homepage": { "loggedInTitle": "Eingeloggt!", @@ -355,7 +355,7 @@ "tabNavigation": "Registerkarte Navigation" }, "admin": { - "title": "Administratoreinstellungen", + "title": "Admin-Einstellungen", "oidc": "OIDC", "users": "Benutzer", "userManagement": "Benutzerverwaltung", @@ -390,11 +390,11 @@ "actions": "Aktionen", "external": "Extern", "local": "Lokal", - "adminManagement": "Verwaltung von Administratoren", + "adminManagement": "Admin Verwaltung", "makeUserAdmin": "Benutzer zum Administrator machen", "adding": "Hinzufügen...", "currentAdmins": "Aktuelle Administratoren", - "adminBadge": "Administrator", + "adminBadge": "Admin", "removeAdminButton": "Administrator entfernen", "general": "Allgemein", "userRegistration": "Benutzerregistrierung", @@ -416,6 +416,13 @@ "userDeletedSuccessfully": "Benutzer {{username}} wurde erfolgreich gelöscht", "failedToDeleteUser": "Benutzer konnte nicht gelöscht werden", "overrideUserInfoUrl": "URL für Benutzerinformationen überschreiben (nicht erforderlich)", + "failedToFetchSessions": "Fehler beim Abrufen der Sitzungen", + "sessionRevokedSuccessfully": "Sitzung erfolgreich widerrufen", + "failedToRevokeSession": "Sitzung konnte nicht widerrufen werden", + "confirmRevokeSession": "Möchten Sie diese Sitzung wirklich beenden?", + "confirmRevokeAllSessions": "Möchten Sie wirklich alle Sitzungen dieses Benutzers beenden?", + "failedToRevokeSessions": "Sitzungen konnten nicht widerrufen werden", + "sessionsRevokedSuccessfully": "Sitzungen erfolgreich beendet", "databaseSecurity": "Datenbanksicherheit", "encryptionStatus": "Verschlüsselungsstatus", "encryptionEnabled": "Verschlüsselung aktiviert", @@ -620,7 +627,7 @@ "autoStartContainer": "Automatischer Start beim Container-Start", "autoStartDesc": "Diesen Tunnel beim Start des Containers automatisch starten", "addConnection": "Tunnelverbindung hinzufügen", - "sshpassRequired": "Sshpass erforderlich für die Passwort-Authentifizierung", + "sshpassRequired": "sshpass erforderlich für die Passwort-Authentifizierung", "sshpassRequiredDesc": "Für die Passwortauthentifizierung in Tunneln muss sshpass auf dem System installiert sein.", "otherInstallMethods": "Andere Installationsmethoden:", "debianUbuntuEquivalent": "(Debian\/Ubuntu) oder das entsprechende Pendant für Ihr Betriebssystem.", @@ -1072,7 +1079,7 @@ "used": "Gebraucht", "percentage": "Prozentsatz", "refreshStatusAndMetrics": "Aktualisierungsstatus und Metriken", - "refreshStatus": "Aktualisierungsstatus", + "refreshStatus": "Aktualisieren", "fileManagerAlreadyOpen": "Der Dateimanager ist für diesen Host bereits geöffnet", "openFileManager": "Dateimanager öffnen", "cpuCores_one": "{{count}} CPU", @@ -1081,9 +1088,10 @@ "loadAverageNA": "Durchschnitt: N\/A", "cpuUsage": "CPU-Auslastung", "memoryUsage": "Speicherauslastung", + "diskUsage": "Festplattennutzung", "rootStorageSpace": "Root-Speicherplatz", "of": "von", - "feedbackMessage": "Haben Sie Ideen für die nächsten Schritte im Bereich der Serververwaltung? Teilen Sie diese mit uns", + "feedbackMessage": "Haben Sie Ideen, wie es bei der Serververwaltung weitergehen könnte? Dann teilen Sie diese gerne mit uns auf", "failedToFetchHostConfig": "Abrufen der Hostkonfiguration fehlgeschlagen", "failedToFetchStatus": "Abrufen des Serverstatus fehlgeschlagen", "failedToFetchMetrics": "Abrufen der Servermetriken fehlgeschlagen", @@ -1092,7 +1100,26 @@ "refreshing": "Aktualisieren...", "serverOffline": "Server offline", "cannotFetchMetrics": "Metriken können nicht vom Offline-Server abgerufen werden", - "load": "Laden" + "load": "Last" + "available": "Verfügbar", + "editLayout": "Layout anpassen", + "cancelEdit": "Abbrechen", + "addWidget": "Widget hinzufügen", + "saveLayout": "Layout speichern", + "unsavedChanges": "Ungespeicherte Änderungen", + "layoutSaved": "Layout erfolgreich gespeichert", + "failedToSaveLayout": "Speichern des Layout fehlgeschlagen", + "systemInfo": "System Information", + "hostname": "Hostname", + "operatingSystem": "Betriebssystem", + "kernel": "Kernel", + "totalUptime": "Gesamte Betriebszeit", + "seconds": "Sekunden", + "networkInterfaces": "Netzwerkschnittstellen", + "noInterfacesFound": "Keine Netzwerkschnittstellen gefunden", + "totalProcesses": "Gesamtprozesse", + "running": "läuft", + "noProcessesFound": "Keine Prozesse gefunden" }, "auth": { "loginTitle": "Melden Sie sich bei Termix an", @@ -1372,7 +1399,7 @@ "disconnected": "Getrennt", "maxRetriesExhausted": "Maximale Wiederholungsversuche ausgeschöpft", "endpointHostNotFound": "Endpunkthost nicht gefunden", - "administrator": "Administrator", + "administrator": "Admin", "user": "Benutzer", "external": "Extern", "local": "Lokal", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3de6628c..8012ad05 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1210,7 +1210,6 @@ "totpRequired": "TOTP Authentication Required", "totpUnavailable": "Server Stats unavailable for TOTP-enabled servers", "load": "Load", - "free": "Free", "available": "Available", "editLayout": "Edit Layout", "cancelEdit": "Cancel", -- 2.49.1 From c69d31062ea42700f8655832f3ab1ce650788b78 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Fri, 7 Nov 2025 00:28:58 -0600 Subject: [PATCH 05/42] feat: Finalize command palette --- src/locales/de/translation.json | 2 +- src/ui/desktop/DesktopApp.tsx | 1 + .../apps/command-palette/CommandPalette.tsx | 361 ++++++++++++++++-- src/ui/desktop/apps/dashboard/Dashboard.tsx | 6 +- .../desktop/apps/host-manager/HostManager.tsx | 24 +- src/ui/desktop/apps/tools/ToolsMenu.tsx | 18 +- src/ui/desktop/navigation/TopNavbar.tsx | 3 + 7 files changed, 356 insertions(+), 59 deletions(-) diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index ed288f3e..37fff37f 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1100,7 +1100,7 @@ "refreshing": "Aktualisieren...", "serverOffline": "Server offline", "cannotFetchMetrics": "Metriken können nicht vom Offline-Server abgerufen werden", - "load": "Last" + "load": "Last", "available": "Verfügbar", "editLayout": "Layout anpassen", "cancelEdit": "Abbrechen", diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 39aa9540..b7bcb6de 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -185,6 +185,7 @@ function AppContent() { setIsCommandPaletteOpen(true)} /> )} diff --git a/src/ui/desktop/apps/command-palette/CommandPalette.tsx b/src/ui/desktop/apps/command-palette/CommandPalette.tsx index 0d647735..fd898db0 100644 --- a/src/ui/desktop/apps/command-palette/CommandPalette.tsx +++ b/src/ui/desktop/apps/command-palette/CommandPalette.tsx @@ -3,21 +3,59 @@ import { CommandInput, CommandItem, CommandList, - CommandShortcut, CommandGroup, CommandSeparator, } from "@/components/ui/command.tsx"; -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { - Calculator, - Calendar, - CreditCard, + Key, + Server, Settings, - Smile, User, + Github, + Terminal, + FolderOpen, + Pencil, + EllipsisVertical, } from "lucide-react"; -import { CommandEmpty } from "cmdk"; +import { BiMoney, BiSupport } from "react-icons/bi"; +import { BsDiscord } from "react-icons/bs"; +import { GrUpdate } from "react-icons/gr"; +import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; +import { getRecentActivity, getSSHHosts } from "@/ui/main-axios.ts"; +import type { RecentActivityItem } from "@/ui/main-axios.ts"; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button.tsx"; + +interface SSHHost { + id: number; + name: string; + ip: string; + port: number; + username: string; + folder: string; + tags: string[]; + pin: boolean; + authType: string; + password?: string; + key?: string; + keyPassword?: string; + keyType?: string; + enableTerminal: boolean; + enableTunnel: boolean; + enableFileManager: boolean; + defaultPath: string; + tunnelConnections: unknown[]; + createdAt: string; + updatedAt: string; +} + export function CommandPalette({ isOpen, setIsOpen, @@ -26,59 +64,316 @@ export function CommandPalette({ setIsOpen: (isOpen: boolean) => void; }) { const inputRef = useRef(null); + const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs(); + const [recentActivity, setRecentActivity] = useState( + [], + ); + const [hosts, setHosts] = useState([]); useEffect(() => { if (isOpen) { inputRef.current?.focus(); + getRecentActivity(50).then((activity) => { + setRecentActivity(activity.slice(0, 5)); + }); + getSSHHosts().then((allHosts) => { + setHosts(allHosts); + }); } }, [isOpen]); + + const handleAddHost = () => { + const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); + if (sshManagerTab) { + updateTab(sshManagerTab.id, { initialTab: "add_host" }); + setCurrentTab(sshManagerTab.id); + } else { + const id = addTab({ + type: "ssh_manager", + title: "Host Manager", + initialTab: "add_host", + }); + setCurrentTab(id); + } + setIsOpen(false); + }; + + const handleAddCredential = () => { + const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); + if (sshManagerTab) { + updateTab(sshManagerTab.id, { initialTab: "add_credential" }); + setCurrentTab(sshManagerTab.id); + } else { + const id = addTab({ + type: "ssh_manager", + title: "Host Manager", + initialTab: "add_credential", + }); + setCurrentTab(id); + } + setIsOpen(false); + }; + + const handleOpenAdminSettings = () => { + const adminTab = tabList.find((t) => t.type === "admin"); + if (adminTab) { + setCurrentTab(adminTab.id); + } else { + const id = addTab({ type: "admin", title: "Admin Settings" }); + setCurrentTab(id); + } + setIsOpen(false); + }; + + const handleOpenUserProfile = () => { + const userProfileTab = tabList.find((t) => t.type === "user_profile"); + if (userProfileTab) { + setCurrentTab(userProfileTab.id); + } else { + const id = addTab({ type: "user_profile", title: "User Profile" }); + setCurrentTab(id); + } + setIsOpen(false); + }; + + const handleOpenUpdateLog = () => { + window.open("https://github.com/Termix-SSH/Termix/releases", "_blank"); + setIsOpen(false); + }; + + const handleGitHub = () => { + window.open("https://github.com/Termix-SSH/Termix", "_blank"); + setIsOpen(false); + }; + + const handleSupport = () => { + window.open("https://github.com/Termix-SSH/Support/issues/new", "_blank"); + setIsOpen(false); + }; + + const handleDiscord = () => { + window.open("https://discord.com/invite/jVQGdvHDrf", "_blank"); + setIsOpen(false); + }; + + const handleDonate = () => { + window.open("https://github.com/sponsors/LukeGus", "_blank"); + setIsOpen(false); + }; + + const handleActivityClick = (item: RecentActivityItem) => { + getSSHHosts().then((hosts) => { + const host = hosts.find((h: { id: number }) => h.id === item.hostId); + if (!host) return; + + if (item.type === "terminal") { + addTab({ + type: "terminal", + title: item.hostName, + hostConfig: host, + }); + } else if (item.type === "file_manager") { + addTab({ + type: "file_manager", + title: item.hostName, + hostConfig: host, + }); + } + }); + setIsOpen(false); + }; + + const handleHostTerminalClick = (host: SSHHost) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + addTab({ type: "terminal", title, hostConfig: host }); + setIsOpen(false); + }; + + const handleHostFileManagerClick = (host: SSHHost) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + addTab({ type: "file_manager", title, hostConfig: host }); + setIsOpen(false); + }; + + const handleHostServerDetailsClick = (host: SSHHost) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + addTab({ type: "server", title, hostConfig: host }); + setIsOpen(false); + }; + + const handleHostEditClick = (host: SSHHost) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + addTab({ + type: "ssh_manager", + title: "Host Manager", + hostConfig: host, + initialTab: "add_host", + }); + setIsOpen(false); + }; + return (
setIsOpen(false)} > e.stopPropagation()} > - - - - - Calendar + + {recentActivity.length > 0 && ( + <> + + {recentActivity.map((item, index) => ( + handleActivityClick(item)} + > + {item.type === "terminal" ? : } + {item.hostName} + + ))} + + + + )} + + + + Add Host - - - Search Emoji + + + Add Credential - - - Calculator + + + Admin Settings + + + + User Profile + + + + Update Log - - - - Profile - ⌘P + + {hosts.map((host, index) => { + const title = host.name?.trim() + ? host.name + : `${host.username}@${host.ip}:${host.port}`; + return ( + { + if (host.enableTerminal) { + handleHostTerminalClick(host); + } + }} + className="flex items-center justify-between" + > +
+ + {title} +
+
e.stopPropagation()} + > + + + + + + { + e.stopPropagation(); + handleHostServerDetailsClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + Open Server Details + + { + e.stopPropagation(); + handleHostFileManagerClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + Open File Manager + + { + e.stopPropagation(); + handleHostEditClick(host); + }} + className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" + > + + Edit + + + +
+
+ ); + })} +
+ + + + + GitHub - - - Billing - ⌘B + + + Support - - - Settings - ⌘S + + + Discord + + + + Donate
diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index 39742b77..dd6d3826 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -85,7 +85,7 @@ export function Dashboard({ >([]); const [serverStatsLoading, setServerStatsLoading] = useState(true); - const { addTab, setCurrentTab, tabs: tabList } = useTabs(); + const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs(); let sidebarState: "expanded" | "collapsed" = "expanded"; try { @@ -264,6 +264,7 @@ export function Dashboard({ const handleAddHost = () => { const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); if (sshManagerTab) { + updateTab(sshManagerTab.id, { initialTab: "add_host" }); setCurrentTab(sshManagerTab.id); } else { const id = addTab({ @@ -278,6 +279,7 @@ export function Dashboard({ const handleAddCredential = () => { const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); if (sshManagerTab) { + updateTab(sshManagerTab.id, { initialTab: "add_credential" }); setCurrentTab(sshManagerTab.id); } else { const id = addTab({ @@ -671,7 +673,7 @@ export function Dashboard({ {server.name}

-
+
{t("dashboard.cpu")}:{" "} {server.cpu !== null diff --git a/src/ui/desktop/apps/host-manager/HostManager.tsx b/src/ui/desktop/apps/host-manager/HostManager.tsx index 04da3788..dff7b923 100644 --- a/src/ui/desktop/apps/host-manager/HostManager.tsx +++ b/src/ui/desktop/apps/host-manager/HostManager.tsx @@ -35,28 +35,10 @@ export function HostManager({ const lastProcessedHostIdRef = useRef(undefined); useEffect(() => { - if (ignoreNextHostConfigChangeRef.current) { - ignoreNextHostConfigChangeRef.current = false; - return; + if (initialTab) { + setActiveTab(initialTab); } - - if (hostConfig && initialTab === "add_host") { - const currentHostId = hostConfig.id; - - if (currentHostId !== lastProcessedHostIdRef.current) { - setEditingHost(hostConfig); - setActiveTab("add_host"); - lastProcessedHostIdRef.current = currentHostId; - } else if ( - activeTab === "host_viewer" || - activeTab === "credentials" || - activeTab === "add_credential" - ) { - setEditingHost(hostConfig); - setActiveTab("add_host"); - } - } - }, [hostConfig, initialTab]); + }, [initialTab]); const handleEditHost = (host: SSHHost) => { setEditingHost(host); diff --git a/src/ui/desktop/apps/tools/ToolsMenu.tsx b/src/ui/desktop/apps/tools/ToolsMenu.tsx index 5f6bf96e..f4028f1b 100644 --- a/src/ui/desktop/apps/tools/ToolsMenu.tsx +++ b/src/ui/desktop/apps/tools/ToolsMenu.tsx @@ -6,17 +6,19 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu.tsx"; import { Button } from "@/components/ui/button.tsx"; -import { Hammer, Wrench, FileText } from "lucide-react"; +import { Hammer, Wrench, FileText, Command } from "lucide-react"; import { useTranslation } from "react-i18next"; interface ToolsMenuProps { onOpenSshTools: () => void; onOpenSnippets: () => void; + onOpenCommandPalette: () => void; } export function ToolsMenu({ onOpenSshTools, onOpenSnippets, + onOpenCommandPalette, }: ToolsMenuProps): React.ReactElement { const { t } = useTranslation(); @@ -33,7 +35,7 @@ export function ToolsMenu({ {t("snippets.title")} + + +
+ Command Palette + + LShift LShift + +
+
); diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx index 7a7bb34d..2ad540f8 100644 --- a/src/ui/desktop/navigation/TopNavbar.tsx +++ b/src/ui/desktop/navigation/TopNavbar.tsx @@ -26,11 +26,13 @@ interface TabData { interface TopNavbarProps { isTopbarOpen: boolean; setIsTopbarOpen: (open: boolean) => void; + onOpenCommandPalette: () => void; } export function TopNavbar({ isTopbarOpen, setIsTopbarOpen, + onOpenCommandPalette, }: TopNavbarProps): React.ReactElement { const { state } = useSidebar(); const { @@ -476,6 +478,7 @@ export function TopNavbar({ setToolsSheetOpen(true)} onOpenSnippets={() => setSnippetsSidebarOpen(true)} + onOpenCommandPalette={onOpenCommandPalette} /> ); diff --git a/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx b/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx index 786afa12..4f500ce8 100644 --- a/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx +++ b/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx @@ -92,6 +92,8 @@ interface FileManagerGridProps { createIntent?: CreateIntent | null; onConfirmCreate?: (name: string) => void; onCancelCreate?: () => void; + onNewFile?: () => void; + onNewFolder?: () => void; } const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { @@ -192,6 +194,8 @@ export function FileManagerGrid({ createIntent, onConfirmCreate, onCancelCreate, + onNewFile, + onNewFolder, }: FileManagerGridProps) { const { t } = useTranslation(); const gridRef = useRef(null); @@ -772,6 +776,42 @@ export function FileManagerGrid({ onUndo(); } break; + case "d": + case "D": + if ( + (event.ctrlKey || event.metaKey) && + selectedFiles.length > 0 && + onDownload + ) { + event.preventDefault(); + onDownload(selectedFiles); + } + break; + case "n": + case "N": + if (event.ctrlKey || event.metaKey) { + event.preventDefault(); + if (event.shiftKey && onNewFolder) { + onNewFolder(); + } else if (!event.shiftKey && onNewFile) { + onNewFile(); + } + } + break; + case "u": + case "U": + if ((event.ctrlKey || event.metaKey) && onUpload) { + event.preventDefault(); + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files; + if (files) onUpload(files); + }; + input.click(); + } + break; case "Delete": if (selectedFiles.length > 0 && onDelete) { onDelete(selectedFiles); @@ -783,6 +823,12 @@ export function FileManagerGrid({ onStartEdit(selectedFiles[0]); } break; + case "Enter": + if (selectedFiles.length === 1) { + event.preventDefault(); + onFileOpen(selectedFiles[0]); + } + break; case "y": case "Y": if (event.ctrlKey || event.metaKey) { -- 2.49.1 From a2761c1ebf4a24412d17197b0e6d9de4d591929f Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 06:35:43 +0800 Subject: [PATCH 10/42] feat: Add i18n support for command palette - Add commandPalette translation section with 22 keys to all 4 languages - Update CommandPalette component to use i18n for all UI text - Translate search placeholder, group headings, menu items, and shortcut hints - Support multilingual command palette interface --- src/locales/de/translation.json | 23 ++++++++ src/locales/en/translation.json | 23 ++++++++ src/locales/ru/translation.json | 23 ++++++++ src/locales/zh/translation.json | 23 ++++++++ .../apps/command-palette/CommandPalette.tsx | 52 ++++++++++--------- 5 files changed, 119 insertions(+), 25 deletions(-) diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 2c9c4207..3d9e65d6 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1571,5 +1571,28 @@ "cpu": "CPU", "ram": "RAM", "notAvailable": "Nicht verfügbar" + }, + "commandPalette": { + "searchPlaceholder": "Nach Hosts oder Schnellaktionen suchen...", + "recentActivity": "Kürzliche Aktivität", + "navigation": "Navigation", + "addHost": "Host hinzufügen", + "addCredential": "Anmeldedaten hinzufügen", + "adminSettings": "Admin-Einstellungen", + "userProfile": "Benutzerprofil", + "updateLog": "Aktualisierungsprotokoll", + "hosts": "Hosts", + "openServerDetails": "Serverdetails öffnen", + "openFileManager": "Dateimanager öffnen", + "edit": "Bearbeiten", + "links": "Links", + "github": "GitHub", + "support": "Support", + "discord": "Discord", + "donate": "Spenden", + "press": "Drücken Sie", + "toToggle": "zum Umschalten", + "close": "Schließen", + "hostManager": "Host-Manager" } } diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 853d5ae6..213bdc06 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1689,5 +1689,28 @@ "cpu": "CPU", "ram": "RAM", "notAvailable": "N/A" + }, + "commandPalette": { + "searchPlaceholder": "Search for hosts or quick actions...", + "recentActivity": "Recent Activity", + "navigation": "Navigation", + "addHost": "Add Host", + "addCredential": "Add Credential", + "adminSettings": "Admin Settings", + "userProfile": "User Profile", + "updateLog": "Update Log", + "hosts": "Hosts", + "openServerDetails": "Open Server Details", + "openFileManager": "Open File Manager", + "edit": "Edit", + "links": "Links", + "github": "GitHub", + "support": "Support", + "discord": "Discord", + "donate": "Donate", + "press": "Press", + "toToggle": "to toggle", + "close": "Close", + "hostManager": "Host Manager" } } diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index 1f3fb9bd..f641414e 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1657,5 +1657,28 @@ "cpu": "CPU", "ram": "RAM", "notAvailable": "N/A" + }, + "commandPalette": { + "searchPlaceholder": "Поиск хостов или быстрых действий...", + "recentActivity": "Недавняя активность", + "navigation": "Навигация", + "addHost": "Добавить хост", + "addCredential": "Добавить учетные данные", + "adminSettings": "Настройки администратора", + "userProfile": "Профиль пользователя", + "updateLog": "Журнал обновлений", + "hosts": "Хосты", + "openServerDetails": "Открыть детали сервера", + "openFileManager": "Открыть файловый менеджер", + "edit": "Редактировать", + "links": "Ссылки", + "github": "GitHub", + "support": "Поддержка", + "discord": "Discord", + "donate": "Пожертвовать", + "press": "Нажмите", + "toToggle": "для переключения", + "close": "Закрыть", + "hostManager": "Менеджер хостов" } } diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index bac2f366..bdeb9d57 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1582,5 +1582,28 @@ "cpu": "CPU", "ram": "内存", "notAvailable": "不可用" + }, + "commandPalette": { + "searchPlaceholder": "搜索主机或快速操作...", + "recentActivity": "最近活动", + "navigation": "导航", + "addHost": "添加主机", + "addCredential": "添加凭据", + "adminSettings": "管理员设置", + "userProfile": "用户资料", + "updateLog": "更新日志", + "hosts": "主机", + "openServerDetails": "打开服务器详情", + "openFileManager": "打开文件管理器", + "edit": "编辑", + "links": "链接", + "github": "GitHub", + "support": "支持", + "discord": "Discord", + "donate": "捐赠", + "press": "按下", + "toToggle": "来切换", + "close": "关闭", + "hostManager": "主机管理器" } } diff --git a/src/ui/desktop/apps/command-palette/CommandPalette.tsx b/src/ui/desktop/apps/command-palette/CommandPalette.tsx index 1833aa1d..64871062 100644 --- a/src/ui/desktop/apps/command-palette/CommandPalette.tsx +++ b/src/ui/desktop/apps/command-palette/CommandPalette.tsx @@ -20,6 +20,7 @@ import { Pencil, EllipsisVertical, } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { BiMoney, BiSupport } from "react-icons/bi"; import { BsDiscord } from "react-icons/bs"; import { GrUpdate } from "react-icons/gr"; @@ -64,6 +65,7 @@ export function CommandPalette({ isOpen: boolean; setIsOpen: (isOpen: boolean) => void; }) { + const { t } = useTranslation(); const inputRef = useRef(null); const { addTab, setCurrentTab, tabs: tabList, updateTab } = useTabs(); const [recentActivity, setRecentActivity] = useState( @@ -91,7 +93,7 @@ export function CommandPalette({ } else { const id = addTab({ type: "ssh_manager", - title: "Host Manager", + title: t("commandPalette.hostManager"), initialTab: "add_host", }); setCurrentTab(id); @@ -107,7 +109,7 @@ export function CommandPalette({ } else { const id = addTab({ type: "ssh_manager", - title: "Host Manager", + title: t("commandPalette.hostManager"), initialTab: "add_credential", }); setCurrentTab(id); @@ -120,7 +122,7 @@ export function CommandPalette({ if (adminTab) { setCurrentTab(adminTab.id); } else { - const id = addTab({ type: "admin", title: "Admin Settings" }); + const id = addTab({ type: "admin", title: t("commandPalette.adminSettings") }); setCurrentTab(id); } setIsOpen(false); @@ -131,7 +133,7 @@ export function CommandPalette({ if (userProfileTab) { setCurrentTab(userProfileTab.id); } else { - const id = addTab({ type: "user_profile", title: "User Profile" }); + const id = addTab({ type: "user_profile", title: t("commandPalette.userProfile") }); setCurrentTab(id); } setIsOpen(false); @@ -214,7 +216,7 @@ export function CommandPalette({ : `${host.username}@${host.ip}:${host.port}`; addTab({ type: "ssh_manager", - title: "Host Manager", + title: t("commandPalette.hostManager"), hostConfig: host, initialTab: "add_host", }); @@ -235,7 +237,7 @@ export function CommandPalette({ > {recentActivity.length > 0 && ( <> - + {recentActivity.map((item, index) => ( )} - + - Add Host + {t("commandPalette.addHost")} - Add Credential + {t("commandPalette.addCredential")} - Admin Settings + {t("commandPalette.adminSettings")} - User Profile + {t("commandPalette.userProfile")} - Update Log + {t("commandPalette.updateLog")} - + {hosts.map((host, index) => { const title = host.name?.trim() ? host.name @@ -329,7 +331,7 @@ export function CommandPalette({ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" > - Open Server Details + {t("commandPalette.openServerDetails")} { @@ -339,7 +341,7 @@ export function CommandPalette({ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" > - Open File Manager + {t("commandPalette.openFileManager")} { @@ -349,7 +351,7 @@ export function CommandPalette({ className="flex items-center gap-2 cursor-pointer px-3 py-2 hover:bg-dark-hover text-gray-300" > - Edit + {t("commandPalette.edit")} @@ -359,36 +361,36 @@ export function CommandPalette({ })} - + - GitHub + {t("commandPalette.github")} - Support + {t("commandPalette.support")} - Discord + {t("commandPalette.discord")} - Donate + {t("commandPalette.donate")}
- Press + {t("commandPalette.press")} Shift Shift - to toggle + {t("commandPalette.toToggle")}
- Close + {t("commandPalette.close")} Esc
-- 2.49.1 From f311a7a5ad1acaa19fef078aac9eff76df45d0e2 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 06:48:31 +0800 Subject: [PATCH 11/42] feat: Add smooth transitions and animations to UI - Add fade-in/fade-out transition to command palette (200ms) - Add scale animation to command palette on open/close - Add smooth popup animation to context menu (150ms) - Add visual feedback for file selection with ring effect - Add hover scale effect to file grid items - Add transition-all to list view items for consistent behavior - Zero JavaScript overhead, pure CSS transitions - All animations under 200ms for instant feel --- .../apps/command-palette/CommandPalette.tsx | 10 +++++--- .../file-manager/FileManagerContextMenu.tsx | 25 +++++++++++++++---- .../apps/file-manager/FileManagerGrid.tsx | 8 +++--- 3 files changed, 32 insertions(+), 11 deletions(-) diff --git a/src/ui/desktop/apps/command-palette/CommandPalette.tsx b/src/ui/desktop/apps/command-palette/CommandPalette.tsx index 64871062..2c1da720 100644 --- a/src/ui/desktop/apps/command-palette/CommandPalette.tsx +++ b/src/ui/desktop/apps/command-palette/CommandPalette.tsx @@ -226,13 +226,17 @@ export function CommandPalette({ return (
setIsOpen(false)} > e.stopPropagation()} > { - if (!isVisible) return; + if (!isVisible) { + setIsMounted(false); + return; + } + + setIsMounted(true); const adjustPosition = () => { const menuWidth = 200; @@ -183,8 +189,6 @@ export function FileManagerContextMenu({ }; }, [isVisible, x, y, onClose]); - if (!isVisible) return null; - const isFileContext = files.length > 0; const isSingleFile = files.length === 1; const isMultipleFiles = files.length > 1; @@ -440,13 +444,24 @@ export function FileManagerContextMenu({ ); }; + if (!isVisible && !isMounted) return null; + return ( <> -
+
f.path === file.path) && @@ -1138,8 +1139,9 @@ export function FileManagerGrid({ draggable={true} className={cn( "flex items-center gap-3 p-2 rounded cursor-pointer", + "transition-all duration-150 ease-out", "hover:bg-accent hover:text-accent-foreground", - isSelected && "bg-primary/20", + isSelected && "bg-primary/20 ring-2 ring-primary/20", dragState.target?.path === file.path && "bg-muted border-primary border-dashed relative z-10", dragState.files.some((f) => f.path === file.path) && -- 2.49.1 From cd1afc90783bd153324f2a5920459f2d1e2ce2bf Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 06:55:42 +0800 Subject: [PATCH 12/42] feat: Add button active state and dashboard card animations - Add active:scale-95 to all buttons for tactile click feedback - Add hover border effect to dashboard cards (150ms transition) - Add pulse animation to dashboard loading states - Pure CSS transitions with zero JavaScript overhead - Improves enterprise-level feel of UI --- src/components/ui/button.tsx | 2 +- src/ui/desktop/apps/dashboard/Dashboard.tsx | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 26ee717b..d97fef14 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95", { variants: { variant: { diff --git a/src/ui/desktop/apps/dashboard/Dashboard.tsx b/src/ui/desktop/apps/dashboard/Dashboard.tsx index f38d5d95..69e2962e 100644 --- a/src/ui/desktop/apps/dashboard/Dashboard.tsx +++ b/src/ui/desktop/apps/dashboard/Dashboard.tsx @@ -529,7 +529,7 @@ export function Dashboard({
-
+

@@ -549,7 +549,7 @@ export function Dashboard({ className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${recentActivityLoading ? "overflow-y-hidden" : "overflow-y-auto"}`} > {recentActivityLoading ? ( -

+
{t("dashboard.loadingRecentActivity")}
@@ -581,7 +581,7 @@ export function Dashboard({
-
+

@@ -645,7 +645,7 @@ export function Dashboard({

-
+

@@ -655,7 +655,7 @@ export function Dashboard({ className={`grid gap-4 grid-cols-[repeat(auto-fit,minmax(200px,1fr))] auto-rows-min overflow-x-hidden ${serverStatsLoading ? "overflow-y-hidden" : "overflow-y-auto"}`} > {serverStatsLoading ? ( -

+
{t("dashboard.loadingServerStats")}
-- 2.49.1 From fe127e045ff72821eb2c120443eb55249480ea90 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:11:29 +0800 Subject: [PATCH 13/42] feat: Add smooth macOS-style page transitions - Add fullscreen crossfade transition for login/logout (300ms fade-out + 400ms fade-in) - Add slide-in-from-right animation for all page switches (Dashboard, Terminal, SSH Manager, Admin, Profile) - Fix TypeScript compilation by adding esModuleInterop to tsconfig.node.json - Pass handleLogout from DesktopApp to LeftSidebar for consistent transition behavior All page transitions now use Tailwind animate-in utilities with 300ms duration for smooth, native-feeling UX --- src/ui/desktop/DesktopApp.tsx | 73 +++++++++++++++++++---- src/ui/desktop/navigation/LeftSidebar.tsx | 4 +- tsconfig.node.json | 1 + 3 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index b7bcb6de..093ba6d5 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -23,6 +23,8 @@ function AppContent() { const saved = localStorage.getItem("topNavbarOpen"); return saved !== null ? JSON.parse(saved) : true; }); + const [isTransitioning, setIsTransitioning] = useState(false); + const [transitionPhase, setTransitionPhase] = useState<'idle' | 'fadeOut' | 'fadeIn'>('idle'); const { currentTab, tabs } = useTabs(); const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); @@ -98,13 +100,44 @@ function AppContent() { username: string | null; userId: string | null; }) => { - setIsAuthenticated(true); - setIsAdmin(authData.isAdmin); - setUsername(authData.username); + setIsTransitioning(true); + setTransitionPhase('fadeOut'); + + setTimeout(() => { + setIsAuthenticated(true); + setIsAdmin(authData.isAdmin); + setUsername(authData.username); + setTransitionPhase('fadeIn'); + + setTimeout(() => { + setIsTransitioning(false); + setTransitionPhase('idle'); + }, 400); + }, 300); }, [], ); + const handleLogout = useCallback(async () => { + setIsTransitioning(true); + setTransitionPhase('fadeOut'); + + setTimeout(async () => { + try { + const { logoutUser, isElectron } = await import("@/ui/main-axios.ts"); + await logoutUser(); + + if (isElectron()) { + localStorage.removeItem("jwt"); + } + } catch (error) { + console.error("Logout failed:", error); + } + + window.location.reload(); + }, 300); + }, []); + const currentTabData = tabs.find((tab) => tab.id === currentTab); const showTerminalView = currentTabData?.type === "terminal" || @@ -135,20 +168,25 @@ function AppContent() { {isAuthenticated && ( + onSelectView={handleSelectView} + disabled={!isAuthenticated || authLoading} + isAdmin={isAdmin} + username={username} + onLogout={handleLogout} + >
- + {showTerminalView && ( +
+ +
+ )}
{showHome && ( -
+
+
+
)} {showProfile && ( -
+
)} @@ -189,6 +227,15 @@ function AppContent() { /> )} + + {isTransitioning && ( +
+ )} + void; } async function handleLogout() { @@ -87,6 +88,7 @@ export function LeftSidebar({ isAdmin, username, children, + onLogout, }: SidebarProps): React.ReactElement { const { t } = useTranslation(); @@ -486,7 +488,7 @@ export function LeftSidebar({ )} {t("common.logout")} diff --git a/tsconfig.node.json b/tsconfig.node.json index 4e53dcad..2edfb287 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -8,6 +8,7 @@ "moduleResolution": "nodenext", "verbatimModuleSyntax": true, "moduleDetection": "force", + "esModuleInterop": true, "noEmit": false, "outDir": "./dist/backend", "strict": false, -- 2.49.1 From aa94851f4a299eed7ca8a902be424713fbd51dfc Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:15:02 +0800 Subject: [PATCH 14/42] fix: Add key prop to force animation re-trigger on tab switch Each page container now has key={currentTab} to ensure React unmounts and remounts the element on every tab switch, properly triggering the slide-in animation --- src/ui/desktop/DesktopApp.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 093ba6d5..35228c70 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -179,14 +179,14 @@ function AppContent() { style={{ display: showTerminalView ? "block" : "none" }} > {showTerminalView && ( -
+
)}
{showHome && ( -
+
+
+
)} {showProfile && ( -
+
)} -- 2.49.1 From 28406d060c0df25213f85755046814e0d662284d Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:17:39 +0800 Subject: [PATCH 15/42] revert: Remove page transition animations Page switching animations were not noticeable enough and felt unnecessary. Keep only the login/logout fullscreen crossfade transitions which provide clear visual feedback for authentication state changes --- src/ui/desktop/DesktopApp.tsx | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 35228c70..743ed13a 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -178,15 +178,11 @@ function AppContent() { className="h-screen w-full visible pointer-events-auto static overflow-hidden" style={{ display: showTerminalView ? "block" : "none" }} > - {showTerminalView && ( -
- -
- )} +
{showHome && ( -
+
+
+
)} {showProfile && ( -
+
)} -- 2.49.1 From f1bf12bb98b95f1d16df2ce8f45dda920e3ee277 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:19:42 +0800 Subject: [PATCH 16/42] feat: Add ripple effect to login/logout transitions Add three-layer expanding ripple animation during fadeOut phase: - Ripples expand from screen center using primary theme color - Each layer has staggered delay (0ms, 150ms, 300ms) for wave effect - Ripples fade out as they expand to create elegant visual feedback - Uses pure CSS keyframe animation, no external libraries Total animation: 800ms ripple + 300ms screen fade --- src/ui/desktop/DesktopApp.tsx | 41 ++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 743ed13a..5d83275a 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -229,7 +229,46 @@ function AppContent() { className={`fixed inset-0 bg-background z-[20000] transition-opacity duration-300 ${ transitionPhase === 'fadeOut' ? 'opacity-100' : 'opacity-0' }`} - /> + > + {transitionPhase === 'fadeOut' && ( + <> +
+
+
+
+
+ + + )} +
)} Date: Sun, 9 Nov 2025 08:21:56 +0800 Subject: [PATCH 17/42] feat: Add smooth TERMIX logo animation to transitions Changes: - Extend transition duration from 300ms/400ms to 800ms/600ms for more elegant feel - Reduce ripple intensity from /20,/15,/10 to /8,/5 for subtlety - Slow down ripple animation from 0.8s to 2s with cubic-bezier easing - Add centered TERMIX logo with monospace font and subtitle - Logo fades in from 80% scale, holds, then fades out at 110% scale - Total effect: 1.2s logo animation synced with 2s ripple waves Creates a premium, branded transition experience --- src/ui/desktop/DesktopApp.tsx | 57 ++++++++++++++++++++++++++--------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index 5d83275a..dd776f84 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -112,8 +112,8 @@ function AppContent() { setTimeout(() => { setIsTransitioning(false); setTransitionPhase('idle'); - }, 400); - }, 300); + }, 600); + }, 800); }, [], ); @@ -135,7 +135,7 @@ function AppContent() { } window.location.reload(); - }, 300); + }, 800); }, []); const currentTabData = tabs.find((tab) => tab.id === currentTab); @@ -226,38 +226,47 @@ function AppContent() { {isTransitioning && (
{transitionPhase === 'fadeOut' && ( <>
-
-
-
+
+
+ TERMIX +
+
+ SSH TERMINAL MANAGER +
+
)} -- 2.49.1 From 7c5fddb07fe1a05d7359d865c74c31f4c8f2be51 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:23:56 +0800 Subject: [PATCH 18/42] feat: Enhance transition animation with premium details Timing adjustments: - Extend fadeOut from 800ms to 1200ms - Extend fadeIn from 600ms to 800ms - Slow background fade to 700ms for elegance Visual enhancements: - Add 4-layer ripple waves (10%, 7%, 5%, 3% opacity) with staggered delays - Ripple animation extended to 2.5s with refined opacity curve - Logo blur effect: starts at 8px, sharpens to 0px, exits at 4px - Logo glow effect: triple-layer text-shadow using primary theme color - Increase logo size from text-6xl to text-7xl - Subtitle delayed fade-in from bottom with smooth slide animation Creates a cinematic, polished brand experience --- src/ui/desktop/DesktopApp.tsx | 107 +++++++++++++++++++++++++++------- 1 file changed, 87 insertions(+), 20 deletions(-) diff --git a/src/ui/desktop/DesktopApp.tsx b/src/ui/desktop/DesktopApp.tsx index dd776f84..bd652536 100644 --- a/src/ui/desktop/DesktopApp.tsx +++ b/src/ui/desktop/DesktopApp.tsx @@ -112,8 +112,8 @@ function AppContent() { setTimeout(() => { setIsTransitioning(false); setTransitionPhase('idle'); - }, 600); - }, 800); + }, 800); + }, 1200); }, [], ); @@ -135,7 +135,7 @@ function AppContent() { } window.location.reload(); - }, 800); + }, 1200); }, []); const currentTabData = tabs.find((tab) => tab.id === currentTab); @@ -226,34 +226,52 @@ function AppContent() { {isTransitioning && (
{transitionPhase === 'fadeOut' && ( <> -
-
+
+
+
-
+
TERMIX
-
+
SSH TERMINAL MANAGER
@@ -263,10 +281,13 @@ function AppContent() { 0% { width: 0; height: 0; - opacity: 0.8; + opacity: 1; } - 50% { - opacity: 0.4; + 30% { + opacity: 0.6; + } + 70% { + opacity: 0.3; } 100% { width: 200vmax; @@ -277,19 +298,65 @@ function AppContent() { @keyframes logoFade { 0% { opacity: 0; - transform: scale(0.8); + transform: scale(0.85); + filter: blur(8px); } - 20% { + 25% { opacity: 1; transform: scale(1); + filter: blur(0px); } - 80% { + 75% { opacity: 1; transform: scale(1); + filter: blur(0px); } 100% { opacity: 0; - transform: scale(1.1); + transform: scale(1.05); + filter: blur(4px); + } + } + @keyframes logoGlow { + 0% { + color: hsl(var(--primary)); + text-shadow: none; + } + 25% { + color: hsl(var(--primary)); + text-shadow: + 0 0 20px hsla(var(--primary), 0.3), + 0 0 40px hsla(var(--primary), 0.2), + 0 0 60px hsla(var(--primary), 0.1); + } + 75% { + color: hsl(var(--primary)); + text-shadow: + 0 0 20px hsla(var(--primary), 0.3), + 0 0 40px hsla(var(--primary), 0.2), + 0 0 60px hsla(var(--primary), 0.1); + } + 100% { + color: hsl(var(--primary)); + text-shadow: none; + } + } + @keyframes subtitleFade { + 0%, 30% { + opacity: 0; + transform: translateY(10px); + } + 50% { + opacity: 1; + transform: translateY(0); + } + 75% { + opacity: 1; + transform: translateY(0); + } + 100% { + opacity: 0; + transform: translateY(-5px); } } `} -- 2.49.1 From 3a8e3a1607e0ee57e99c4c6fd8d4c74da4fb2c5c Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:29:42 +0800 Subject: [PATCH 19/42] feat: Redesign login page with split-screen cinematic layout Major redesign of authentication page: Left Side (40% width): - Full-height gradient background using primary theme color - Large TERMIX logo with glow effect - Subtitle and tagline - Infinite animated ripple waves (3 layers) - Hidden on mobile, shows brand identity Right Side (60% width): - Centered glassmorphism card with backdrop blur - Refined tab switcher with pill-style active state - Enlarged title with gradient text effect - Added welcome subtitles for better UX - Card slides in from bottom on load - All existing functionality preserved Visual enhancements: - Tab navigation: segmented control style in muted container - Active tab: white background with subtle shadow - Smooth 200ms transitions on all interactions - Card: rounded-2xl, shadow-xl, semi-transparent border Creates premium, modern login experience matching transition animations --- src/ui/desktop/authentication/Auth.tsx | 102 ++++++++++++++++++++----- 1 file changed, 83 insertions(+), 19 deletions(-) diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 997d712b..446931c3 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -683,7 +683,7 @@ export function Auth({ if (showServerConfig === null && !isInElectronWebView()) { return (
@@ -697,7 +697,7 @@ export function Auth({ if (showServerConfig && !isInElectronWebView()) { return (
@@ -722,7 +722,7 @@ export function Auth({ ) { return (
@@ -755,7 +755,7 @@ export function Auth({ if (dbHealthChecking && !dbConnectionFailed) { return (
@@ -774,7 +774,7 @@ export function Auth({ if (dbConnectionFailed) { return (
@@ -834,10 +834,61 @@ export function Auth({ return (
+ {/* Split Screen Layout */} +
+ + {/* Left Side - Brand Showcase */} +
+ {/* Animated Ripples Background */} +
+
+
+
+
+ + {/* Logo and Branding */} +
+
+ TERMIX +
+
+ SSH TERMINAL MANAGER +
+
+ Secure, powerful, and intuitive SSH connection management for modern teams +
+
+ + +
+ + {/* Right Side - Auth Form */} +
+
{isInElectronWebView() && !webviewAuthSuccess && ( @@ -948,15 +999,16 @@ export function Auth({ return ( <> -
+ {/* Tab Navigation */} +
{passwordLoginAllowed && ( )}
-
-

+ + {/* Page Title */} +
+

{tab === "login" ? t("auth.loginTitle") : tab === "signup" @@ -1022,6 +1076,13 @@ export function Auth({ ? t("auth.loginWithExternal") : t("auth.forgotPassword")}

+

+ {tab === "login" + ? "Welcome back to TERMIX" + : tab === "signup" + ? "Create your TERMIX account" + : "Continue with external provider"} +

{tab === "external" || tab === "reset" ? ( @@ -1339,6 +1400,9 @@ export function Auth({ })()} )} +

+
+
); } -- 2.49.1 From fe9e392b2cc77038d16062c48bbc7b317ef6dbc7 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 08:46:58 +0800 Subject: [PATCH 20/42] feat: Update login page theme colors and add i18n support - Changed login page gradient from blue to match dark theme colors - Updated ripple effects to use theme primary color - Added i18n translation keys for login page (auth.tagline, auth.description, auth.welcomeBack, auth.createAccount, auth.continueExternal) - Updated all language files (en, zh, de, ru, pt-BR) with new translations - Fixed TypeScript compilation issues by clearing build cache --- src/components/ui/button.tsx | 2 +- src/components/ui/input.tsx | 2 +- src/components/ui/tabs.tsx | 10 +++++-- src/locales/de/translation.json | 5 ++++ src/locales/en/translation.json | 5 ++++ src/locales/pt-BR/translation.json | 5 ++++ src/locales/ru/translation.json | 5 ++++ src/locales/zh/translation.json | 5 ++++ src/ui/desktop/authentication/Auth.tsx | 32 ++++++++++++--------- src/ui/desktop/navigation/SSHAuthDialog.tsx | 4 +-- src/ui/desktop/navigation/TOTPDialog.tsx | 4 +-- 11 files changed, 57 insertions(+), 22 deletions(-) diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index d97fef14..fbdf6b8d 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -6,7 +6,7 @@ import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-100 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive active:scale-95", { variants: { variant: { diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index b1a060f5..ba0f8921 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) { type={type} data-slot="input" className={cn( - "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", + "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] duration-200 outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className, diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx index 46295c2f..12b4fc5c 100644 --- a/src/components/ui/tabs.tsx +++ b/src/components/ui/tabs.tsx @@ -40,7 +40,7 @@ function TabsTrigger({ ); diff --git a/src/locales/de/translation.json b/src/locales/de/translation.json index 3d9e65d6..c1d6639b 100644 --- a/src/locales/de/translation.json +++ b/src/locales/de/translation.json @@ -1192,6 +1192,11 @@ "from": "von" }, "auth": { + "tagline": "SSH TERMINAL MANAGER", + "description": "Sichere, leistungsstarke und intuitive SSH-Verbindungsverwaltung", + "welcomeBack": "Willkommen zurück bei TERMIX", + "createAccount": "Erstellen Sie Ihr TERMIX-Konto", + "continueExternal": "Mit externem Anbieter fortfahren", "loginTitle": "Melden Sie sich bei Termix an", "registerTitle": "Benutzerkonto erstellen", "loginButton": "Anmelden", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 213bdc06..28296a66 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1301,6 +1301,11 @@ "from": "from" }, "auth": { + "tagline": "SSH TERMINAL MANAGER", + "description": "Secure, powerful, and intuitive SSH connection management", + "welcomeBack": "Welcome back to TERMIX", + "createAccount": "Create your TERMIX account", + "continueExternal": "Continue with external provider", "loginTitle": "Login to Termix", "registerTitle": "Create Account", "loginButton": "Login", diff --git a/src/locales/pt-BR/translation.json b/src/locales/pt-BR/translation.json index c656e9cb..2af8e80e 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -1143,6 +1143,11 @@ "available": "Disponível" }, "auth": { + "tagline": "GERENCIADOR DE TERMINAL SSH", + "description": "Gerenciamento de conexão SSH seguro, poderoso e intuitivo", + "welcomeBack": "Bem-vindo de volta ao TERMIX", + "createAccount": "Crie sua conta TERMIX", + "continueExternal": "Continuar com provedor externo", "loginTitle": "Entrar no Termix", "registerTitle": "Criar Conta", "loginButton": "Entrar", diff --git a/src/locales/ru/translation.json b/src/locales/ru/translation.json index f641414e..add3f212 100644 --- a/src/locales/ru/translation.json +++ b/src/locales/ru/translation.json @@ -1288,6 +1288,11 @@ "from": "с" }, "auth": { + "tagline": "SSH ТЕРМИНАЛ МЕНЕДЖЕР", + "description": "Безопасное, мощное и интуитивное управление SSH-соединениями", + "welcomeBack": "Добро пожаловать обратно в TERMIX", + "createAccount": "Создайте вашу учетную запись TERMIX", + "continueExternal": "Продолжить с внешним провайдером", "loginTitle": "Вход в Termix", "registerTitle": "Создать учетную запись", "loginButton": "Войти", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index bdeb9d57..3495f971 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1272,6 +1272,11 @@ "from": "来自" }, "auth": { + "tagline": "SSH 终端管理器", + "description": "安全、强大、直观的 SSH 连接管理", + "welcomeBack": "欢迎回到 TERMIX", + "createAccount": "创建您的 TERMIX 账户", + "continueExternal": "使用外部提供商继续", "loginTitle": "登录 Termix", "registerTitle": "创建账户", "loginButton": "登录", diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 446931c3..17d6081c 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -841,30 +841,34 @@ export function Auth({
{/* Left Side - Brand Showcase */} -
+
{/* Animated Ripples Background */}
-
-
-
+
+
+
{/* Logo and Branding */}
TERMIX
-
- SSH TERMINAL MANAGER +
+ {t("auth.tagline") || "SSH TERMINAL MANAGER"}
-
- Secure, powerful, and intuitive SSH connection management for modern teams +
+ {t("auth.description") || "Secure, powerful, and intuitive SSH connection management"}
@@ -1067,7 +1071,7 @@ export function Auth({ {/* Page Title */}
-

+

{tab === "login" ? t("auth.loginTitle") : tab === "signup" @@ -1078,10 +1082,10 @@ export function Auth({

{tab === "login" - ? "Welcome back to TERMIX" + ? t("auth.welcomeBack") : tab === "signup" - ? "Create your TERMIX account" - : "Continue with external provider"} + ? t("auth.createAccount") + : t("auth.continueExternal")}

diff --git a/src/ui/desktop/navigation/SSHAuthDialog.tsx b/src/ui/desktop/navigation/SSHAuthDialog.tsx index fb254288..2afda788 100644 --- a/src/ui/desktop/navigation/SSHAuthDialog.tsx +++ b/src/ui/desktop/navigation/SSHAuthDialog.tsx @@ -137,10 +137,10 @@ export function SSHAuthDialog({ return (
- + diff --git a/src/ui/desktop/navigation/TOTPDialog.tsx b/src/ui/desktop/navigation/TOTPDialog.tsx index 1a444404..d0527b6b 100644 --- a/src/ui/desktop/navigation/TOTPDialog.tsx +++ b/src/ui/desktop/navigation/TOTPDialog.tsx @@ -25,12 +25,12 @@ export function TOTPDialog({ if (!isOpen) return null; return ( -
+
-
+

-- 2.49.1 From a6fbd00a5897fccfc9e46789af8abe378d10b3e9 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:04:19 +0800 Subject: [PATCH 21/42] refactor: Use shadcn Tabs component and fix modal styling - Replace custom tab navigation with shadcn Tabs component - Restore border-2 border-dark-border for modal consistency - Remove circular icon from login success message - Simplify authentication success display --- src/ui/desktop/authentication/Auth.tsx | 112 ++++++++----------------- 1 file changed, 36 insertions(+), 76 deletions(-) diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 17d6081c..4cd3673c 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -5,6 +5,7 @@ import { Input } from "@/components/ui/input.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx"; import { Label } from "@/components/ui/label.tsx"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert.tsx"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs.tsx"; import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/ui/desktop/user/LanguageSwitcher.tsx"; import { toast } from "sonner"; @@ -890,7 +891,7 @@ export function Auth({ {/* Right Side - Auth Form */}
-
{isInElectronWebView() && !webviewAuthSuccess && ( @@ -902,21 +903,6 @@ export function Auth({ )} {isInElectronWebView() && webviewAuthSuccess && (
-
- - - -

{t("messages.loginSuccess")} @@ -1004,70 +990,44 @@ export function Auth({ return ( <> {/* Tab Navigation */} -
- {passwordLoginAllowed && ( - - )} - {(passwordLoginAllowed || firstUser) && - registrationAllowed && ( - + )} - {oidcConfigured && ( - - )} -
+ {oidcConfigured && ( + + {t("auth.external")} + + )} + + {/* Page Title */}
-- 2.49.1 From 4294a0f91909ae4247319044ba825016fce493bd Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:06:06 +0800 Subject: [PATCH 22/42] refactor: Remove ripple effects and gradient from login page - Remove animated ripple background effects - Remove gradient background, use solid color (bg-dark-bg-darker) - Remove text-shadow glow effect from logo - Simplify brand showcase to clean, minimal design --- src/ui/desktop/authentication/Auth.tsx | 33 +++----------------------- 1 file changed, 3 insertions(+), 30 deletions(-) diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 4cd3673c..b68c62d0 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -842,25 +842,13 @@ export function Auth({
{/* Left Side - Brand Showcase */} -
- {/* Animated Ripples Background */} -
-
-
-
-
- +
{/* Logo and Branding */} -
+
TERMIX @@ -872,21 +860,6 @@ export function Auth({ {t("auth.description") || "Secure, powerful, and intuitive SSH connection management"}
- -
{/* Right Side - Auth Form */} -- 2.49.1 From 5a888cb13a50ac9acbdd5805b0e84c526d4aeb3b Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:07:52 +0800 Subject: [PATCH 23/42] feat: Add decorative slash and remove subtitle from login page - Add decorative slash divider with gradient lines below TERMIX logo - Remove subtitle text (welcomeBack and createAccount) - Simplify page title to show only the main heading --- src/ui/desktop/authentication/Auth.tsx | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index b68c62d0..5266bab7 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -853,6 +853,12 @@ export function Auth({ > TERMIX
+ {/* Decorative Slash */} +
+
+
/
+
+
{t("auth.tagline") || "SSH TERMINAL MANAGER"}
@@ -1004,7 +1010,7 @@ export function Auth({ {/* Page Title */}
-

+

{tab === "login" ? t("auth.loginTitle") : tab === "signup" @@ -1013,13 +1019,6 @@ export function Auth({ ? t("auth.loginWithExternal") : t("auth.forgotPassword")}

-

- {tab === "login" - ? t("auth.welcomeBack") - : tab === "signup" - ? t("auth.createAccount") - : t("auth.continueExternal")} -

{tab === "external" || tab === "reset" ? ( -- 2.49.1 From 10e31092c97f7d20f577b39ec4e923fcb6b908c3 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:11:28 +0800 Subject: [PATCH 24/42] feat: Add diagonal line pattern background to login page - Replace decorative slash with subtle diagonal line pattern background - Use repeating-linear-gradient at 45deg angle - Set very low opacity (0.03) for subtle effect - Pattern uses theme primary color --- src/ui/desktop/authentication/Auth.tsx | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 5266bab7..7d08f968 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -842,9 +842,22 @@ export function Auth({
{/* Left Side - Brand Showcase */} -
+
+ {/* Diagonal Lines Background */} +
{/* Logo and Branding */} -
+
TERMIX
- {/* Decorative Slash */} -
-
-
/
-
-
{t("auth.tagline") || "SSH TERMINAL MANAGER"}
-- 2.49.1 From f8d07c155233f4f1bc009e229b8d2cf03c06c0b8 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:14:08 +0800 Subject: [PATCH 25/42] fix: Display diagonal line pattern on login background - Combine background color and pattern in single style attribute - Use white semi-transparent lines (rgba 0.03 opacity) - 45deg angle, 35px spacing, 2px width - Remove separate overlay div to ensure pattern visibility --- src/ui/desktop/authentication/Auth.tsx | 29 +++++++++++++------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/src/ui/desktop/authentication/Auth.tsx b/src/ui/desktop/authentication/Auth.tsx index 7d08f968..43a8381b 100644 --- a/src/ui/desktop/authentication/Auth.tsx +++ b/src/ui/desktop/authentication/Auth.tsx @@ -842,22 +842,21 @@ export function Auth({
{/* Left Side - Brand Showcase */} -
- {/* Diagonal Lines Background */} -
+
{/* Logo and Branding */} -
+
Date: Sun, 9 Nov 2025 09:16:11 +0800 Subject: [PATCH 26/42] security: Fix user enumeration vulnerability in login - Unify error messages for invalid username and incorrect password - Both return 401 status with 'Invalid username or password' - Prevent attackers from enumerating valid usernames - Maintain detailed logging for debugging purposes - Changed from 404 'User not found' to generic auth failure message --- src/backend/database/routes/users.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 7f21cbae..92ea8746 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -896,11 +896,11 @@ router.post("/login", async (req, res) => { .where(eq(users.username, username)); if (!user || user.length === 0) { - authLogger.warn(`User not found: ${username}`, { + authLogger.warn(`Login failed: user not found`, { operation: "user_login", username, }); - return res.status(404).json({ error: "User not found" }); + return res.status(401).json({ error: "Invalid username or password" }); } const userRecord = user[0]; @@ -918,12 +918,12 @@ router.post("/login", async (req, res) => { const isMatch = await bcrypt.compare(password, userRecord.password_hash); if (!isMatch) { - authLogger.warn(`Incorrect password for user: ${username}`, { + authLogger.warn(`Login failed: incorrect password`, { operation: "user_login", username, userId: userRecord.id, }); - return res.status(401).json({ error: "Incorrect password" }); + return res.status(401).json({ error: "Invalid username or password" }); } try { -- 2.49.1 From 9a0933bf2fc229c846875a9f7b102edc06d6e4ff Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 09:20:28 +0800 Subject: [PATCH 27/42] security: Add login rate limiting to prevent brute force attacks - Implement LoginRateLimiter with IP and username-based tracking - Block after 5 failed attempts within 15 minutes - Lock account/IP for 15 minutes after threshold - Automatic cleanup of expired entries every 5 minutes - Track remaining attempts in logs for monitoring - Return 429 status with remaining time on rate limit - Reset counters on successful login - Dual protection: both IP-based and username-based limits --- src/backend/database/routes/users.ts | 27 +++++ src/backend/utils/login-rate-limiter.ts | 146 ++++++++++++++++++++++++ 2 files changed, 173 insertions(+) create mode 100644 src/backend/utils/login-rate-limiter.ts diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 92ea8746..bbabf4bc 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -27,6 +27,7 @@ import { AuthManager } from "../../utils/auth-manager.js"; import { DataCrypto } from "../../utils/data-crypto.js"; import { LazyFieldEncryption } from "../../utils/lazy-field-encryption.js"; import { parseUserAgent } from "../../utils/user-agent-parser.js"; +import { loginRateLimiter } from "../../utils/login-rate-limiter.js"; const authManager = AuthManager.getInstance(); @@ -862,6 +863,7 @@ router.get("/oidc/callback", async (req, res) => { // POST /users/login router.post("/login", async (req, res) => { const { username, password } = req.body; + const clientIp = req.ip || req.socket.remoteAddress || "unknown"; if (!isNonEmptyString(username) || !isNonEmptyString(password)) { authLogger.warn("Invalid traditional login attempt", { @@ -872,6 +874,21 @@ router.post("/login", async (req, res) => { return res.status(400).json({ error: "Invalid username or password" }); } + // Check rate limiting + const lockStatus = loginRateLimiter.isLocked(clientIp, username); + if (lockStatus.locked) { + authLogger.warn("Login attempt blocked due to rate limiting", { + operation: "user_login_blocked", + username, + ip: clientIp, + remainingTime: lockStatus.remainingTime, + }); + return res.status(429).json({ + error: "Too many login attempts. Please try again later.", + remainingTime: lockStatus.remainingTime, + }); + } + try { const row = db.$client .prepare("SELECT value FROM settings WHERE key = 'allow_password_login'") @@ -896,9 +913,12 @@ router.post("/login", async (req, res) => { .where(eq(users.username, username)); if (!user || user.length === 0) { + loginRateLimiter.recordFailedAttempt(clientIp, username); authLogger.warn(`Login failed: user not found`, { operation: "user_login", username, + ip: clientIp, + remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username), }); return res.status(401).json({ error: "Invalid username or password" }); } @@ -918,10 +938,13 @@ router.post("/login", async (req, res) => { const isMatch = await bcrypt.compare(password, userRecord.password_hash); if (!isMatch) { + loginRateLimiter.recordFailedAttempt(clientIp, username); authLogger.warn(`Login failed: incorrect password`, { operation: "user_login", username, userId: userRecord.id, + ip: clientIp, + remainingAttempts: loginRateLimiter.getRemainingAttempts(clientIp, username), }); return res.status(401).json({ error: "Invalid username or password" }); } @@ -965,6 +988,9 @@ router.post("/login", async (req, res) => { deviceInfo: deviceInfo.deviceInfo, }); + // Reset rate limiter on successful login + loginRateLimiter.resetAttempts(clientIp, username); + authLogger.success(`User logged in successfully: ${username}`, { operation: "user_login_success", username, @@ -972,6 +998,7 @@ router.post("/login", async (req, res) => { dataUnlocked: true, deviceType: deviceInfo.type, deviceInfo: deviceInfo.deviceInfo, + ip: clientIp, }); const response: Record = { diff --git a/src/backend/utils/login-rate-limiter.ts b/src/backend/utils/login-rate-limiter.ts new file mode 100644 index 00000000..4e5ed704 --- /dev/null +++ b/src/backend/utils/login-rate-limiter.ts @@ -0,0 +1,146 @@ +interface LoginAttempt { + count: number; + firstAttempt: number; + lockedUntil?: number; +} + +class LoginRateLimiter { + private ipAttempts = new Map(); + private usernameAttempts = new Map(); + + private readonly MAX_ATTEMPTS = 5; + private readonly WINDOW_MS = 15 * 60 * 1000; // 15 minutes + private readonly LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes + + // Clean up old entries periodically + constructor() { + setInterval(() => this.cleanup(), 5 * 60 * 1000); // Clean every 5 minutes + } + + private cleanup(): void { + const now = Date.now(); + + // Clean IP attempts + for (const [ip, attempt] of this.ipAttempts.entries()) { + if (attempt.lockedUntil && attempt.lockedUntil < now) { + this.ipAttempts.delete(ip); + } else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) { + this.ipAttempts.delete(ip); + } + } + + // Clean username attempts + for (const [username, attempt] of this.usernameAttempts.entries()) { + if (attempt.lockedUntil && attempt.lockedUntil < now) { + this.usernameAttempts.delete(username); + } else if (!attempt.lockedUntil && (now - attempt.firstAttempt) > this.WINDOW_MS) { + this.usernameAttempts.delete(username); + } + } + } + + recordFailedAttempt(ip: string, username?: string): void { + const now = Date.now(); + + // Record IP attempt + const ipAttempt = this.ipAttempts.get(ip); + if (!ipAttempt) { + this.ipAttempts.set(ip, { + count: 1, + firstAttempt: now, + }); + } else if ((now - ipAttempt.firstAttempt) > this.WINDOW_MS) { + // Reset if outside window + this.ipAttempts.set(ip, { + count: 1, + firstAttempt: now, + }); + } else { + ipAttempt.count++; + if (ipAttempt.count >= this.MAX_ATTEMPTS) { + ipAttempt.lockedUntil = now + this.LOCKOUT_MS; + } + } + + // Record username attempt if provided + if (username) { + const userAttempt = this.usernameAttempts.get(username); + if (!userAttempt) { + this.usernameAttempts.set(username, { + count: 1, + firstAttempt: now, + }); + } else if ((now - userAttempt.firstAttempt) > this.WINDOW_MS) { + // Reset if outside window + this.usernameAttempts.set(username, { + count: 1, + firstAttempt: now, + }); + } else { + userAttempt.count++; + if (userAttempt.count >= this.MAX_ATTEMPTS) { + userAttempt.lockedUntil = now + this.LOCKOUT_MS; + } + } + } + } + + resetAttempts(ip: string, username?: string): void { + this.ipAttempts.delete(ip); + if (username) { + this.usernameAttempts.delete(username); + } + } + + isLocked(ip: string, username?: string): { locked: boolean; remainingTime?: number } { + const now = Date.now(); + + // Check IP lockout + const ipAttempt = this.ipAttempts.get(ip); + if (ipAttempt?.lockedUntil && ipAttempt.lockedUntil > now) { + return { + locked: true, + remainingTime: Math.ceil((ipAttempt.lockedUntil - now) / 1000), + }; + } + + // Check username lockout + if (username) { + const userAttempt = this.usernameAttempts.get(username); + if (userAttempt?.lockedUntil && userAttempt.lockedUntil > now) { + return { + locked: true, + remainingTime: Math.ceil((userAttempt.lockedUntil - now) / 1000), + }; + } + } + + return { locked: false }; + } + + getRemainingAttempts(ip: string, username?: string): number { + const now = Date.now(); + let minRemaining = this.MAX_ATTEMPTS; + + // Check IP attempts + const ipAttempt = this.ipAttempts.get(ip); + if (ipAttempt && (now - ipAttempt.firstAttempt) <= this.WINDOW_MS) { + const ipRemaining = Math.max(0, this.MAX_ATTEMPTS - ipAttempt.count); + minRemaining = Math.min(minRemaining, ipRemaining); + } + + // Check username attempts + if (username) { + const userAttempt = this.usernameAttempts.get(username); + if (userAttempt && (now - userAttempt.firstAttempt) <= this.WINDOW_MS) { + const userRemaining = Math.max(0, this.MAX_ATTEMPTS - userAttempt.count); + minRemaining = Math.min(minRemaining, userRemaining); + } + } + + return minRemaining; + } +} + +// Export singleton instance +export const loginRateLimiter = new LoginRateLimiter(); -- 2.49.1 From 417df31aa76f9fb49058171eb7acd4fd57cb865c Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 15:17:11 +0800 Subject: [PATCH 28/42] feat: Add file color coding in file manager - Files: blue (text-blue-400) - Folders: red (text-red-400) - Symlinks: green (text-green-400) - Add toggle in User Profile settings - Store preference in localStorage - Support i18n (en, zh) - Can be disabled via settings - Matches dark theme color scheme --- src/locales/en/translation.json | 2 + src/locales/zh/translation.json | 2 + .../apps/file-manager/FileManagerGrid.tsx | 42 +++++++++++++------ src/ui/desktop/user/UserProfile.tsx | 28 +++++++++++++ 4 files changed, 62 insertions(+), 12 deletions(-) diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 28296a66..0fe90342 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1479,6 +1479,8 @@ "local": "Local", "external": "External (OIDC)", "selectPreferredLanguage": "Select your preferred language for the interface", + "fileColorCoding": "File Color Coding", + "fileColorCodingDesc": "Color-code files by type: folders (red), files (blue), symlinks (green)", "currentPassword": "Current Password", "passwordChangedSuccess": "Password changed successfully! Please log in again.", "failedToChangePassword": "Failed to change password. Please check your current password and try again." diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 3495f971..5561e41c 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1442,6 +1442,8 @@ "local": "本地", "external": "外部 (OIDC)", "selectPreferredLanguage": "选择您的界面首选语言", + "fileColorCoding": "文件颜色编码", + "fileColorCodingDesc": "按类型对文件进行颜色编码:文件夹(红色)、文件(蓝色)、符号链接(绿色)", "currentPassword": "当前密码", "passwordChangedSuccess": "密码修改成功!请重新登录。", "failedToChangePassword": "修改密码失败。请检查您当前的密码并重试。" diff --git a/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx b/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx index 8809a7bd..5c1a0ac8 100644 --- a/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx +++ b/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx @@ -96,15 +96,33 @@ interface FileManagerGridProps { onNewFolder?: () => void; } -const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { - const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6"; +const getFileTypeColor = (file: FileItem): string => { + const colorEnabled = localStorage.getItem("fileColorCoding") !== "false"; + if (!colorEnabled) { + return "text-muted-foreground"; + } if (file.type === "directory") { - return ; + return "text-red-400"; } if (file.type === "link") { - return ; + return "text-green-400"; + } + + return "text-blue-400"; +}; + +const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { + const iconClass = viewMode === "grid" ? "w-8 h-8" : "w-6 h-6"; + const colorClass = getFileTypeColor(file); + + if (file.type === "directory") { + return ; + } + + if (file.type === "link") { + return ; } const ext = file.name.split(".").pop()?.toLowerCase(); @@ -113,30 +131,30 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { case "txt": case "md": case "readme": - return ; + return ; case "png": case "jpg": case "jpeg": case "gif": case "bmp": case "svg": - return ; + return ; case "mp4": case "avi": case "mkv": case "mov": - return ; + return ; case "mp3": case "wav": case "flac": case "ogg": - return ; + return ; case "zip": case "tar": case "gz": case "rar": case "7z": - return ; + return ; case "js": case "ts": case "jsx": @@ -150,7 +168,7 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { case "rb": case "go": case "rs": - return ; + return ; case "json": case "xml": case "yaml": @@ -159,9 +177,9 @@ const getFileIcon = (file: FileItem, viewMode: "grid" | "list" = "grid") => { case "ini": case "conf": case "config": - return ; + return ; default: - return ; + return ; } }; diff --git a/src/ui/desktop/user/UserProfile.tsx b/src/ui/desktop/user/UserProfile.tsx index dfc9c709..77734431 100644 --- a/src/ui/desktop/user/UserProfile.tsx +++ b/src/ui/desktop/user/UserProfile.tsx @@ -10,6 +10,7 @@ import { TabsTrigger, } from "@/components/ui/tabs.tsx"; import { Separator } from "@/components/ui/separator.tsx"; +import { Switch } from "@/components/ui/switch.tsx"; import { User, Shield, AlertCircle } from "lucide-react"; import { TOTPSetup } from "@/ui/desktop/user/TOTPSetup.tsx"; import { @@ -87,6 +88,9 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) { const [deletePassword, setDeletePassword] = useState(""); const [deleteLoading, setDeleteLoading] = useState(false); const [deleteError, setDeleteError] = useState(null); + const [fileColorCoding, setFileColorCoding] = useState( + localStorage.getItem("fileColorCoding") !== "false" + ); useEffect(() => { fetchUserInfo(); @@ -128,6 +132,13 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) { } }; + const handleFileColorCodingToggle = (enabled: boolean) => { + setFileColorCoding(enabled); + localStorage.setItem("fileColorCoding", enabled.toString()); + // Trigger a re-render by dispatching a custom event + window.dispatchEvent(new Event("fileColorCodingChanged")); + }; + const handleDeleteAccount = async (e: React.FormEvent) => { e.preventDefault(); setDeleteLoading(true); @@ -325,6 +336,23 @@ export function UserProfile({ isTopbarOpen = true }: UserProfileProps) {
+
+
+
+ +

+ {t("profile.fileColorCodingDesc")} +

+
+ +
+
+
-- 2.49.1 From ae3b8bba12935156a89e0a35b51cb8941c37b34a Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 15:39:52 +0800 Subject: [PATCH 29/42] feat: Add file permissions dialog for SSH file manager Add a comprehensive file permissions modification feature accessible via right-click context menu: - Add PermissionsDialog component with read/write/execute checkboxes for owner/group/others - Display current and new permissions side-by-side with real-time octal calculation - Support both numeric (755) and symbolic (rwxr-xr-x) permission formats - Implement backend API endpoint POST /ssh/file_manager/ssh/changePermissions - Add frontend API function changeSSHPermissions with complete logging - Integrate dialog with FileManager via onProperties callback - Add i18n translations for English and Chinese - Include path escaping and comprehensive error handling --- src/backend/ssh/file-manager.ts | 97 ++++++ src/locales/en/translation.json | 14 +- src/locales/zh/translation.json | 14 +- .../desktop/apps/file-manager/FileManager.tsx | 41 +++ .../components/PermissionsDialog.tsx | 295 ++++++++++++++++++ src/ui/main-axios.ts | 45 +++ 6 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 70b0e22d..b2ee2138 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -2490,6 +2490,103 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { }); }); +app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => { + const { sessionId, path, permissions } = req.body; + const sshConn = sshSessions[sessionId]; + + if (!sshConn || !sshConn.isConnected) { + fileLogger.error( + "SSH connection not found or not connected for changePermissions", + { + operation: "change_permissions", + sessionId, + hasConnection: !!sshConn, + isConnected: sshConn?.isConnected, + }, + ); + return res.status(400).json({ error: "SSH connection not available" }); + } + + if (!path) { + return res.status(400).json({ error: "File path is required" }); + } + + if (!permissions || !/^\d{3,4}$/.test(permissions)) { + return res.status(400).json({ + error: "Valid permissions required (e.g., 755, 644)" + }); + } + + const octalPerms = permissions.slice(-3); + const escapedPath = path.replace(/'/g, "'\"'\"'"); + const command = `chmod ${octalPerms} '${escapedPath}'`; + + fileLogger.info("Changing file permissions", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + + sshConn.client.exec(command, (err, stream) => { + if (err) { + fileLogger.error("SSH changePermissions exec error:", err, { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + return res.status(500).json({ error: "Failed to change permissions" }); + } + + let errorOutput = ""; + + stream.stderr.on("data", (data) => { + errorOutput += data.toString(); + }); + + stream.on("close", (code) => { + if (code !== 0) { + fileLogger.error("chmod command failed", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + exitCode: code, + error: errorOutput, + }); + return res.status(500).json({ + error: errorOutput || "Failed to change permissions" + }); + } + + fileLogger.success("File permissions changed successfully", { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + + res.json({ + success: true, + message: "Permissions changed successfully" + }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH changePermissions stream error:", streamErr, { + operation: "change_permissions", + sessionId, + path, + permissions: octalPerms, + }); + if (!res.headersSent) { + res.status(500).json({ error: "Stream error while changing permissions" }); + } + }); + }); +}); + process.on("SIGINT", () => { Object.keys(sshSessions).forEach(cleanupSession); process.exit(0); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 0fe90342..485c2293 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -1169,7 +1169,19 @@ "sshConnectionFailed": "SSH connection failed. Please check your connection to {{name}} ({{ip}}:{{port}})", "loadFileFailed": "Failed to load file: {{error}}", "connectedSuccessfully": "Connected successfully", - "totpVerificationFailed": "TOTP verification failed" + "totpVerificationFailed": "TOTP verification failed", + "changePermissions": "Change Permissions", + "changePermissionsDesc": "Modify file permissions for", + "currentPermissions": "Current Permissions", + "newPermissions": "New Permissions", + "owner": "Owner", + "group": "Group", + "others": "Others", + "read": "Read", + "write": "Write", + "execute": "Execute", + "permissionsChangedSuccessfully": "Permissions changed successfully", + "failedToChangePermissions": "Failed to change permissions" }, "tunnels": { "title": "SSH Tunnels", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 5561e41c..9150f81d 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -1151,7 +1151,19 @@ "sshConnectionFailed": "SSH 连接失败。请检查与 {{name}} ({{ip}}:{{port}}) 的连接", "loadFileFailed": "加载文件失败:{{error}}", "connectedSuccessfully": "连接成功", - "totpVerificationFailed": "TOTP 验证失败" + "totpVerificationFailed": "TOTP 验证失败", + "changePermissions": "修改权限", + "changePermissionsDesc": "修改文件权限", + "currentPermissions": "当前权限", + "newPermissions": "新权限", + "owner": "所有者", + "group": "组", + "others": "其他", + "read": "读取", + "write": "写入", + "execute": "执行", + "permissionsChangedSuccessfully": "权限修改成功", + "failedToChangePermissions": "权限修改失败" }, "tunnels": { "title": "SSH 隧道", diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index 72a56006..7c0e8208 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -16,6 +16,7 @@ import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; +import { PermissionsDialog } from "./components/PermissionsDialog"; import { Upload, FolderPlus, @@ -49,6 +50,7 @@ import { addFolderShortcut, getPinnedFiles, logActivity, + changeSSHPermissions, } from "@/ui/main-axios.ts"; import type { SidebarItem } from "./FileManagerSidebar"; @@ -146,6 +148,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const [createIntent, setCreateIntent] = useState(null); const [editingFile, setEditingFile] = useState(null); + const [permissionsDialogFile, setPermissionsDialogFile] = useState(null); const { selectedFiles, clearSelection, setSelection } = useFileSelection(); @@ -1180,6 +1183,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { setEditingFile(file); } + function handleOpenPermissionsDialog(file: FileItem) { + setPermissionsDialogFile(file); + } + + async function handleSavePermissions(file: FileItem, permissions: string) { + if (!sshSessionId) { + toast.error(t("fileManager.noSSHConnection")); + return; + } + + try { + await changeSSHPermissions( + sshSessionId, + file.path, + permissions, + currentHost?.id, + currentHost?.userId?.toString(), + ); + + toast.success(t("fileManager.permissionsChangedSuccessfully")); + await handleRefreshDirectory(); + } catch (error: unknown) { + console.error("Failed to change permissions:", error); + toast.error(t("fileManager.failedToChangePermissions")); + throw error; + } + } + async function ensureSSHConnection() { if (!sshSessionId || !currentHost || isReconnecting) return; @@ -1968,6 +1999,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { onAddShortcut={handleAddShortcut} isPinned={isPinnedFile} currentPath={currentPath} + onProperties={handleOpenPermissionsDialog} />
@@ -1993,6 +2025,15 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { }} /> )} + + { + if (!open) setPermissionsDialogFile(null); + }} + onSave={handleSavePermissions} + />
); } diff --git a/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx b/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx new file mode 100644 index 00000000..c07f9579 --- /dev/null +++ b/src/ui/desktop/apps/file-manager/components/PermissionsDialog.tsx @@ -0,0 +1,295 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { useTranslation } from "react-i18next"; +import { Shield } from "lucide-react"; + +interface FileItem { + name: string; + type: "file" | "directory" | "link"; + path: string; + permissions?: string; + owner?: string; + group?: string; +} + +interface PermissionsDialogProps { + file: FileItem | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (file: FileItem, permissions: string) => Promise; +} + +// Parse permissions like "rwxr-xr-x" or "755" to individual bits +const parsePermissions = (perms: string): { owner: number; group: number; other: number } => { + if (!perms) { + return { owner: 0, group: 0, other: 0 }; + } + + // If numeric format like "755" + if (/^\d{3,4}$/.test(perms)) { + const numStr = perms.slice(-3); + return { + owner: parseInt(numStr[0] || "0", 10), + group: parseInt(numStr[1] || "0", 10), + other: parseInt(numStr[2] || "0", 10), + }; + } + + // If symbolic format like "rwxr-xr-x" or "-rwxr-xr-x" + const cleanPerms = perms.replace(/^-/, "").substring(0, 9); + + const calcBits = (str: string): number => { + let value = 0; + if (str[0] === "r") value += 4; + if (str[1] === "w") value += 2; + if (str[2] === "x") value += 1; + return value; + }; + + return { + owner: calcBits(cleanPerms.substring(0, 3)), + group: calcBits(cleanPerms.substring(3, 6)), + other: calcBits(cleanPerms.substring(6, 9)), + }; +}; + +// Convert individual bits to numeric format +const toNumeric = (owner: number, group: number, other: number): string => { + return `${owner}${group}${other}`; +}; + +export function PermissionsDialog({ + file, + open, + onOpenChange, + onSave, +}: PermissionsDialogProps) { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + + const initialPerms = parsePermissions(file?.permissions || "644"); + const [ownerRead, setOwnerRead] = useState((initialPerms.owner & 4) !== 0); + const [ownerWrite, setOwnerWrite] = useState((initialPerms.owner & 2) !== 0); + const [ownerExecute, setOwnerExecute] = useState((initialPerms.owner & 1) !== 0); + + const [groupRead, setGroupRead] = useState((initialPerms.group & 4) !== 0); + const [groupWrite, setGroupWrite] = useState((initialPerms.group & 2) !== 0); + const [groupExecute, setGroupExecute] = useState((initialPerms.group & 1) !== 0); + + const [otherRead, setOtherRead] = useState((initialPerms.other & 4) !== 0); + const [otherWrite, setOtherWrite] = useState((initialPerms.other & 2) !== 0); + const [otherExecute, setOtherExecute] = useState((initialPerms.other & 1) !== 0); + + // Reset when file changes + useEffect(() => { + if (file) { + const perms = parsePermissions(file.permissions || "644"); + setOwnerRead((perms.owner & 4) !== 0); + setOwnerWrite((perms.owner & 2) !== 0); + setOwnerExecute((perms.owner & 1) !== 0); + setGroupRead((perms.group & 4) !== 0); + setGroupWrite((perms.group & 2) !== 0); + setGroupExecute((perms.group & 1) !== 0); + setOtherRead((perms.other & 4) !== 0); + setOtherWrite((perms.other & 2) !== 0); + setOtherExecute((perms.other & 1) !== 0); + } + }, [file]); + + const calculateOctal = (): string => { + const owner = (ownerRead ? 4 : 0) + (ownerWrite ? 2 : 0) + (ownerExecute ? 1 : 0); + const group = (groupRead ? 4 : 0) + (groupWrite ? 2 : 0) + (groupExecute ? 1 : 0); + const other = (otherRead ? 4 : 0) + (otherWrite ? 2 : 0) + (otherExecute ? 1 : 0); + return toNumeric(owner, group, other); + }; + + const handleSave = async () => { + if (!file) return; + + setLoading(true); + try { + const permissions = calculateOctal(); + await onSave(file, permissions); + onOpenChange(false); + } catch (error) { + console.error("Failed to update permissions:", error); + } finally { + setLoading(false); + } + }; + + if (!file) return null; + + const octal = calculateOctal(); + + return ( + + + + + + {t("fileManager.changePermissions")} + + + {t("fileManager.changePermissionsDesc")}: {file.name} + + + +
+ {/* Current info */} +
+
+ +

{file.permissions || "644"}

+
+
+ +

{octal}

+
+
+ + {/* Owner permissions */} +
+ +
+
+ setOwnerRead(checked === true)} + /> + +
+
+ setOwnerWrite(checked === true)} + /> + +
+
+ setOwnerExecute(checked === true)} + /> + +
+
+
+ + {/* Group permissions */} +
+ +
+
+ setGroupRead(checked === true)} + /> + +
+
+ setGroupWrite(checked === true)} + /> + +
+
+ setGroupExecute(checked === true)} + /> + +
+
+
+ + {/* Others permissions */} +
+ +
+
+ setOtherRead(checked === true)} + /> + +
+
+ setOtherWrite(checked === true)} + /> + +
+
+ setOtherExecute(checked === true)} + /> + +
+
+
+
+ + + + + +
+
+ ); +} diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index f27aa014..4c147071 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1515,6 +1515,51 @@ export async function moveSSHItem( } } +export async function changeSSHPermissions( + sessionId: string, + path: string, + permissions: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string }> { + try { + fileLogger.info("Changing SSH file permissions", { + operation: "change_permissions", + sessionId, + path, + permissions, + hostId, + userId, + }); + + const response = await fileManagerApi.post("/ssh/changePermissions", { + sessionId, + path, + permissions, + hostId, + userId, + }); + + fileLogger.success("SSH file permissions changed successfully", { + operation: "change_permissions", + sessionId, + path, + permissions, + }); + + return response.data; + } catch (error) { + fileLogger.error("Failed to change SSH file permissions", error, { + operation: "change_permissions", + sessionId, + path, + permissions, + }); + handleApiError(error, "change SSH permissions"); + throw error; + } +} + // ============================================================================ // FILE MANAGER DATA // ============================================================================ -- 2.49.1 From b5fe073cd8b6dc1aaf8abd4fd458ca168f14f1bc Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 15:52:56 +0800 Subject: [PATCH 30/42] feat: Add folder customization and batch delete for SSH host manager Allow users to customize folder appearance and manage hosts in bulk: Database & Types: - Add sshFolders table with color and icon fields for folder metadata - Add SSHFolder interface to types with userId, name, color, icon fields - Update folder rename route to also update folder metadata API Endpoints: - GET /ssh/folders - Fetch all folders with metadata for user - PUT /ssh/folders/metadata - Create or update folder color and icon - DELETE /ssh/folders/:name/hosts - Delete all hosts in a folder and folder metadata Frontend Features: - Create FolderEditDialog component with color picker (8 colors) and icon selector (10 icons) - Add folder metadata state management in HostManagerViewer - Display custom folder colors and icons in host manager UI - Add "Edit Folder Appearance" button with palette icon - Add "Delete All Hosts in Folder" button with trash icon and confirmation - Fetch and sync folder metadata on component mount and refresh API Functions: - getSSHFolders() - Retrieve all folder metadata - updateFolderMetadata(name, color, icon) - Update folder appearance - deleteAllHostsInFolder(folderName) - Batch delete with count return i18n Support: - Add translations for folder customization (en, zh) - Add batch delete confirmation messages - Add success/error toast messages --- src/backend/database/db/schema.ts | 16 ++ src/backend/database/routes/ssh.ts | 163 +++++++++++++++ src/locales/en/translation.json | 11 + src/locales/zh/translation.json | 11 + src/types/index.ts | 10 + .../apps/host-manager/HostManagerViewer.tsx | 171 +++++++++++++++- .../components/FolderEditDialog.tsx | 189 ++++++++++++++++++ src/ui/main-axios.ts | 85 ++++++++ 8 files changed, 655 insertions(+), 1 deletion(-) create mode 100644 src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 86af0d02..2e5ed460 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -209,6 +209,22 @@ export const snippets = sqliteTable("snippets", { .default(sql`CURRENT_TIMESTAMP`), }); +export const sshFolders = sqliteTable("ssh_folders", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + name: text("name").notNull(), + color: text("color"), + icon: text("icon"), + createdAt: text("created_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), + updatedAt: text("updated_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); + export const recentActivity = sqliteTable("recent_activity", { id: integer("id").primaryKey({ autoIncrement: true }), userId: text("user_id") diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 8e9cf570..cc33b7a8 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -8,6 +8,7 @@ import { fileManagerRecent, fileManagerPinned, fileManagerShortcuts, + sshFolders, } from "../db/schema.js"; import { eq, and, desc, isNotNull, or } from "drizzle-orm"; import type { Request, Response } from "express"; @@ -1341,6 +1342,17 @@ router.put( DatabaseSaveTrigger.triggerSave("folder_rename"); + // Also update folder metadata if exists + await db + .update(sshFolders) + .set({ + name: newName, + updatedAt: new Date().toISOString(), + }) + .where( + and(eq(sshFolders.userId, userId), eq(sshFolders.name, oldName)), + ); + res.json({ message: "Folder renamed successfully", updatedHosts: updatedHosts.length, @@ -1358,6 +1370,157 @@ router.put( }, ); +// Route: Get all folders with metadata (requires JWT) +// GET /ssh/db/folders +router.get( + "/folders", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + + if (!isNonEmptyString(userId)) { + return res.status(400).json({ error: "Invalid user ID" }); + } + + try { + const folders = await db + .select() + .from(sshFolders) + .where(eq(sshFolders.userId, userId)); + + res.json(folders); + } catch (err) { + sshLogger.error("Failed to fetch folders", err, { + operation: "fetch_folders", + userId, + }); + res.status(500).json({ error: "Failed to fetch folders" }); + } + }, +); + +// Route: Update folder metadata (requires JWT) +// PUT /ssh/db/folders/metadata +router.put( + "/folders/metadata", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { name, color, icon } = req.body; + + if (!isNonEmptyString(userId) || !name) { + return res.status(400).json({ error: "Folder name is required" }); + } + + try { + // Check if folder metadata exists + const existing = await db + .select() + .from(sshFolders) + .where(and(eq(sshFolders.userId, userId), eq(sshFolders.name, name))) + .limit(1); + + if (existing.length > 0) { + // Update existing + await db + .update(sshFolders) + .set({ + color, + icon, + updatedAt: new Date().toISOString(), + }) + .where( + and(eq(sshFolders.userId, userId), eq(sshFolders.name, name)), + ); + } else { + // Create new + await db.insert(sshFolders).values({ + userId, + name, + color, + icon, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + } + + DatabaseSaveTrigger.triggerSave("folder_metadata_update"); + + res.json({ message: "Folder metadata updated successfully" }); + } catch (err) { + sshLogger.error("Failed to update folder metadata", err, { + operation: "update_folder_metadata", + userId, + name, + }); + res.status(500).json({ error: "Failed to update folder metadata" }); + } + }, +); + +// Route: Delete all hosts in folder (requires JWT) +// DELETE /ssh/db/folders/:name/hosts +router.delete( + "/folders/:name/hosts", + authenticateJWT, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const folderName = req.params.name; + + if (!isNonEmptyString(userId) || !folderName) { + return res.status(400).json({ error: "Invalid folder name" }); + } + + try { + // Get all hosts in the folder + const hostsToDelete = await db + .select() + .from(sshData) + .where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName))); + + if (hostsToDelete.length === 0) { + return res.json({ + message: "No hosts found in folder", + deletedCount: 0, + }); + } + + // Delete all hosts + await db + .delete(sshData) + .where(and(eq(sshData.userId, userId), eq(sshData.folder, folderName))); + + // Delete folder metadata + await db + .delete(sshFolders) + .where( + and(eq(sshFolders.userId, userId), eq(sshFolders.name, folderName)), + ); + + DatabaseSaveTrigger.triggerSave("folder_hosts_delete"); + + sshLogger.info("Deleted all hosts in folder", { + operation: "delete_folder_hosts", + userId, + folderName, + deletedCount: hostsToDelete.length, + }); + + res.json({ + message: "All hosts in folder deleted successfully", + deletedCount: hostsToDelete.length, + }); + } catch (err) { + sshLogger.error("Failed to delete hosts in folder", err, { + operation: "delete_folder_hosts", + userId, + folderName, + }); + res.status(500).json({ error: "Failed to delete hosts in folder" }); + } + }, +); + // Route: Bulk import SSH hosts (requires JWT) // POST /ssh/bulk-import router.post( diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 485c2293..51e96e4e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -754,6 +754,17 @@ "failedToRemoveFromFolder": "Failed to remove host from folder", "folderRenamed": "Folder \"{{oldName}}\" renamed to \"{{newName}}\" successfully", "failedToRenameFolder": "Failed to rename folder", + "editFolderAppearance": "Edit Folder Appearance", + "editFolderAppearanceDesc": "Customize the color and icon for folder", + "folderColor": "Folder Color", + "folderIcon": "Folder Icon", + "preview": "Preview", + "folderAppearanceUpdated": "Folder appearance updated successfully", + "failedToUpdateFolderAppearance": "Failed to update folder appearance", + "deleteAllHostsInFolder": "Delete All Hosts in Folder", + "confirmDeleteAllHostsInFolder": "Are you sure you want to delete all {{count}} hosts in folder \"{{folder}}\"? This action cannot be undone.", + "allHostsInFolderDeleted": "Deleted {{count}} hosts from folder \"{{folder}}\" successfully", + "failedToDeleteHostsInFolder": "Failed to delete hosts in folder", "movedToFolder": "Host \"{{name}}\" moved to \"{{folder}}\" successfully", "failedToMoveToFolder": "Failed to move host to folder", "statistics": "Statistics", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 9150f81d..00202b57 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -766,6 +766,17 @@ "failedToRemoveFromFolder": "从文件夹中移除主机失败", "folderRenamed": "文件夹\"{{oldName}}\"已成功重命名为\"{{newName}}\"", "failedToRenameFolder": "重命名文件夹失败", + "editFolderAppearance": "编辑文件夹外观", + "editFolderAppearanceDesc": "自定义文件夹的颜色和图标", + "folderColor": "文件夹颜色", + "folderIcon": "文件夹图标", + "preview": "预览", + "folderAppearanceUpdated": "文件夹外观更新成功", + "failedToUpdateFolderAppearance": "更新文件夹外观失败", + "deleteAllHostsInFolder": "删除文件夹内所有主机", + "confirmDeleteAllHostsInFolder": "确定要删除文件夹\"{{folder}}\"中的全部 {{count}} 个主机吗?此操作无法撤销。", + "allHostsInFolderDeleted": "已成功从文件夹\"{{folder}}\"删除 {{count}} 个主机", + "failedToDeleteHostsInFolder": "删除文件夹中的主机失败", "movedToFolder": "主机\"{{name}}\"已成功移动到\"{{folder}}\"", "failedToMoveToFolder": "移动主机到文件夹失败", "statistics": "统计", diff --git a/src/types/index.ts b/src/types/index.ts index 027de232..cd8a3bc0 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -62,6 +62,16 @@ export interface SSHHostData { terminalConfig?: TerminalConfig; } +export interface SSHFolder { + id: number; + userId: string; + name: string; + color?: string; + icon?: string; + createdAt: string; + updatedAt: string; +} + // ============================================================================ // CREDENTIAL TYPES // ============================================================================ diff --git a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx index c438b447..ef750f0c 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx @@ -22,6 +22,9 @@ import { updateSSHHost, renameFolder, exportSSHHostWithCredentials, + getSSHFolders, + updateFolderMetadata, + deleteAllHostsInFolder, } from "@/ui/main-axios.ts"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; @@ -45,12 +48,24 @@ import { Copy, Activity, Clock, + Palette, + Trash, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, } from "lucide-react"; import type { SSHHost, + SSHFolder, SSHManagerHostViewerProps, } from "../../../../types/index.js"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; +import { FolderEditDialog } from "./components/FolderEditDialog"; export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { const { t } = useTranslation(); @@ -65,13 +80,17 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { const [editingFolder, setEditingFolder] = useState(null); const [editingFolderName, setEditingFolderName] = useState(""); const [operationLoading, setOperationLoading] = useState(false); + const [folderMetadata, setFolderMetadata] = useState>(new Map()); + const [editingFolderAppearance, setEditingFolderAppearance] = useState(null); const dragCounter = useRef(0); useEffect(() => { fetchHosts(); + fetchFolderMetadata(); const handleHostsRefresh = () => { fetchHosts(); + fetchFolderMetadata(); }; window.addEventListener("hosts:refresh", handleHostsRefresh); @@ -116,6 +135,87 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { } }; + const fetchFolderMetadata = async () => { + try { + const folders = await getSSHFolders(); + const metadataMap = new Map(); + folders.forEach((folder) => { + metadataMap.set(folder.name, folder); + }); + setFolderMetadata(metadataMap); + } catch (error) { + console.error("Failed to fetch folder metadata:", error); + } + }; + + const handleSaveFolderAppearance = async (folderName: string, color: string, icon: string) => { + try { + await updateFolderMetadata(folderName, color, icon); + toast.success(t("hosts.folderAppearanceUpdated")); + await fetchFolderMetadata(); + window.dispatchEvent(new CustomEvent("folders:changed")); + } catch (error) { + console.error("Failed to update folder appearance:", error); + toast.error(t("hosts.failedToUpdateFolderAppearance")); + } + }; + + const handleDeleteAllHostsInFolder = async (folderName: string) => { + const hostsInFolder = hostsByFolder[folderName] || []; + confirmWithToast( + t("hosts.confirmDeleteAllHostsInFolder", { + folder: folderName, + count: hostsInFolder.length, + }), + async () => { + try { + const result = await deleteAllHostsInFolder(folderName); + toast.success( + t("hosts.allHostsInFolderDeleted", { + folder: folderName, + count: result.deletedCount, + }) + ); + await fetchHosts(); + await fetchFolderMetadata(); + window.dispatchEvent(new CustomEvent("ssh-hosts:changed")); + + const { refreshServerPolling } = await import("@/ui/main-axios.ts"); + refreshServerPolling(); + } catch (error) { + console.error("Failed to delete hosts in folder:", error); + toast.error(t("hosts.failedToDeleteHostsInFolder")); + } + }, + "destructive", + ); + }; + + const getFolderIcon = (folderName: string) => { + const metadata = folderMetadata.get(folderName); + if (!metadata?.icon) return Folder; + + const iconMap: Record = { + Folder, + Server, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, + }; + + return iconMap[metadata.icon] || Folder; + }; + + const getFolderColor = (folderName: string) => { + const metadata = folderMetadata.get(folderName); + return metadata?.color; + }; + const handleDelete = async (hostId: number, hostName: string) => { confirmWithToast( t("hosts.confirmDelete", { name: hostName }), @@ -854,7 +954,16 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
- + {(() => { + const FolderIcon = getFolderIcon(folder); + const folderColor = getFolderColor(folder); + return ( + + ); + })()} {editingFolder === folder ? (
{folderHosts.length} + {folder !== t("hosts.uncategorized") && ( +
+ + + + + + + {t("hosts.editFolderAppearance")} + + + + + + + + + + {t("hosts.deleteAllHostsInFolder")} + + + +
+ )}
@@ -1202,6 +1355,22 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { ))}
+ + {editingFolderAppearance && ( + { + if (!open) setEditingFolderAppearance(null); + }} + onSave={async (color, icon) => { + await handleSaveFolderAppearance(editingFolderAppearance, color, icon); + setEditingFolderAppearance(null); + }} + /> + )}
); } diff --git a/src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx b/src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx new file mode 100644 index 00000000..d60eabcf --- /dev/null +++ b/src/ui/desktop/apps/host-manager/components/FolderEditDialog.tsx @@ -0,0 +1,189 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { useTranslation } from "react-i18next"; +import { + Folder, + Server, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, +} from "lucide-react"; + +interface FolderEditDialogProps { + folderName: string; + currentColor?: string; + currentIcon?: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onSave: (color: string, icon: string) => Promise; +} + +const AVAILABLE_COLORS = [ + { value: "#ef4444", label: "Red" }, + { value: "#f97316", label: "Orange" }, + { value: "#eab308", label: "Yellow" }, + { value: "#22c55e", label: "Green" }, + { value: "#3b82f6", label: "Blue" }, + { value: "#a855f7", label: "Purple" }, + { value: "#ec4899", label: "Pink" }, + { value: "#6b7280", label: "Gray" }, +]; + +const AVAILABLE_ICONS = [ + { value: "Folder", label: "Folder", Icon: Folder }, + { value: "Server", label: "Server", Icon: Server }, + { value: "Cloud", label: "Cloud", Icon: Cloud }, + { value: "Database", label: "Database", Icon: Database }, + { value: "Box", label: "Box", Icon: Box }, + { value: "Package", label: "Package", Icon: Package }, + { value: "Layers", label: "Layers", Icon: Layers }, + { value: "Archive", label: "Archive", Icon: Archive }, + { value: "HardDrive", label: "HardDrive", Icon: HardDrive }, + { value: "Globe", label: "Globe", Icon: Globe }, +]; + +export function FolderEditDialog({ + folderName, + currentColor, + currentIcon, + open, + onOpenChange, + onSave, +}: FolderEditDialogProps) { + const { t } = useTranslation(); + const [selectedColor, setSelectedColor] = useState(currentColor || AVAILABLE_COLORS[0].value); + const [selectedIcon, setSelectedIcon] = useState(currentIcon || AVAILABLE_ICONS[0].value); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (open) { + setSelectedColor(currentColor || AVAILABLE_COLORS[0].value); + setSelectedIcon(currentIcon || AVAILABLE_ICONS[0].value); + } + }, [open, currentColor, currentIcon]); + + const handleSave = async () => { + setLoading(true); + try { + await onSave(selectedColor, selectedIcon); + onOpenChange(false); + } catch (error) { + console.error("Failed to save folder metadata:", error); + } finally { + setLoading(false); + } + }; + + return ( + + + + + + {t("hosts.editFolderAppearance")} + + + {t("hosts.editFolderAppearanceDesc")}: {folderName} + + + +
+ {/* Color Selection */} +
+ +
+ {AVAILABLE_COLORS.map((color) => ( +
+
+ + {/* Icon Selection */} +
+ +
+ {AVAILABLE_ICONS.map(({ value, label, Icon }) => ( + + ))} +
+
+ + {/* Preview */} +
+ +
+ {(() => { + const IconComponent = AVAILABLE_ICONS.find( + (i) => i.value === selectedIcon + )?.Icon || Folder; + return ( + + ); + })()} + {folderName} +
+
+
+ + + + + +
+
+ ); +} diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 4c147071..7d9e6342 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -2,6 +2,7 @@ import axios, { AxiosError, type AxiosInstance } from "axios"; import type { SSHHost, SSHHostData, + SSHFolder, TunnelConfig, TunnelStatus, FileManagerFile, @@ -2451,6 +2452,90 @@ export async function renameFolder( } } +export async function getSSHFolders(): Promise { + try { + sshLogger.info("Fetching SSH folders", { + operation: "fetch_ssh_folders", + }); + + const response = await authApi.get("/ssh/folders"); + + sshLogger.success("SSH folders fetched successfully", { + operation: "fetch_ssh_folders", + count: response.data.length, + }); + + return response.data; + } catch (error) { + sshLogger.error("Failed to fetch SSH folders", error, { + operation: "fetch_ssh_folders", + }); + handleApiError(error, "fetch SSH folders"); + throw error; + } +} + +export async function updateFolderMetadata( + name: string, + color?: string, + icon?: string, +): Promise { + try { + sshLogger.info("Updating folder metadata", { + operation: "update_folder_metadata", + name, + color, + icon, + }); + + await authApi.put("/ssh/folders/metadata", { + name, + color, + icon, + }); + + sshLogger.success("Folder metadata updated successfully", { + operation: "update_folder_metadata", + name, + }); + } catch (error) { + sshLogger.error("Failed to update folder metadata", error, { + operation: "update_folder_metadata", + name, + }); + handleApiError(error, "update folder metadata"); + throw error; + } +} + +export async function deleteAllHostsInFolder( + folderName: string, +): Promise<{ deletedCount: number }> { + try { + sshLogger.info("Deleting all hosts in folder", { + operation: "delete_folder_hosts", + folderName, + }); + + const response = await authApi.delete(`/ssh/folders/${encodeURIComponent(folderName)}/hosts`); + + sshLogger.success("All hosts in folder deleted successfully", { + operation: "delete_folder_hosts", + folderName, + deletedCount: response.data.deletedCount, + }); + + return response.data; + } catch (error) { + sshLogger.error("Failed to delete hosts in folder", error, { + operation: "delete_folder_hosts", + folderName, + }); + handleApiError(error, "delete hosts in folder"); + throw error; + } +} + export async function renameCredentialFolder( oldName: string, newName: string, -- 2.49.1 From af23882f5096933765a20da34f4618d524eebd84 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 16:14:47 +0800 Subject: [PATCH 31/42] fix: Add ssh_folders table to database initialization Adds the CREATE TABLE statement for ssh_folders table to ensure it's created when the database is initialized. This fixes the 'no such table: ssh_folders' error that occurs on backend restart. --- src/backend/database/db/index.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 8310ed6c..d735e6b7 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -300,6 +300,17 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (user_id) REFERENCES users (id) ); + CREATE TABLE IF NOT EXISTS ssh_folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + name TEXT NOT NULL, + color TEXT, + icon TEXT, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id) + ); + CREATE TABLE IF NOT EXISTS recent_activity ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, -- 2.49.1 From c63ec9d8be916380705e7c33c479baeb4309e1e0 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 16:18:55 +0800 Subject: [PATCH 32/42] feat: Add folder customization support to sidebar Updates the left sidebar to display custom folder colors and icons: - FolderCard component now accepts folderColor and folderIcon props - LeftSidebar fetches folder metadata and passes to FolderCard - Supports same 10 icons as host manager (Folder, Server, Cloud, Database, Box, Package, Layers, Archive, HardDrive, Globe) - Folder metadata refreshes with host data and on ssh-hosts:changed events This ensures the sidebar reflects folder appearance changes made in the host manager. --- src/ui/desktop/navigation/LeftSidebar.tsx | 51 ++++++++++++++----- .../desktop/navigation/hosts/FolderCard.tsx | 39 +++++++++++++- 2 files changed, 75 insertions(+), 15 deletions(-) diff --git a/src/ui/desktop/navigation/LeftSidebar.tsx b/src/ui/desktop/navigation/LeftSidebar.tsx index 15b0e812..9669b788 100644 --- a/src/ui/desktop/navigation/LeftSidebar.tsx +++ b/src/ui/desktop/navigation/LeftSidebar.tsx @@ -34,8 +34,9 @@ import { import { Input } from "@/components/ui/input.tsx"; import { Button } from "@/components/ui/button.tsx"; import { FolderCard } from "@/ui/desktop/navigation/hosts/FolderCard.tsx"; -import { getSSHHosts } from "@/ui/main-axios.ts"; +import { getSSHHosts, getSSHFolders } from "@/ui/main-axios.ts"; import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext.tsx"; +import type { SSHFolder } from "@/types/index.ts"; interface SSHHost { id: number; @@ -145,6 +146,20 @@ export function LeftSidebar({ const prevHostsRef = React.useRef([]); const [search, setSearch] = useState(""); const [debouncedSearch, setDebouncedSearch] = useState(""); + const [folderMetadata, setFolderMetadata] = useState>(new Map()); + + const fetchFolderMetadata = React.useCallback(async () => { + try { + const folders = await getSSHFolders(); + const metadataMap = new Map(); + folders.forEach((folder) => { + metadataMap.set(folder.name, folder); + }); + setFolderMetadata(metadataMap); + } catch (error) { + console.error("Failed to fetch folder metadata:", error); + } + }, []); const fetchHosts = React.useCallback(async () => { try { @@ -210,13 +225,18 @@ export function LeftSidebar({ React.useEffect(() => { fetchHosts(); - const interval = setInterval(fetchHosts, 300000); + fetchFolderMetadata(); + const interval = setInterval(() => { + fetchHosts(); + fetchFolderMetadata(); + }, 300000); return () => clearInterval(interval); - }, [fetchHosts]); + }, [fetchHosts, fetchFolderMetadata]); React.useEffect(() => { const handleHostsChanged = () => { fetchHosts(); + fetchFolderMetadata(); }; const handleCredentialsChanged = () => { fetchHosts(); @@ -239,7 +259,7 @@ export function LeftSidebar({ handleCredentialsChanged as EventListener, ); }; - }, [fetchHosts]); + }, [fetchHosts, fetchFolderMetadata]); React.useEffect(() => { const handler = setTimeout(() => setDebouncedSearch(search), 200); @@ -437,15 +457,20 @@ export function LeftSidebar({
)} - {sortedFolders.map((folder, idx) => ( - - ))} + {sortedFolders.map((folder, idx) => { + const metadata = folderMetadata.get(folder); + return ( + + ); + })} diff --git a/src/ui/desktop/navigation/hosts/FolderCard.tsx b/src/ui/desktop/navigation/hosts/FolderCard.tsx index 82b0dc38..67451273 100644 --- a/src/ui/desktop/navigation/hosts/FolderCard.tsx +++ b/src/ui/desktop/navigation/hosts/FolderCard.tsx @@ -1,6 +1,18 @@ import React, { useState } from "react"; import { CardTitle } from "@/components/ui/card.tsx"; -import { ChevronDown, Folder } from "lucide-react"; +import { + ChevronDown, + Folder, + Server, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, +} from "lucide-react"; import { Button } from "@/components/ui/button.tsx"; import { Host } from "@/ui/desktop/navigation/hosts/Host.tsx"; import { Separator } from "@/components/ui/separator.tsx"; @@ -40,11 +52,15 @@ interface FolderCardProps { hosts: SSHHost[]; isFirst: boolean; isLast: boolean; + folderColor?: string; + folderIcon?: string; } export function FolderCard({ folderName, hosts, + folderColor, + folderIcon, }: FolderCardProps): React.ReactElement { const [isExpanded, setIsExpanded] = useState(true); @@ -52,6 +68,21 @@ export function FolderCard({ setIsExpanded(!isExpanded); }; + const iconMap: Record> = { + Folder, + Server, + Cloud, + Database, + Box, + Package, + Layers, + Archive, + HardDrive, + Globe, + }; + + const FolderIcon = folderIcon && iconMap[folderIcon] ? iconMap[folderIcon] : Folder; + return (
- +
-- 2.49.1 From 82f1e043aeb4ad59a8eead320b635758ead01fc9 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 16:26:08 +0800 Subject: [PATCH 33/42] feat: Add direct SSH connection, file management, and server status monitoring to host manager Adds inline quick actions to host manager cards for efficient multi-host management: Server Status Monitoring: - Real-time status indicators (online/offline/degraded) for each host card - Status updates every 10 seconds - Respects per-host status check configuration - Uses same status logic as sidebar host components Quick Action Buttons: - Terminal: Opens SSH terminal session (when enableTerminal is true) - File Manager: Opens file browser (when enableFileManager is true) - Server Details: Opens server monitoring dashboard (always available) - Buttons appear in bottom section of each host card - Hover effects with color-coded borders (blue/emerald/purple) Benefits: - Enables centralized monitoring and management of multiple hosts - Reduces clicks needed to access common operations - Provides at-a-glance status overview in grid view - Maintains consistent UX with sidebar host components --- .../apps/host-manager/HostManagerViewer.tsx | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx index ef750f0c..0e606bad 100644 --- a/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx +++ b/src/ui/desktop/apps/host-manager/HostManagerViewer.tsx @@ -25,10 +25,12 @@ import { getSSHFolders, updateFolderMetadata, deleteAllHostsInFolder, + getServerStatusById, } from "@/ui/main-axios.ts"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { useConfirmation } from "@/hooks/use-confirmation.ts"; +import { Status, StatusIndicator } from "@/components/ui/shadcn-io/status"; import { Edit, Trash2, @@ -58,6 +60,7 @@ import { Archive, HardDrive, Globe, + FolderOpen, } from "lucide-react"; import type { SSHHost, @@ -66,10 +69,12 @@ import type { } from "../../../../types/index.js"; import { DEFAULT_STATS_CONFIG } from "@/types/stats-widgets"; import { FolderEditDialog } from "./components/FolderEditDialog"; +import { useTabs } from "@/ui/desktop/navigation/tabs/TabContext"; export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { const { t } = useTranslation(); const { confirmWithToast } = useConfirmation(); + const { addTab } = useTabs(); const [hosts, setHosts] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -82,6 +87,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { const [operationLoading, setOperationLoading] = useState(false); const [folderMetadata, setFolderMetadata] = useState>(new Map()); const [editingFolderAppearance, setEditingFolderAppearance] = useState(null); + const [serverStatuses, setServerStatuses] = useState>(new Map()); const dragCounter = useRef(0); useEffect(() => { @@ -191,6 +197,71 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { ); }; + useEffect(() => { + if (hosts.length === 0) return; + + const statusIntervals: NodeJS.Timeout[] = []; + const statusCancelled: boolean[] = []; + + hosts.forEach((host, index) => { + const statsConfig = (() => { + try { + return host.statsConfig + ? JSON.parse(host.statsConfig) + : DEFAULT_STATS_CONFIG; + } catch { + return DEFAULT_STATS_CONFIG; + } + })(); + + const shouldShowStatus = statsConfig.statusCheckEnabled !== false; + + if (!shouldShowStatus) { + setServerStatuses((prev) => { + const next = new Map(prev); + next.set(host.id, "offline"); + return next; + }); + return; + } + + const fetchStatus = async () => { + try { + const res = await getServerStatusById(host.id); + if (!statusCancelled[index]) { + setServerStatuses((prev) => { + const next = new Map(prev); + next.set(host.id, res?.status === "online" ? "online" : "offline"); + return next; + }); + } + } catch (error: unknown) { + if (!statusCancelled[index]) { + const err = error as { response?: { status?: number } }; + let status: "online" | "offline" | "degraded" = "offline"; + if (err?.response?.status === 504) { + status = "degraded"; + } + setServerStatuses((prev) => { + const next = new Map(prev); + next.set(host.id, status); + return next; + }); + } + } + }; + + fetchStatus(); + const intervalId = setInterval(fetchStatus, 10000); + statusIntervals.push(intervalId); + }); + + return () => { + statusCancelled.fill(true); + statusIntervals.forEach((interval) => clearInterval(interval)); + }; + }, [hosts]); + const getFolderIcon = (folderName: string) => { const metadata = folderMetadata.get(folderName); if (!metadata?.icon) return Folder; @@ -1110,6 +1181,28 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) {
+ {(() => { + const statsConfig = (() => { + try { + return host.statsConfig + ? JSON.parse(host.statsConfig) + : DEFAULT_STATS_CONFIG; + } catch { + return DEFAULT_STATS_CONFIG; + } + })(); + const shouldShowStatus = statsConfig.statusCheckEnabled !== false; + const serverStatus = serverStatuses.get(host.id) || "degraded"; + + return shouldShowStatus ? ( + + + + ) : null; + })()} {host.pin && ( )} @@ -1332,6 +1425,76 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { })()}
+ +
+ {host.enableTerminal && ( + + + + + +

Open Terminal

+
+
+ )} + {host.enableFileManager && ( + + + + + +

Open File Manager

+
+
+ )} + + + + + +

Open Server Details

+
+
+
-- 2.49.1 From 2eb6c26c42cd1c24924370cb9c1ec1dacc928215 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 17:38:21 +0800 Subject: [PATCH 34/42] fix: Allow Host Manager button to switch to existing tab instead of being disabled Changes Host Manager button behavior to match Admin and User Profile tabs: - Button is always clickable (except during split screen mode) - If Host Manager tab already exists, clicking switches to it - If tab doesn't exist, creates new tab and switches to it - Removes 'Already Open' disabled state and tooltip This improves UX by allowing users to quickly return to Host Manager tab without needing to manually find and click the tab. --- src/ui/desktop/navigation/LeftSidebar.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/ui/desktop/navigation/LeftSidebar.tsx b/src/ui/desktop/navigation/LeftSidebar.tsx index 9669b788..faa0cae9 100644 --- a/src/ui/desktop/navigation/LeftSidebar.tsx +++ b/src/ui/desktop/navigation/LeftSidebar.tsx @@ -115,7 +115,11 @@ export function LeftSidebar({ Array.isArray(allSplitScreenTab) && allSplitScreenTab.length > 0; const sshManagerTab = tabList.find((t) => t.type === "ssh_manager"); const openSshManagerTab = () => { - if (sshManagerTab || isSplitScreenActive) return; + if (isSplitScreenActive) return; + if (sshManagerTab) { + setCurrentTab(sshManagerTab.id); + return; + } const id = addTab({ type: "ssh_manager", title: "Host Manager" }); setCurrentTab(id); }; @@ -416,13 +420,11 @@ export function LeftSidebar({ className="m-2 flex flex-row font-semibold border-2 !border-dark-border" variant="outline" onClick={openSshManagerTab} - disabled={!!sshManagerTab || isSplitScreenActive} + disabled={isSplitScreenActive} title={ - sshManagerTab - ? t("interface.sshManagerAlreadyOpen") - : isSplitScreenActive - ? t("interface.disabledDuringSplitScreen") - : undefined + isSplitScreenActive + ? t("interface.disabledDuringSplitScreen") + : undefined } > -- 2.49.1 From 303386806fff18df6d78b61d476428de0cd0e453 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 17:45:44 +0800 Subject: [PATCH 35/42] feat: Add drag-and-drop split screen functionality for tabs Implements intuitive drag-and-drop split screen feature for tabs: Core Functionality: - Drag a splittable tab (Terminal, File Manager, or Server) onto another splittable tab - Automatically triggers split screen mode for the dragged tab - Target tab becomes the active focused tab - Works alongside existing tab reordering drag-and-drop Visual Feedback: - Valid drop targets show blue border (border-blue-400/50) when dragging begins - Hovered drop target shows enhanced highlight with blue ring and background - Clear visual distinction between reorder drag vs split screen drop - Smooth transitions and hover states Smart Detection: - Only allows split screen between compatible tab types (terminal/server/file_manager) - Prevents splitting onto tabs already in split screen mode - Respects 3-tab split screen limit - Automatically falls back to reorder if dropped between tabs instead of on a tab Implementation Details: - Added hoverTabIndex to dragState to track which tab is being hovered - New findHoveredTab() function detects mouse position over tabs - Modified handleDrop() to prioritize split screen over reorder - Added isValidDropTarget and isHoveredDropTarget props to Tab component - Updated Tab.tsx styles with conditional classes for drop target states User Experience: - Drag tab to empty space between tabs = reorder (existing behavior) - Drag tab directly onto another tab = split screen (new behavior) - Visual feedback guides user to valid drop targets - Seamless integration with existing tab management --- src/ui/desktop/navigation/TopNavbar.tsx | 101 ++++++++++++++++++++++++ src/ui/desktop/navigation/tabs/Tab.tsx | 13 +++ 2 files changed, 114 insertions(+) diff --git a/src/ui/desktop/navigation/TopNavbar.tsx b/src/ui/desktop/navigation/TopNavbar.tsx index 2ad540f8..aa820f71 100644 --- a/src/ui/desktop/navigation/TopNavbar.tsx +++ b/src/ui/desktop/navigation/TopNavbar.tsx @@ -66,12 +66,14 @@ export function TopNavbar({ currentX: number; startX: number; targetIndex: number | null; + hoverTabIndex: number | null; }>({ draggedId: null, draggedIndex: null, currentX: 0, startX: 0, targetIndex: null, + hoverTabIndex: null, }); const containerRef = React.useRef(null); const tabRefs = React.useRef>(new Map()); @@ -123,6 +125,7 @@ export function TopNavbar({ startX: e.clientX, currentX: e.clientX, targetIndex: index, + hoverTabIndex: null, }); }; @@ -207,6 +210,22 @@ export function TopNavbar({ return newTargetIndex; }; + const findHoveredTab = (clientX: number, clientY: number): number | null => { + for (const [index, tabEl] of tabRefs.current.entries()) { + if (!tabEl) continue; + const rect = tabEl.getBoundingClientRect(); + if ( + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom + ) { + return index; + } + } + return null; + }; + const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); @@ -222,6 +241,14 @@ export function TopNavbar({ })); } + const hoveredTabIndex = findHoveredTab(e.clientX, e.clientY); + if (hoveredTabIndex !== dragState.hoverTabIndex) { + setDragState((prev) => ({ + ...prev, + hoverTabIndex: hoveredTabIndex, + })); + } + const newTargetIndex = calculateTargetIndex(); if (newTargetIndex !== null && newTargetIndex !== dragState.targetIndex) { setDragState((prev) => ({ @@ -240,7 +267,57 @@ export function TopNavbar({ const fromIndex = dragState.draggedIndex; const toIndex = dragState.targetIndex; const draggedId = dragState.draggedId; + const hoverTabIndex = dragState.hoverTabIndex; + // Check if dropping onto another tab for split screen + if ( + fromIndex !== null && + hoverTabIndex !== null && + fromIndex !== hoverTabIndex && + draggedId !== null + ) { + const draggedTab = tabs[fromIndex]; + const targetTab = tabs[hoverTabIndex]; + + const isDraggedSplittable = + draggedTab.type === "terminal" || + draggedTab.type === "server" || + draggedTab.type === "file_manager"; + const isTargetSplittable = + targetTab.type === "terminal" || + targetTab.type === "server" || + targetTab.type === "file_manager"; + + // Both tabs must be splittable and target must not already be in split screen + if ( + isDraggedSplittable && + isTargetSplittable && + !allSplitScreenTab.includes(targetTab.id) && + allSplitScreenTab.length < 3 + ) { + // Trigger split screen for the dragged tab + setSplitScreenTab(draggedId); + setCurrentTab(targetTab.id); + + setDragState({ + draggedId: null, + draggedIndex: null, + startX: 0, + currentX: 0, + targetIndex: null, + hoverTabIndex: null, + }); + + setTimeout(() => { + isProcessingDropRef.current = false; + setIsInDropAnimation(false); + }, 50); + + return; + } + } + + // Original reorder logic if (fromIndex !== null && toIndex !== null && fromIndex !== toIndex) { prevTabsRef.current = tabs; @@ -252,6 +329,7 @@ export function TopNavbar({ startX: 0, currentX: 0, targetIndex: null, + hoverTabIndex: null, }); }); @@ -267,6 +345,7 @@ export function TopNavbar({ startX: 0, currentX: 0, targetIndex: null, + hoverTabIndex: null, }); } @@ -284,6 +363,7 @@ export function TopNavbar({ startX: 0, currentX: 0, targetIndex: null, + hoverTabIndex: null, }); }; @@ -351,6 +431,25 @@ export function TopNavbar({ ? dragState.currentX - dragState.startX : 0; + // Check if this tab is a valid drop target for split screen + const draggedTab = + dragState.draggedIndex !== null + ? tabs[dragState.draggedIndex] + : null; + const isDraggedSplittable = + draggedTab && + (draggedTab.type === "terminal" || + draggedTab.type === "server" || + draggedTab.type === "file_manager"); + const isValidDropTarget = + isDraggedSplittable && + isSplittable && + !isDraggingThisTab && + !isSplit && + allSplitScreenTab.length < 3; + const isHoveredDropTarget = + isValidDropTarget && dragState.hoverTabIndex === index; + let transform = ""; if (!isInDropAnimation) { @@ -466,6 +565,8 @@ export function TopNavbar({ disableClose={disableClose} isDragging={isDraggingThisTab} isDragOver={false} + isValidDropTarget={isValidDropTarget} + isHoveredDropTarget={isHoveredDropTarget} />
); diff --git a/src/ui/desktop/navigation/tabs/Tab.tsx b/src/ui/desktop/navigation/tabs/Tab.tsx index 03a21bbb..ba35e918 100644 --- a/src/ui/desktop/navigation/tabs/Tab.tsx +++ b/src/ui/desktop/navigation/tabs/Tab.tsx @@ -27,6 +27,8 @@ interface TabProps { disableClose?: boolean; isDragging?: boolean; isDragOver?: boolean; + isValidDropTarget?: boolean; + isHoveredDropTarget?: boolean; } export function Tab({ @@ -44,6 +46,8 @@ export function Tab({ disableClose = false, isDragging = false, isDragOver = false, + isValidDropTarget = false, + isHoveredDropTarget = false, }: TabProps): React.ReactElement { const { t } = useTranslation(); @@ -54,12 +58,21 @@ export function Tab({ isDragOver && "bg-background/40 text-muted-foreground border-border opacity-60", isDragging && "opacity-70", + isHoveredDropTarget && + "bg-blue-500/20 border-blue-500 ring-2 ring-blue-500/50", + !isHoveredDropTarget && + isValidDropTarget && + "border-blue-400/50 bg-background/90", !isDragOver && !isDragging && + !isValidDropTarget && + !isHoveredDropTarget && isActive && "bg-background text-foreground border-border z-10", !isDragOver && !isDragging && + !isValidDropTarget && + !isHoveredDropTarget && !isActive && "bg-background/80 text-muted-foreground border-border hover:bg-background/90", ); -- 2.49.1 From c4c5be34f292887757a6ed5fe3a1e5c384107904 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 17:56:08 +0800 Subject: [PATCH 36/42] feat: Add archive extraction feature to file manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds comprehensive archive extraction support to the file manager context menu: Backend API (file-manager.ts): - New POST /ssh/file_manager/ssh/extractArchive endpoint - Supports multiple archive formats: .zip, .tar, .tar.gz, .tgz, .tar.bz2, .tbz2, .tar.xz, .gz, .bz2, .xz, .7z, .rar - Automatically selects appropriate extraction command based on file extension - Extracts to current directory by default, supports custom extraction path - Proper error handling and logging for extraction operations Frontend Implementation: - Added extractSSHArchive() function in main-axios.ts for API calls - Added handleExtractArchive() handler in FileManager.tsx - FileManagerContextMenu displays 'Extract Archive' option for supported archive files - FileArchive icon from lucide-react for visual clarity - Keyboard shortcut: Ctrl+E User Experience: - Right-click on any supported archive file to see 'Extract Archive' option - Toast notifications for progress and success/error states - Automatic directory refresh after extraction to show extracted files - Only shows extract option for recognized archive file types i18n Support: - English translations: extractArchive, extractingArchive, archiveExtractedSuccessfully, extractFailed - Chinese translations: 解压文件, 正在解压, 解压成功, 解压失败 Supported Archive Formats: - ZIP archives (.zip) - TAR archives (.tar, .tar.gz, .tgz, .tar.bz2, .tbz2, .tar.xz) - Compressed files (.gz, .bz2, .xz) - 7-Zip archives (.7z) - RAR archives (.rar) This feature streamlines file management workflows by allowing users to extract archives directly from the file manager without switching to terminal. --- src/backend/ssh/file-manager.ts | 126 ++++++++++++++++++ src/locales/en/translation.json | 4 + src/locales/zh/translation.json | 4 + .../desktop/apps/file-manager/FileManager.tsx | 30 +++++ .../file-manager/FileManagerContextMenu.tsx | 30 +++++ src/ui/main-axios.ts | 45 +++++++ 6 files changed, 239 insertions(+) diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index b2ee2138..6ec53e3e 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -2587,6 +2587,132 @@ app.post("/ssh/file_manager/ssh/changePermissions", async (req, res) => { }); }); +// Route: Extract archive file (requires JWT) +// POST /ssh/file_manager/ssh/extractArchive +app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => { + const { sessionId, archivePath, extractPath } = req.body; + + if (!sessionId || !archivePath) { + return res.status(400).json({ error: "Missing required parameters" }); + } + + const session = sshSessions[sessionId]; + if (!session || !session.isConnected) { + return res.status(400).json({ error: "SSH session not connected" }); + } + + session.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + const fileName = archivePath.split("/").pop() || ""; + const fileExt = fileName.toLowerCase(); + + // Determine extraction command based on file extension + let extractCommand = ""; + const targetPath = extractPath || archivePath.substring(0, archivePath.lastIndexOf("/")); + + if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz")) { + extractCommand = `tar -xzf "${archivePath}" -C "${targetPath}"`; + } else if (fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2")) { + extractCommand = `tar -xjf "${archivePath}" -C "${targetPath}"`; + } else if (fileExt.endsWith(".tar.xz")) { + extractCommand = `tar -xJf "${archivePath}" -C "${targetPath}"`; + } else if (fileExt.endsWith(".tar")) { + extractCommand = `tar -xf "${archivePath}" -C "${targetPath}"`; + } else if (fileExt.endsWith(".zip")) { + extractCommand = `unzip -o "${archivePath}" -d "${targetPath}"`; + } else if (fileExt.endsWith(".gz") && !fileExt.endsWith(".tar.gz")) { + extractCommand = `gunzip -c "${archivePath}" > "${archivePath.replace(/\.gz$/, "")}"`; + } else if (fileExt.endsWith(".bz2") && !fileExt.endsWith(".tar.bz2")) { + extractCommand = `bunzip2 -k "${archivePath}"`; + } else if (fileExt.endsWith(".xz") && !fileExt.endsWith(".tar.xz")) { + extractCommand = `unxz -k "${archivePath}"`; + } else if (fileExt.endsWith(".7z")) { + extractCommand = `7z x "${archivePath}" -o"${targetPath}"`; + } else if (fileExt.endsWith(".rar")) { + extractCommand = `unrar x "${archivePath}" "${targetPath}/"`; + } else { + return res.status(400).json({ error: "Unsupported archive format" }); + } + + fileLogger.info("Extracting archive", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath: targetPath, + command: extractCommand, + }); + + session.client.exec(extractCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH exec error during extract:", err, { + operation: "extract_archive", + sessionId, + archivePath, + }); + return res.status(500).json({ error: "Failed to execute extract command" }); + } + + let errorOutput = ""; + + stream.on("data", (data: Buffer) => { + fileLogger.debug("Extract stdout", { + operation: "extract_archive", + sessionId, + output: data.toString(), + }); + }); + + stream.stderr.on("data", (data: Buffer) => { + errorOutput += data.toString(); + fileLogger.debug("Extract stderr", { + operation: "extract_archive", + sessionId, + error: data.toString(), + }); + }); + + stream.on("close", (code: number) => { + if (code !== 0) { + fileLogger.error("Extract command failed", { + operation: "extract_archive", + sessionId, + archivePath, + exitCode: code, + error: errorOutput, + }); + return res.status(500).json({ + error: errorOutput || "Failed to extract archive" + }); + } + + fileLogger.success("Archive extracted successfully", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath: targetPath, + }); + + res.json({ + success: true, + message: "Archive extracted successfully", + extractPath: targetPath + }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH extractArchive stream error:", streamErr, { + operation: "extract_archive", + sessionId, + archivePath, + }); + if (!res.headersSent) { + res.status(500).json({ error: "Stream error while extracting archive" }); + } + }); + }); +}); + process.on("SIGINT", () => { Object.keys(sshSessions).forEach(cleanupSession); process.exit(0); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 51e96e4e..3637167e 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -903,6 +903,10 @@ "connectToSsh": "Connect to SSH to use file operations", "uploadFile": "Upload File", "downloadFile": "Download", + "extractArchive": "Extract Archive", + "extractingArchive": "Extracting {{name}}...", + "archiveExtractedSuccessfully": "{{name}} extracted successfully", + "extractFailed": "Extract failed", "edit": "Edit", "preview": "Preview", "previous": "Previous", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 00202b57..665c2626 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -915,6 +915,10 @@ "connectToSsh": "连接 SSH 以使用文件操作", "uploadFile": "上传文件", "downloadFile": "下载", + "extractArchive": "解压文件", + "extractingArchive": "正在解压 {{name}}...", + "archiveExtractedSuccessfully": "{{name}} 解压成功", + "extractFailed": "解压失败", "edit": "编辑", "preview": "预览", "previous": "上一页", diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index 7c0e8208..6d981f76 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -51,6 +51,7 @@ import { getPinnedFiles, logActivity, changeSSHPermissions, + extractSSHArchive, } from "@/ui/main-axios.ts"; import type { SidebarItem } from "./FileManagerSidebar"; @@ -1061,6 +1062,34 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { } } + async function handleExtractArchive(file: FileItem) { + if (!sshSessionId) return; + + try { + await ensureSSHConnection(); + + toast.info(t("fileManager.extractingArchive", { name: file.name })); + + await extractSSHArchive( + sshSessionId, + file.path, + undefined, + currentHost?.id, + currentHost?.userId?.toString(), + ); + + toast.success(t("fileManager.archiveExtractedSuccessfully", { name: file.name })); + + // Refresh directory to show extracted files + handleRefreshDirectory(); + } catch (error: unknown) { + const err = error as { message?: string }; + toast.error( + `${t("fileManager.extractFailed")}: ${err.message || t("fileManager.unknownError")}`, + ); + } + } + async function handleUndo() { if (undoHistory.length === 0) { toast.info(t("fileManager.noUndoableActions")); @@ -2000,6 +2029,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { isPinned={isPinnedFile} currentPath={currentPath} onProperties={handleOpenPermissionsDialog} + onExtractArchive={handleExtractArchive} />
diff --git a/src/ui/desktop/apps/file-manager/FileManagerContextMenu.tsx b/src/ui/desktop/apps/file-manager/FileManagerContextMenu.tsx index e11cbf6c..e2fac138 100644 --- a/src/ui/desktop/apps/file-manager/FileManagerContextMenu.tsx +++ b/src/ui/desktop/apps/file-manager/FileManagerContextMenu.tsx @@ -17,6 +17,7 @@ import { Play, Star, Bookmark, + FileArchive, } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Kbd, KbdGroup } from "@/components/ui/kbd"; @@ -60,6 +61,7 @@ interface ContextMenuProps { onAddShortcut?: (path: string) => void; isPinned?: (file: FileItem) => boolean; currentPath?: string; + onExtractArchive?: (file: FileItem) => void; } interface MenuItem { @@ -99,6 +101,7 @@ export function FileManagerContextMenu({ onAddShortcut, isPinned, currentPath, + onExtractArchive, }: ContextMenuProps) { const { t } = useTranslation(); const [menuPosition, setMenuPosition] = useState({ x, y }); @@ -254,6 +257,33 @@ export function FileManagerContextMenu({ }); } + // Add extract option for archive files + if (isSingleFile && files[0].type === "file" && onExtractArchive) { + const fileName = files[0].name.toLowerCase(); + const isArchive = + fileName.endsWith(".zip") || + fileName.endsWith(".tar") || + fileName.endsWith(".tar.gz") || + fileName.endsWith(".tgz") || + fileName.endsWith(".tar.bz2") || + fileName.endsWith(".tbz2") || + fileName.endsWith(".tar.xz") || + fileName.endsWith(".gz") || + fileName.endsWith(".bz2") || + fileName.endsWith(".xz") || + fileName.endsWith(".7z") || + fileName.endsWith(".rar"); + + if (isArchive) { + menuItems.push({ + icon: , + label: t("fileManager.extractArchive"), + action: () => onExtractArchive(files[0]), + shortcut: "Ctrl+E", + }); + } + } + if (isSingleFile && files[0].type === "file") { const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false; diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 7d9e6342..ae7fc925 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1561,6 +1561,51 @@ export async function changeSSHPermissions( } } +export async function extractSSHArchive( + sessionId: string, + archivePath: string, + extractPath?: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string; extractPath: string }> { + try { + fileLogger.info("Extracting archive", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath, + hostId, + userId, + }); + + const response = await fileManagerApi.post("/ssh/extractArchive", { + sessionId, + archivePath, + extractPath, + hostId, + userId, + }); + + fileLogger.success("Archive extracted successfully", { + operation: "extract_archive", + sessionId, + archivePath, + extractPath: response.data.extractPath, + }); + + return response.data; + } catch (error) { + fileLogger.error("Failed to extract archive", error, { + operation: "extract_archive", + sessionId, + archivePath, + extractPath, + }); + handleApiError(error, "extract archive"); + throw error; + } +} + // ============================================================================ // FILE MANAGER DATA // ============================================================================ -- 2.49.1 From 49094bbadce624fe23d6042c0e669606ebbce619 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 20:18:52 +0800 Subject: [PATCH 37/42] feat: Add terminal command history tracking and autocomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive command history system for SSH terminals: - Stage 1: Automatic command tracking with character-level input monitoring - Stage 2: Ctrl+R history search dialog with real-time filtering and keyboard navigation - Stage 3: Tab-based autocomplete with multi-match selection UI Key features: - Per-host command history stored in SQLite database - Smart positioning for autocomplete menu (auto-adjusts based on screen space) - Delete individual commands from history - Keyboard shortcuts: Ctrl+R (search), Tab (autocomplete), ↑↓ (navigate), Enter (select), Esc (close) Bug fixes: - Prevent double input by filtering keyup events (only handle keydown) - Use refs to avoid closure traps in event handlers - Real-time history updates without requiring terminal restart 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/backend/database/database.ts | 2 + src/backend/database/db/index.ts | 10 + src/backend/database/db/schema.ts | 14 + src/backend/database/routes/terminal.ts | 221 +++++++++++ .../apps/terminal/CommandAutocomplete.tsx | 67 ++++ .../apps/terminal/CommandHistoryDialog.tsx | 225 +++++++++++ src/ui/desktop/apps/terminal/Terminal.tsx | 362 ++++++++++++++++++ src/ui/hooks/useCommandHistory.ts | 142 +++++++ src/ui/hooks/useCommandTracker.ts | 144 +++++++ src/ui/main-axios.ts | 118 ++++++ 10 files changed, 1305 insertions(+) create mode 100644 src/backend/database/routes/terminal.ts create mode 100644 src/ui/desktop/apps/terminal/CommandAutocomplete.tsx create mode 100644 src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx create mode 100644 src/ui/hooks/useCommandHistory.ts create mode 100644 src/ui/hooks/useCommandTracker.ts diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 83280beb..37a09592 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -7,6 +7,7 @@ import sshRoutes from "./routes/ssh.js"; import alertRoutes from "./routes/alerts.js"; import credentialsRoutes from "./routes/credentials.js"; import snippetsRoutes from "./routes/snippets.js"; +import terminalRoutes from "./routes/terminal.js"; import cors from "cors"; import fetch from "node-fetch"; import fs from "fs"; @@ -1418,6 +1419,7 @@ app.use("/ssh", sshRoutes); app.use("/alerts", alertRoutes); app.use("/credentials", credentialsRoutes); app.use("/snippets", snippetsRoutes); +app.use("/terminal", terminalRoutes); app.use( ( diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index d735e6b7..5847882f 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -322,6 +322,16 @@ async function initializeCompleteDatabase(): Promise { FOREIGN KEY (host_id) REFERENCES ssh_data (id) ); + CREATE TABLE IF NOT EXISTS command_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + host_id INTEGER NOT NULL, + command TEXT NOT NULL, + executed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users (id), + FOREIGN KEY (host_id) REFERENCES ssh_data (id) + ); + `); try { diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 2e5ed460..5aac5b3b 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -239,3 +239,17 @@ export const recentActivity = sqliteTable("recent_activity", { .notNull() .default(sql`CURRENT_TIMESTAMP`), }); + +export const commandHistory = sqliteTable("command_history", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("user_id") + .notNull() + .references(() => users.id), + hostId: integer("host_id") + .notNull() + .references(() => sshData.id), + command: text("command").notNull(), + executedAt: text("executed_at") + .notNull() + .default(sql`CURRENT_TIMESTAMP`), +}); diff --git a/src/backend/database/routes/terminal.ts b/src/backend/database/routes/terminal.ts new file mode 100644 index 00000000..f07366da --- /dev/null +++ b/src/backend/database/routes/terminal.ts @@ -0,0 +1,221 @@ +import type { AuthenticatedRequest } from "../../../types/index.js"; +import express from "express"; +import { db } from "../db/index.js"; +import { commandHistory } from "../db/schema.js"; +import { eq, and, desc, sql } from "drizzle-orm"; +import type { Request, Response } from "express"; +import { authLogger } from "../../utils/logger.js"; +import { AuthManager } from "../../utils/auth-manager.js"; + +const router = express.Router(); + +function isNonEmptyString(val: unknown): val is string { + return typeof val === "string" && val.trim().length > 0; +} + +const authManager = AuthManager.getInstance(); +const authenticateJWT = authManager.createAuthMiddleware(); +const requireDataAccess = authManager.createDataAccessMiddleware(); + +// Save command to history +// POST /terminal/command_history +router.post( + "/command_history", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId, command } = req.body; + + if ( + !isNonEmptyString(userId) || + !hostId || + !isNonEmptyString(command) + ) { + authLogger.warn("Invalid command history save request", { + operation: "command_history_save", + userId, + hasHostId: !!hostId, + hasCommand: !!command, + }); + return res.status(400).json({ error: "Missing required parameters" }); + } + + try { + const insertData = { + userId, + hostId: parseInt(hostId, 10), + command: command.trim(), + }; + + const result = await db.insert(commandHistory).values(insertData).returning(); + + authLogger.info(`Command saved to history for host ${hostId}`, { + operation: "command_history_save_success", + userId, + hostId: parseInt(hostId, 10), + }); + + res.status(201).json(result[0]); + } catch (err) { + authLogger.error("Failed to save command to history", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to save command", + }); + } + }, +); + +// Get command history for a specific host +// GET /terminal/command_history/:hostId +router.get( + "/command_history/:hostId", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId } = req.params; + const hostIdNum = parseInt(hostId, 10); + + if (!isNonEmptyString(userId) || isNaN(hostIdNum)) { + authLogger.warn("Invalid command history fetch request", { + userId, + hostId: hostIdNum, + }); + return res.status(400).json({ error: "Invalid request parameters" }); + } + + try { + // Get unique commands for this host, ordered by most recent + // Use DISTINCT to avoid duplicates, but keep the most recent occurrence + const result = await db + .selectDistinct({ command: commandHistory.command }) + .from(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostIdNum) + ) + ) + .orderBy(desc(commandHistory.executedAt)) + .limit(500); // Limit to last 500 unique commands + + // Further deduplicate in case DISTINCT didn't work perfectly + const uniqueCommands = Array.from( + new Set(result.map((r) => r.command)) + ); + + authLogger.info(`Fetched command history for host ${hostId}`, { + operation: "command_history_fetch_success", + userId, + hostId: hostIdNum, + count: uniqueCommands.length, + }); + + res.json(uniqueCommands); + } catch (err) { + authLogger.error("Failed to fetch command history", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to fetch history", + }); + } + }, +); + +// Delete a specific command from history +// POST /terminal/command_history/delete +router.post( + "/command_history/delete", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId, command } = req.body; + + if ( + !isNonEmptyString(userId) || + !hostId || + !isNonEmptyString(command) + ) { + authLogger.warn("Invalid command delete request", { + operation: "command_history_delete", + userId, + hasHostId: !!hostId, + hasCommand: !!command, + }); + return res.status(400).json({ error: "Missing required parameters" }); + } + + try { + const hostIdNum = parseInt(hostId, 10); + + // Delete all instances of this command for this user and host + await db + .delete(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostIdNum), + eq(commandHistory.command, command.trim()) + ) + ); + + authLogger.info(`Command deleted from history for host ${hostId}`, { + operation: "command_history_delete_success", + userId, + hostId: hostIdNum, + }); + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to delete command from history", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to delete command", + }); + } + }, +); + +// Clear command history for a specific host (optional feature) +// DELETE /terminal/command_history/:hostId +router.delete( + "/command_history/:hostId", + authenticateJWT, + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId; + const { hostId } = req.params; + const hostIdNum = parseInt(hostId, 10); + + if (!isNonEmptyString(userId) || isNaN(hostIdNum)) { + authLogger.warn("Invalid command history clear request"); + return res.status(400).json({ error: "Invalid request" }); + } + + try { + await db + .delete(commandHistory) + .where( + and( + eq(commandHistory.userId, userId), + eq(commandHistory.hostId, hostIdNum) + ) + ); + + authLogger.success(`Command history cleared for host ${hostId}`, { + operation: "command_history_clear_success", + userId, + hostId: hostIdNum, + }); + + res.json({ success: true }); + } catch (err) { + authLogger.error("Failed to clear command history", err); + res.status(500).json({ + error: err instanceof Error ? err.message : "Failed to clear history", + }); + } + }, +); + +export default router; diff --git a/src/ui/desktop/apps/terminal/CommandAutocomplete.tsx b/src/ui/desktop/apps/terminal/CommandAutocomplete.tsx new file mode 100644 index 00000000..1828ab72 --- /dev/null +++ b/src/ui/desktop/apps/terminal/CommandAutocomplete.tsx @@ -0,0 +1,67 @@ +import React, { useEffect, useRef } from "react"; +import { cn } from "@/lib/utils"; + +interface CommandAutocompleteProps { + suggestions: string[]; + selectedIndex: number; + onSelect: (command: string) => void; + position: { top: number; left: number }; + visible: boolean; +} + +export function CommandAutocomplete({ + suggestions, + selectedIndex, + onSelect, + position, + visible, +}: CommandAutocompleteProps) { + const containerRef = useRef(null); + const selectedRef = useRef(null); + + // Scroll selected item into view + useEffect(() => { + if (selectedRef.current && containerRef.current) { + selectedRef.current.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, [selectedIndex]); + + if (!visible || suggestions.length === 0) { + return null; + } + + return ( +
+ {suggestions.map((suggestion, index) => ( +
onSelect(suggestion)} + onMouseEnter={() => { + // Optional: update selected index on hover + }} + > + {suggestion} +
+ ))} +
+ Tab/Enter to complete • ↑↓ to navigate • Esc to close +
+
+ ); +} diff --git a/src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx b/src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx new file mode 100644 index 00000000..644f950e --- /dev/null +++ b/src/ui/desktop/apps/terminal/CommandHistoryDialog.tsx @@ -0,0 +1,225 @@ +import React, { useState, useEffect, useRef } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { cn } from "@/lib/utils"; +import { Search, Clock, X, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface CommandHistoryDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + commands: string[]; + onSelectCommand: (command: string) => void; + onDeleteCommand?: (command: string) => void; + isLoading?: boolean; +} + +export function CommandHistoryDialog({ + open, + onOpenChange, + commands, + onSelectCommand, + onDeleteCommand, + isLoading = false, +}: CommandHistoryDialogProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedIndex, setSelectedIndex] = useState(0); + const inputRef = useRef(null); + const listRef = useRef(null); + const selectedRef = useRef(null); + + // Filter commands based on search query + const filteredCommands = searchQuery + ? commands.filter((cmd) => + cmd.toLowerCase().includes(searchQuery.toLowerCase()) + ) + : commands; + + // Reset state when dialog opens/closes + useEffect(() => { + if (open) { + setSearchQuery(""); + setSelectedIndex(0); + // Focus search input + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [open]); + + // Scroll selected item into view + useEffect(() => { + if (selectedRef.current && listRef.current) { + selectedRef.current.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + }, [selectedIndex]); + + // Handle keyboard navigation + const handleKeyDown = (e: React.KeyboardEvent) => { + if (filteredCommands.length === 0) return; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => + prev < filteredCommands.length - 1 ? prev + 1 : prev + ); + break; + + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => (prev > 0 ? prev - 1 : 0)); + break; + + case "Enter": + e.preventDefault(); + if (filteredCommands[selectedIndex]) { + onSelectCommand(filteredCommands[selectedIndex]); + onOpenChange(false); + } + break; + + case "Escape": + e.preventDefault(); + onOpenChange(false); + break; + } + }; + + const handleSelect = (command: string) => { + onSelectCommand(command); + onOpenChange(false); + }; + + return ( + + + + + + Command History + + + +
+
+ + { + setSearchQuery(e.target.value); + setSelectedIndex(0); + }} + onKeyDown={handleKeyDown} + className="pl-10 pr-10" + /> + {searchQuery && ( + + )} +
+
+ + + {isLoading ? ( +
+
+
+ Loading history... +
+
+ ) : filteredCommands.length === 0 ? ( +
+ {searchQuery ? ( + <> + +

No commands found matching "{searchQuery}"

+ + ) : ( + <> + +

No command history yet

+

Execute commands to build your history

+ + )} +
+ ) : ( +
+ {filteredCommands.map((command, index) => ( +
setSelectedIndex(index)} + > + handleSelect(command)} + > + {command} + + {onDeleteCommand && ( + + )} +
+ ))} +
+ )} +
+ +
+
+
+ + ↑↓ Navigate + + + Enter Select + + + Esc Close + +
+ + {filteredCommands.length} command{filteredCommands.length !== 1 ? "s" : ""} + +
+
+
+
+ ); +} diff --git a/src/ui/desktop/apps/terminal/Terminal.tsx b/src/ui/desktop/apps/terminal/Terminal.tsx index 02b61b80..8b5d5eff 100644 --- a/src/ui/desktop/apps/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/terminal/Terminal.tsx @@ -4,6 +4,7 @@ import { useState, useImperativeHandle, forwardRef, + useCallback, } from "react"; import { useXTerm } from "react-xtermjs"; import { FitAddon } from "@xterm/addon-fit"; @@ -26,6 +27,10 @@ import { TERMINAL_FONTS, } from "@/constants/terminal-themes"; import type { TerminalConfig } from "@/types"; +import { useCommandTracker } from "@/ui/hooks/useCommandTracker"; +import { useCommandHistory } from "@/ui/hooks/useCommandHistory"; +import { CommandHistoryDialog } from "./CommandHistoryDialog"; +import { CommandAutocomplete } from "./CommandAutocomplete"; interface HostConfig { id?: number; @@ -122,6 +127,94 @@ export const Terminal = forwardRef( const isConnectingRef = useRef(false); const connectionTimeoutRef = useRef(null); const activityLoggedRef = useRef(false); + const keyHandlerAttachedRef = useRef(false); + + // Command history tracking (Stage 1) + const { trackInput, getCurrentCommand, updateCurrentCommand } = useCommandTracker({ + hostId: hostConfig.id, + enabled: true, + onCommandExecuted: (command) => { + // Add to autocomplete history (Stage 3) + if (!autocompleteHistory.current.includes(command)) { + autocompleteHistory.current = [command, ...autocompleteHistory.current]; + } + }, + }); + + // Create refs for callbacks to avoid triggering useEffect re-runs + const getCurrentCommandRef = useRef(getCurrentCommand); + const updateCurrentCommandRef = useRef(updateCurrentCommand); + + useEffect(() => { + getCurrentCommandRef.current = getCurrentCommand; + updateCurrentCommandRef.current = updateCurrentCommand; + }, [getCurrentCommand, updateCurrentCommand]); + + // Real-time autocomplete (Stage 3) + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [autocompleteSuggestions, setAutocompleteSuggestions] = useState([]); + const [autocompleteSelectedIndex, setAutocompleteSelectedIndex] = useState(0); + const [autocompletePosition, setAutocompletePosition] = useState({ top: 0, left: 0 }); + const autocompleteHistory = useRef([]); + const currentAutocompleteCommand = useRef(""); + + // Refs for accessing current state in event handlers + const showAutocompleteRef = useRef(false); + const autocompleteSuggestionsRef = useRef([]); + const autocompleteSelectedIndexRef = useRef(0); + + // Command history dialog (Stage 2) + const [showHistoryDialog, setShowHistoryDialog] = useState(false); + const [commandHistory, setCommandHistory] = useState([]); + const [isLoadingHistory, setIsLoadingHistory] = useState(false); + + // Load command history when dialog opens + useEffect(() => { + if (showHistoryDialog && hostConfig.id) { + setIsLoadingHistory(true); + import("@/ui/main-axios.ts") + .then((module) => module.getCommandHistory(hostConfig.id!)) + .then((history) => { + setCommandHistory(history); + }) + .catch((error) => { + console.error("Failed to load command history:", error); + setCommandHistory([]); + }) + .finally(() => { + setIsLoadingHistory(false); + }); + } + }, [showHistoryDialog, hostConfig.id]); + + // Load command history for autocomplete on mount (Stage 3) + useEffect(() => { + if (hostConfig.id) { + import("@/ui/main-axios.ts") + .then((module) => module.getCommandHistory(hostConfig.id!)) + .then((history) => { + autocompleteHistory.current = history; + }) + .catch((error) => { + console.error("Failed to load autocomplete history:", error); + autocompleteHistory.current = []; + }); + } + }, [hostConfig.id]); + + // Sync autocomplete state to refs for event handlers + useEffect(() => { + showAutocompleteRef.current = showAutocomplete; + }, [showAutocomplete]); + + useEffect(() => { + autocompleteSuggestionsRef.current = autocompleteSuggestions; + }, [autocompleteSuggestions]); + + useEffect(() => { + autocompleteSelectedIndexRef.current = autocompleteSelectedIndex; + }, [autocompleteSelectedIndex]); + const activityLoggingRef = useRef(false); const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null); @@ -511,6 +604,9 @@ export const Terminal = forwardRef( }), ); terminal.onData((data) => { + // Track command input for history (Stage 1) + trackInput(data); + // Send input to server ws.send(JSON.stringify({ type: "input", data })); }); @@ -770,6 +866,86 @@ export const Terminal = forwardRef( return ""; } + // Handle command selection from history dialog (Stage 2) + const handleSelectCommand = useCallback( + (command: string) => { + if (!terminal || !webSocketRef.current) return; + + // Send the command to the terminal + // Simulate typing the command character by character + for (const char of command) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }) + ); + } + + // Return focus to terminal after selecting command + setTimeout(() => { + terminal.focus(); + }, 100); + }, + [terminal] + ); + + // Handle autocomplete selection (mouse click) + const handleAutocompleteSelect = useCallback( + (selectedCommand: string) => { + if (!webSocketRef.current) return; + + const currentCmd = currentAutocompleteCommand.current; + const completion = selectedCommand.substring(currentCmd.length); + + // Send completion characters to server + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }) + ); + } + + // Update current command tracker + updateCurrentCommand(selectedCommand); + + // Close autocomplete + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + + // Return focus to terminal + setTimeout(() => { + terminal?.focus(); + }, 50); + + console.log(`[Autocomplete] ${currentCmd} → ${selectedCommand}`); + }, + [terminal, updateCurrentCommand] + ); + + // Handle command deletion from history dialog + const handleDeleteCommand = useCallback( + async (command: string) => { + if (!hostConfig.id) return; + + try { + // Call API to delete command + const { deleteCommandFromHistory } = await import("@/ui/main-axios.ts"); + await deleteCommandFromHistory(hostConfig.id, command); + + // Update local state + setCommandHistory((prev) => prev.filter((cmd) => cmd !== command)); + + // Update autocomplete history + autocompleteHistory.current = autocompleteHistory.current.filter( + (cmd) => cmd !== command + ); + + console.log(`[Terminal] Command deleted from history: ${command}`); + } catch (error) { + console.error("Failed to delete command from history:", error); + } + }, + [hostConfig.id] + ); + useEffect(() => { if (!terminal || !xtermRef.current) return; @@ -874,6 +1050,14 @@ export const Terminal = forwardRef( navigator.platform.toUpperCase().indexOf("MAC") >= 0 || navigator.userAgent.toUpperCase().indexOf("MAC") >= 0; + // Handle Ctrl+R for command history (Stage 2) + if (e.ctrlKey && e.key === "r" && !e.shiftKey && !e.altKey && !e.metaKey) { + e.preventDefault(); + e.stopPropagation(); + setShowHistoryDialog(true); + return false; + } + if ( config.backspaceMode === "control-h" && e.key === "Backspace" && @@ -962,6 +1146,167 @@ export const Terminal = forwardRef( }; }, [xtermRef, terminal, hostConfig]); + // Register keyboard handler for autocomplete (Stage 3) + // Registered only once when terminal is created + useEffect(() => { + if (!terminal) return; + + const handleCustomKey = (e: KeyboardEvent): boolean => { + // Only handle keydown events, ignore keyup to prevent double triggering + if (e.type !== 'keydown') { + return true; + } + + // If autocomplete is showing, handle keys specially + if (showAutocompleteRef.current) { + // Handle Escape to close autocomplete + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + return false; + } + + // Handle Arrow keys for autocomplete navigation + if (e.key === "ArrowDown" || e.key === "ArrowUp") { + e.preventDefault(); + e.stopPropagation(); + + const currentIndex = autocompleteSelectedIndexRef.current; + const suggestionsLength = autocompleteSuggestionsRef.current.length; + + if (e.key === "ArrowDown") { + const newIndex = currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0; + setAutocompleteSelectedIndex(newIndex); + } else if (e.key === "ArrowUp") { + const newIndex = currentIndex > 0 ? currentIndex - 1 : suggestionsLength - 1; + setAutocompleteSelectedIndex(newIndex); + } + return false; + } + + // Handle Enter to confirm autocomplete selection + if (e.key === "Enter" && autocompleteSuggestionsRef.current.length > 0) { + e.preventDefault(); + e.stopPropagation(); + + const selectedCommand = autocompleteSuggestionsRef.current[autocompleteSelectedIndexRef.current]; + const currentCmd = currentAutocompleteCommand.current; + const completion = selectedCommand.substring(currentCmd.length); + + // Send completion characters to server + if (webSocketRef.current?.readyState === 1) { + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }) + ); + } + } + + // Update current command tracker + updateCurrentCommandRef.current(selectedCommand); + + // Close autocomplete + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + + return false; + } + + // Handle Tab to cycle through suggestions + if (e.key === "Tab" && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + const currentIndex = autocompleteSelectedIndexRef.current; + const suggestionsLength = autocompleteSuggestionsRef.current.length; + const newIndex = currentIndex < suggestionsLength - 1 ? currentIndex + 1 : 0; + setAutocompleteSelectedIndex(newIndex); + return false; + } + + // For any other key while autocomplete is showing, close it and let key through + setShowAutocomplete(false); + setAutocompleteSuggestions([]); + currentAutocompleteCommand.current = ""; + return true; + } + + // Handle Tab for autocomplete (when autocomplete is not showing) + if (e.key === "Tab" && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + + const currentCmd = getCurrentCommandRef.current().trim(); + if (currentCmd.length > 0 && webSocketRef.current?.readyState === 1) { + // Filter commands that start with current input + const matches = autocompleteHistory.current + .filter(cmd => + cmd.startsWith(currentCmd) && + cmd !== currentCmd && + cmd.length > currentCmd.length + ) + .slice(0, 10); // Show up to 10 matches + + if (matches.length === 1) { + // Only one match - auto-complete directly + const completedCommand = matches[0]; + const completion = completedCommand.substring(currentCmd.length); + + for (const char of completion) { + webSocketRef.current.send( + JSON.stringify({ type: "input", data: char }) + ); + } + + updateCurrentCommandRef.current(completedCommand); + } else if (matches.length > 1) { + // Multiple matches - show selection list + currentAutocompleteCommand.current = currentCmd; + setAutocompleteSuggestions(matches); + setAutocompleteSelectedIndex(0); + + // Calculate position (below or above cursor based on available space) + const cursorY = terminal.buffer.active.cursorY; + const cursorX = terminal.buffer.active.cursorX; + const rect = xtermRef.current?.getBoundingClientRect(); + + if (rect) { + const cellHeight = terminal.rows > 0 ? rect.height / terminal.rows : 20; + const cellWidth = terminal.cols > 0 ? rect.width / terminal.cols : 10; + + // Estimate autocomplete menu height (max-h-[240px] from component) + const menuHeight = 240; + const cursorBottomY = rect.top + (cursorY + 1) * cellHeight; + const spaceBelow = window.innerHeight - cursorBottomY; + const spaceAbove = rect.top + cursorY * cellHeight; + + // Show above cursor if not enough space below + const showAbove = spaceBelow < menuHeight && spaceAbove > spaceBelow; + + setAutocompletePosition({ + top: showAbove + ? rect.top + cursorY * cellHeight - menuHeight + : cursorBottomY, + left: rect.left + cursorX * cellWidth, + }); + } + + setShowAutocomplete(true); + } + } + return false; // Prevent default Tab behavior + } + + // Let terminal handle all other keys + return true; + }; + + terminal.attachCustomKeyEventHandler(handleCustomKey); + }, [terminal]); + useEffect(() => { if (!terminal || !hostConfig || !visible) return; @@ -1088,6 +1433,23 @@ export const Terminal = forwardRef( backgroundColor={backgroundColor} /> + + + + {isConnecting && (
string[]; + saveCommand: (command: string) => Promise; + clearSuggestions: () => void; + isLoading: boolean; +} + +/** + * Custom hook for managing command history and autocomplete suggestions + */ +export function useCommandHistory({ + hostId, + enabled = true, +}: UseCommandHistoryOptions): CommandHistoryResult { + const [commandHistory, setCommandHistory] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const historyCache = useRef>(new Map()); + + // Fetch command history when hostId changes + useEffect(() => { + if (!enabled || !hostId) { + setCommandHistory([]); + setSuggestions([]); + return; + } + + // Check cache first + const cached = historyCache.current.get(hostId); + if (cached) { + setCommandHistory(cached); + return; + } + + // Fetch from server + const fetchHistory = async () => { + setIsLoading(true); + try { + const history = await getCommandHistory(hostId); + setCommandHistory(history); + historyCache.current.set(hostId, history); + } catch (error) { + console.error("Failed to fetch command history:", error); + setCommandHistory([]); + } finally { + setIsLoading(false); + } + }; + + fetchHistory(); + }, [hostId, enabled]); + + /** + * Get command suggestions based on current input + */ + const getSuggestions = useCallback( + (input: string): string[] => { + if (!input || input.trim().length === 0) { + return []; + } + + const trimmedInput = input.trim(); + const matches = commandHistory.filter((cmd) => + cmd.startsWith(trimmedInput) + ); + + // Return up to 10 suggestions, excluding exact matches + const filtered = matches.filter((cmd) => cmd !== trimmedInput).slice(0, 10); + + setSuggestions(filtered); + return filtered; + }, + [commandHistory] + ); + + /** + * Save a command to history + */ + const saveCommand = useCallback( + async (command: string) => { + if (!enabled || !hostId || !command || command.trim().length === 0) { + return; + } + + const trimmedCommand = command.trim(); + + // Skip if it's the same as the last command + if (commandHistory.length > 0 && commandHistory[0] === trimmedCommand) { + return; + } + + try { + // Save to server + await saveCommandToHistory(hostId, trimmedCommand); + + // Update local state - add to beginning + setCommandHistory((prev) => { + const newHistory = [trimmedCommand, ...prev.filter((c) => c !== trimmedCommand)]; + // Keep max 500 commands in memory + const limited = newHistory.slice(0, 500); + historyCache.current.set(hostId, limited); + return limited; + }); + } catch (error) { + console.error("Failed to save command to history:", error); + // Still update local state even if server save fails + setCommandHistory((prev) => { + const newHistory = [trimmedCommand, ...prev.filter((c) => c !== trimmedCommand)]; + return newHistory.slice(0, 500); + }); + } + }, + [enabled, hostId, commandHistory] + ); + + /** + * Clear current suggestions + */ + const clearSuggestions = useCallback(() => { + setSuggestions([]); + }, []); + + return { + suggestions, + getSuggestions, + saveCommand, + clearSuggestions, + isLoading, + }; +} diff --git a/src/ui/hooks/useCommandTracker.ts b/src/ui/hooks/useCommandTracker.ts new file mode 100644 index 00000000..fe7302a3 --- /dev/null +++ b/src/ui/hooks/useCommandTracker.ts @@ -0,0 +1,144 @@ +import { useRef, useCallback } from "react"; +import { saveCommandToHistory } from "@/ui/main-axios.ts"; + +interface UseCommandTrackerOptions { + hostId?: number; + enabled?: boolean; + onCommandExecuted?: (command: string) => void; +} + +interface CommandTrackerResult { + trackInput: (data: string) => void; + getCurrentCommand: () => string; + clearCurrentCommand: () => void; + updateCurrentCommand: (command: string) => void; +} + +/** + * Hook to track terminal input and save executed commands to history + * Works with SSH terminals by monitoring input data + */ +export function useCommandTracker({ + hostId, + enabled = true, + onCommandExecuted, +}: UseCommandTrackerOptions): CommandTrackerResult { + const currentCommandRef = useRef(""); + const isInEscapeSequenceRef = useRef(false); + + /** + * Track input data and detect command execution + */ + const trackInput = useCallback( + (data: string) => { + if (!enabled || !hostId) { + return; + } + + // Handle each character + for (let i = 0; i < data.length; i++) { + const char = data[i]; + const charCode = char.charCodeAt(0); + + // Detect escape sequences (e.g., arrow keys, function keys) + if (charCode === 27) { + // ESC + isInEscapeSequenceRef.current = true; + continue; + } + + // Skip characters that are part of escape sequences + if (isInEscapeSequenceRef.current) { + // Common escape sequence endings + if ( + (charCode >= 65 && charCode <= 90) || // A-Z + (charCode >= 97 && charCode <= 122) || // a-z + charCode === 126 // ~ + ) { + isInEscapeSequenceRef.current = false; + } + continue; + } + + // Handle Enter key (CR or LF) + if (charCode === 13 || charCode === 10) { + // \r or \n + const command = currentCommandRef.current.trim(); + + // Save non-empty commands + if (command.length > 0) { + // Save to history (async, don't wait) + saveCommandToHistory(hostId, command).catch((error) => { + console.error("Failed to save command to history:", error); + }); + + // Callback for external handling + if (onCommandExecuted) { + onCommandExecuted(command); + } + } + + // Clear current command + currentCommandRef.current = ""; + continue; + } + + // Handle Backspace/Delete + if (charCode === 8 || charCode === 127) { + // Backspace or DEL + if (currentCommandRef.current.length > 0) { + currentCommandRef.current = currentCommandRef.current.slice(0, -1); + } + continue; + } + + // Handle Ctrl+C, Ctrl+D, etc. - clear current command + if (charCode === 3 || charCode === 4) { + currentCommandRef.current = ""; + continue; + } + + // Handle Ctrl+U (clear line) - common in terminals + if (charCode === 21) { + currentCommandRef.current = ""; + continue; + } + + // Add printable characters to current command + if (charCode >= 32 && charCode <= 126) { + // Printable ASCII + currentCommandRef.current += char; + } + } + }, + [enabled, hostId, onCommandExecuted] + ); + + /** + * Get the current command being typed + */ + const getCurrentCommand = useCallback(() => { + return currentCommandRef.current; + }, []); + + /** + * Clear the current command buffer + */ + const clearCurrentCommand = useCallback(() => { + currentCommandRef.current = ""; + }, []); + + /** + * Update the current command buffer (used for autocomplete) + */ + const updateCurrentCommand = useCallback((command: string) => { + currentCommandRef.current = command; + }, []); + + return { + trackInput, + getCurrentCommand, + clearCurrentCommand, + updateCurrentCommand, + }; +} diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index ae7fc925..5b0c879d 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -1606,6 +1606,55 @@ export async function extractSSHArchive( } } +export async function compressSSHFiles( + sessionId: string, + paths: string[], + archiveName: string, + format?: string, + hostId?: number, + userId?: string, +): Promise<{ success: boolean; message: string; archivePath: string }> { + try { + fileLogger.info("Compressing files", { + operation: "compress_files", + sessionId, + paths, + archiveName, + format, + hostId, + userId, + }); + + const response = await fileManagerApi.post("/ssh/compressFiles", { + sessionId, + paths, + archiveName, + format: format || "zip", + hostId, + userId, + }); + + fileLogger.success("Files compressed successfully", { + operation: "compress_files", + sessionId, + paths, + archivePath: response.data.archivePath, + }); + + return response.data; + } catch (error) { + fileLogger.error("Failed to compress files", error, { + operation: "compress_files", + sessionId, + paths, + archiveName, + format, + }); + handleApiError(error, "compress files"); + throw error; + } +} + // ============================================================================ // FILE MANAGER DATA // ============================================================================ @@ -2801,3 +2850,72 @@ export async function resetRecentActivity(): Promise<{ message: string }> { throw handleApiError(error, "reset recent activity"); } } + +// ============================================================================ +// COMMAND HISTORY API +// ============================================================================ + +/** + * Save a command to history for a specific host + */ +export async function saveCommandToHistory( + hostId: number, + command: string, +): Promise<{ id: number; command: string; executedAt: string }> { + try { + const response = await authApi.post("/terminal/command_history", { + hostId, + command, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "save command to history"); + } +} + +/** + * Get command history for a specific host + * Returns array of unique commands ordered by most recent + */ +export async function getCommandHistory( + hostId: number, +): Promise { + try { + const response = await authApi.get(`/terminal/command_history/${hostId}`); + return response.data; + } catch (error) { + throw handleApiError(error, "fetch command history"); + } +} + +/** + * Delete a specific command from history + */ +export async function deleteCommandFromHistory( + hostId: number, + command: string, +): Promise<{ success: boolean }> { + try { + const response = await authApi.post("/terminal/command_history/delete", { + hostId, + command, + }); + return response.data; + } catch (error) { + throw handleApiError(error, "delete command from history"); + } +} + +/** + * Clear command history for a specific host (optional feature) + */ +export async function clearCommandHistory( + hostId: number, +): Promise<{ success: boolean }> { + try { + const response = await authApi.delete(`/terminal/command_history/${hostId}`); + return response.data; + } catch (error) { + throw handleApiError(error, "clear command history"); + } +} -- 2.49.1 From cfed3deffb175511d2b994a56ff698bb71ed6cfb Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 20:19:58 +0800 Subject: [PATCH 38/42] feat: Add file compression feature with friendly error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive file compression functionality to file manager: Compression features: - Support multiple formats: zip, tar.gz, tar.bz2, tar.xz, 7z - Compress single or multiple files/folders - Interactive compression dialog with format selection - Preview of files to be compressed - Context menu integration (right-click to compress) Error handling improvements: - Detect missing compression tools on remote server - Provide friendly error messages with installation instructions - Format-specific tool detection (zip, tar, 7z, unrar, etc.) - Clear guidance for users when tools are unavailable UI enhancements: - Multi-language support (English/Chinese) - Real-time compression progress feedback - Smart default format selection 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/backend/ssh/file-manager.ts | 198 +++++++++++++++++- src/locales/en/translation.json | 12 ++ src/locales/zh/translation.json | 12 ++ .../desktop/apps/file-manager/FileManager.tsx | 53 +++++ .../file-manager/FileManagerContextMenu.tsx | 14 ++ .../components/CompressDialog.tsx | 148 +++++++++++++ 6 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 src/ui/desktop/apps/file-manager/components/CompressDialog.tsx diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 6ec53e3e..f061c721 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -2681,9 +2681,45 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => { exitCode: code, error: errorOutput, }); - return res.status(500).json({ - error: errorOutput || "Failed to extract archive" - }); + + // Check if command not found + let friendlyError = errorOutput || "Failed to extract archive"; + if (errorOutput.includes("command not found") || errorOutput.includes("not found")) { + // Detect which command is missing based on file extension + let missingCmd = ""; + let installHint = ""; + + if (fileExt.endsWith(".zip")) { + missingCmd = "unzip"; + installHint = "apt install unzip / yum install unzip / brew install unzip"; + } else if (fileExt.endsWith(".tar.gz") || fileExt.endsWith(".tgz") || + fileExt.endsWith(".tar.bz2") || fileExt.endsWith(".tbz2") || + fileExt.endsWith(".tar.xz") || fileExt.endsWith(".tar")) { + missingCmd = "tar"; + installHint = "Usually pre-installed on Linux/Unix systems"; + } else if (fileExt.endsWith(".gz")) { + missingCmd = "gunzip"; + installHint = "apt install gzip / yum install gzip / Usually pre-installed"; + } else if (fileExt.endsWith(".bz2")) { + missingCmd = "bunzip2"; + installHint = "apt install bzip2 / yum install bzip2 / brew install bzip2"; + } else if (fileExt.endsWith(".xz")) { + missingCmd = "unxz"; + installHint = "apt install xz-utils / yum install xz / brew install xz"; + } else if (fileExt.endsWith(".7z")) { + missingCmd = "7z"; + installHint = "apt install p7zip-full / yum install p7zip / brew install p7zip"; + } else if (fileExt.endsWith(".rar")) { + missingCmd = "unrar"; + installHint = "apt install unrar / yum install unrar / brew install unrar"; + } + + if (missingCmd) { + friendlyError = `Command '${missingCmd}' not found on remote server. Please install it first: ${installHint}`; + } + } + + return res.status(500).json({ error: friendlyError }); } fileLogger.success("Archive extracted successfully", { @@ -2713,6 +2749,162 @@ app.post("/ssh/file_manager/ssh/extractArchive", async (req, res) => { }); }); +// Route: Compress files/folders (requires JWT) +// POST /ssh/file_manager/ssh/compressFiles +app.post("/ssh/file_manager/ssh/compressFiles", async (req, res) => { + const { sessionId, paths, archiveName, format } = req.body; + + if (!sessionId || !paths || !Array.isArray(paths) || paths.length === 0 || !archiveName) { + return res.status(400).json({ error: "Missing required parameters" }); + } + + const session = sshSessions[sessionId]; + if (!session || !session.isConnected) { + return res.status(400).json({ error: "SSH session not connected" }); + } + + session.lastActive = Date.now(); + scheduleSessionCleanup(sessionId); + + // Determine compression format + const compressionFormat = format || "zip"; // Default to zip + let compressCommand = ""; + + // Get the directory where the first file is located + const firstPath = paths[0]; + const workingDir = firstPath.substring(0, firstPath.lastIndexOf("/")) || "/"; + + // Extract just the file/folder names for the command + const fileNames = paths.map(p => { + const name = p.split("/").pop(); + return `"${name}"`; + }).join(" "); + + // Construct archive path + let archivePath = ""; + if (archiveName.includes("/")) { + archivePath = archiveName; + } else { + archivePath = workingDir.endsWith("/") + ? `${workingDir}${archiveName}` + : `${workingDir}/${archiveName}`; + } + + if (compressionFormat === "zip") { + // Use zip command - need to cd to directory first + compressCommand = `cd "${workingDir}" && zip -r "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "tar.gz" || compressionFormat === "tgz") { + compressCommand = `cd "${workingDir}" && tar -czf "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "tar.bz2" || compressionFormat === "tbz2") { + compressCommand = `cd "${workingDir}" && tar -cjf "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "tar.xz") { + compressCommand = `cd "${workingDir}" && tar -cJf "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "tar") { + compressCommand = `cd "${workingDir}" && tar -cf "${archivePath}" ${fileNames}`; + } else if (compressionFormat === "7z") { + compressCommand = `cd "${workingDir}" && 7z a "${archivePath}" ${fileNames}`; + } else { + return res.status(400).json({ error: "Unsupported compression format" }); + } + + fileLogger.info("Compressing files", { + operation: "compress_files", + sessionId, + paths, + archivePath, + format: compressionFormat, + command: compressCommand, + }); + + session.client.exec(compressCommand, (err, stream) => { + if (err) { + fileLogger.error("SSH exec error during compress:", err, { + operation: "compress_files", + sessionId, + paths, + }); + return res.status(500).json({ error: "Failed to execute compress command" }); + } + + let errorOutput = ""; + + stream.on("data", (data: Buffer) => { + fileLogger.debug("Compress stdout", { + operation: "compress_files", + sessionId, + output: data.toString(), + }); + }); + + stream.stderr.on("data", (data: Buffer) => { + errorOutput += data.toString(); + fileLogger.debug("Compress stderr", { + operation: "compress_files", + sessionId, + error: data.toString(), + }); + }); + + stream.on("close", (code: number) => { + if (code !== 0) { + fileLogger.error("Compress command failed", { + operation: "compress_files", + sessionId, + paths, + archivePath, + exitCode: code, + error: errorOutput, + }); + + // Check if command not found + let friendlyError = errorOutput || "Failed to compress files"; + if (errorOutput.includes("command not found") || errorOutput.includes("not found")) { + const commandMap: Record = { + "zip": { cmd: "zip", install: "apt install zip / yum install zip / brew install zip" }, + "tar.gz": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems" }, + "tar.bz2": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems" }, + "tar.xz": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems" }, + "tar": { cmd: "tar", install: "Usually pre-installed on Linux/Unix systems" }, + "7z": { cmd: "7z", install: "apt install p7zip-full / yum install p7zip / brew install p7zip" }, + }; + + const info = commandMap[compressionFormat]; + if (info) { + friendlyError = `Command '${info.cmd}' not found on remote server. Please install it first: ${info.install}`; + } + } + + return res.status(500).json({ error: friendlyError }); + } + + fileLogger.success("Files compressed successfully", { + operation: "compress_files", + sessionId, + paths, + archivePath, + format: compressionFormat, + }); + + res.json({ + success: true, + message: "Files compressed successfully", + archivePath: archivePath + }); + }); + + stream.on("error", (streamErr) => { + fileLogger.error("SSH compressFiles stream error:", streamErr, { + operation: "compress_files", + sessionId, + paths, + }); + if (!res.headersSent) { + res.status(500).json({ error: "Stream error while compressing files" }); + } + }); + }); +}); + process.on("SIGINT", () => { Object.keys(sshSessions).forEach(cleanupSession); process.exit(0); diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index 3637167e..6d4fe887 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -907,6 +907,18 @@ "extractingArchive": "Extracting {{name}}...", "archiveExtractedSuccessfully": "{{name}} extracted successfully", "extractFailed": "Extract failed", + "compressFile": "Compress File", + "compressFiles": "Compress Files", + "compressFilesDesc": "Compress {{count}} items into an archive", + "archiveName": "Archive Name", + "enterArchiveName": "Enter archive name...", + "compressionFormat": "Compression Format", + "selectedFiles": "Selected files", + "andMoreFiles": "and {{count}} more...", + "compress": "Compress", + "compressingFiles": "Compressing {{count}} items into {{name}}...", + "filesCompressedSuccessfully": "{{name}} created successfully", + "compressFailed": "Compression failed", "edit": "Edit", "preview": "Preview", "previous": "Previous", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index 665c2626..615d1230 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -919,6 +919,18 @@ "extractingArchive": "正在解压 {{name}}...", "archiveExtractedSuccessfully": "{{name}} 解压成功", "extractFailed": "解压失败", + "compressFile": "压缩文件", + "compressFiles": "压缩文件", + "compressFilesDesc": "将 {{count}} 个项目压缩为归档文件", + "archiveName": "归档文件名", + "enterArchiveName": "输入归档文件名...", + "compressionFormat": "压缩格式", + "selectedFiles": "已选文件", + "andMoreFiles": "以及其他 {{count}} 个...", + "compress": "压缩", + "compressingFiles": "正在将 {{count}} 个项目压缩到 {{name}}...", + "filesCompressedSuccessfully": "{{name}} 创建成功", + "compressFailed": "压缩失败", "edit": "编辑", "preview": "预览", "previous": "上一页", diff --git a/src/ui/desktop/apps/file-manager/FileManager.tsx b/src/ui/desktop/apps/file-manager/FileManager.tsx index 6d981f76..8cab1ec6 100644 --- a/src/ui/desktop/apps/file-manager/FileManager.tsx +++ b/src/ui/desktop/apps/file-manager/FileManager.tsx @@ -17,6 +17,7 @@ import { useTranslation } from "react-i18next"; import { TOTPDialog } from "@/ui/desktop/navigation/TOTPDialog.tsx"; import { SSHAuthDialog } from "@/ui/desktop/navigation/SSHAuthDialog.tsx"; import { PermissionsDialog } from "./components/PermissionsDialog"; +import { CompressDialog } from "./components/CompressDialog"; import { Upload, FolderPlus, @@ -52,6 +53,7 @@ import { logActivity, changeSSHPermissions, extractSSHArchive, + compressSSHFiles, } from "@/ui/main-axios.ts"; import type { SidebarItem } from "./FileManagerSidebar"; @@ -150,6 +152,7 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { const [createIntent, setCreateIntent] = useState(null); const [editingFile, setEditingFile] = useState(null); const [permissionsDialogFile, setPermissionsDialogFile] = useState(null); + const [compressDialogFiles, setCompressDialogFiles] = useState([]); const { selectedFiles, clearSelection, setSelection } = useFileSelection(); @@ -1090,6 +1093,48 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { } } + function handleOpenCompressDialog(files: FileItem[]) { + setCompressDialogFiles(files); + } + + async function handleCompress(archiveName: string, format: string) { + if (!sshSessionId || compressDialogFiles.length === 0) return; + + try { + await ensureSSHConnection(); + + const paths = compressDialogFiles.map(f => f.path); + const fileNames = compressDialogFiles.map(f => f.name); + + toast.info(t("fileManager.compressingFiles", { + count: fileNames.length, + name: archiveName + })); + + await compressSSHFiles( + sshSessionId, + paths, + archiveName, + format, + currentHost?.id, + currentHost?.userId?.toString(), + ); + + toast.success(t("fileManager.filesCompressedSuccessfully", { + name: archiveName + })); + + // Refresh directory to show compressed file + handleRefreshDirectory(); + clearSelection(); + } catch (error: unknown) { + const err = error as { message?: string }; + toast.error( + `${t("fileManager.compressFailed")}: ${err.message || t("fileManager.unknownError")}`, + ); + } + } + async function handleUndo() { if (undoHistory.length === 0) { toast.info(t("fileManager.noUndoableActions")); @@ -2030,10 +2075,18 @@ function FileManagerContent({ initialHost, onClose }: FileManagerProps) { currentPath={currentPath} onProperties={handleOpenPermissionsDialog} onExtractArchive={handleExtractArchive} + onCompress={handleOpenCompressDialog} />
+ 0} + onOpenChange={(open) => !open && setCompressDialogFiles([])} + fileNames={compressDialogFiles.map(f => f.name)} + onCompress={handleCompress} + /> + boolean; currentPath?: string; onExtractArchive?: (file: FileItem) => void; + onCompress?: (files: FileItem[]) => void; } interface MenuItem { @@ -102,6 +103,7 @@ export function FileManagerContextMenu({ isPinned, currentPath, onExtractArchive, + onCompress, }: ContextMenuProps) { const { t } = useTranslation(); const [menuPosition, setMenuPosition] = useState({ x, y }); @@ -284,6 +286,18 @@ export function FileManagerContextMenu({ } } + // Add compress option for selected files/folders + if (isFileContext && onCompress) { + menuItems.push({ + icon: , + label: isMultipleFiles + ? t("fileManager.compressFiles") + : t("fileManager.compressFile"), + action: () => onCompress(files), + shortcut: "Ctrl+Shift+C", + }); + } + if (isSingleFile && files[0].type === "file") { const isCurrentlyPinned = isPinned ? isPinned(files[0]) : false; diff --git a/src/ui/desktop/apps/file-manager/components/CompressDialog.tsx b/src/ui/desktop/apps/file-manager/components/CompressDialog.tsx new file mode 100644 index 00000000..5cf8ba3c --- /dev/null +++ b/src/ui/desktop/apps/file-manager/components/CompressDialog.tsx @@ -0,0 +1,148 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { useTranslation } from "react-i18next"; + +interface CompressDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + fileNames: string[]; + onCompress: (archiveName: string, format: string) => void; +} + +export function CompressDialog({ + open, + onOpenChange, + fileNames, + onCompress, +}: CompressDialogProps) { + const { t } = useTranslation(); + const [archiveName, setArchiveName] = useState(""); + const [format, setFormat] = useState("zip"); + + useEffect(() => { + if (open && fileNames.length > 0) { + // Generate default archive name + if (fileNames.length === 1) { + const baseName = fileNames[0].replace(/\.[^/.]+$/, ""); + setArchiveName(baseName); + } else { + setArchiveName("archive"); + } + } + }, [open, fileNames]); + + const handleCompress = () => { + if (!archiveName.trim()) return; + + // Append extension if not already present + let finalName = archiveName.trim(); + const extensions: Record = { + zip: ".zip", + "tar.gz": ".tar.gz", + "tar.bz2": ".tar.bz2", + "tar.xz": ".tar.xz", + tar: ".tar", + "7z": ".7z", + }; + + const expectedExtension = extensions[format]; + if (expectedExtension && !finalName.endsWith(expectedExtension)) { + finalName += expectedExtension; + } + + onCompress(finalName, format); + onOpenChange(false); + }; + + return ( + + + + {t("fileManager.compressFiles")} + + {t("fileManager.compressFilesDesc", { count: fileNames.length })} + + + +
+
+ + setArchiveName(e.target.value)} + placeholder={t("fileManager.enterArchiveName")} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleCompress(); + } + }} + /> +
+ +
+ + +
+ +
+

+ {t("fileManager.selectedFiles")}: +

+
    + {fileNames.slice(0, 5).map((name, index) => ( +
  • + • {name} +
  • + ))} + {fileNames.length > 5 && ( +
  • + {t("fileManager.andMoreFiles", { count: fileNames.length - 5 })} +
  • + )} +
+
+
+ + + + + +
+
+ ); +} -- 2.49.1 From bc02acf650d9d25c4fea907c74afc00ca72d6352 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 21:00:31 +0800 Subject: [PATCH 39/42] feat: Add professional glitch-effect loading animation with minimum duration control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement unified LoadingOverlay component with advanced glitch effects: Animation features: - Glitch text effect with RGB chromatic aberration - Dynamic clip-path based text slicing animations - Random flicker and screen tearing effects - Horizontal glitch block artifacts - CRT-style scan line with color gradient - Fractal noise overlay for authenticity Technical improvements: - Minimum display duration (600-800ms) to prevent flickering - Smooth fade-in/fade-out transitions - Consistent TERMIX branding across all loading states - Multiple animation layers with different timing - Mix-blend-mode and advanced CSS filters Applied to: - SSH terminal connection loading - File manager directory loading - Server metrics loading Brand enhancement: - Uses TERMIX monospace typography - Cyberpunk-style visual effects - Professional loading experience - Stronger brand recognition 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/ui/components/LoadingOverlay.tsx | 484 ++++++++++++++++++ .../apps/file-manager/FileManagerGrid.tsx | 21 +- src/ui/desktop/apps/server/Server.tsx | 21 +- src/ui/desktop/apps/terminal/Terminal.tsx | 19 +- 4 files changed, 511 insertions(+), 34 deletions(-) create mode 100644 src/ui/components/LoadingOverlay.tsx diff --git a/src/ui/components/LoadingOverlay.tsx b/src/ui/components/LoadingOverlay.tsx new file mode 100644 index 00000000..448ed382 --- /dev/null +++ b/src/ui/components/LoadingOverlay.tsx @@ -0,0 +1,484 @@ +import React, { useEffect, useState, useRef } from "react"; +import { cn } from "@/lib/utils"; + +interface LoadingOverlayProps { + visible: boolean; + minDuration?: number; // Minimum display duration in milliseconds + message?: string; + showLogo?: boolean; + className?: string; + backgroundColor?: string; +} + +export function LoadingOverlay({ + visible, + minDuration = 800, + message, + showLogo = true, + className, + backgroundColor, +}: LoadingOverlayProps) { + const [isShowing, setIsShowing] = useState(false); + const [isFadingOut, setIsFadingOut] = useState(false); + const showStartTimeRef = useRef(null); + const minDurationTimerRef = useRef(null); + + useEffect(() => { + if (visible) { + // Start showing immediately + setIsShowing(true); + setIsFadingOut(false); + showStartTimeRef.current = Date.now(); + + // Clear any existing timer + if (minDurationTimerRef.current) { + clearTimeout(minDurationTimerRef.current); + minDurationTimerRef.current = null; + } + } else if (isShowing) { + // Calculate how long it has been showing + const elapsed = showStartTimeRef.current + ? Date.now() - showStartTimeRef.current + : 0; + const remaining = Math.max(0, minDuration - elapsed); + + if (remaining > 0) { + // Wait for minimum duration before hiding + minDurationTimerRef.current = setTimeout(() => { + setIsFadingOut(true); + // Wait for fade-out animation to complete + setTimeout(() => { + setIsShowing(false); + setIsFadingOut(false); + showStartTimeRef.current = null; + }, 300); // Match fade-out duration + }, remaining); + } else { + // Minimum duration already passed, hide immediately + setIsFadingOut(true); + setTimeout(() => { + setIsShowing(false); + setIsFadingOut(false); + showStartTimeRef.current = null; + }, 300); + } + } + + return () => { + if (minDurationTimerRef.current) { + clearTimeout(minDurationTimerRef.current); + minDurationTimerRef.current = null; + } + }; + }, [visible, isShowing, minDuration]); + + if (!isShowing) { + return null; + } + + return ( + <> + + +
+
+
+ +
+
+ {/* TERMIX Glitch Text */} +
+ TERMIX +
+ + {/* Scan line effect */} +
+
+ + {message && ( +
+

+ {message} +

+
+ )} +
+
+ + ); +} diff --git a/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx b/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx index 5c1a0ac8..25c14cf4 100644 --- a/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx +++ b/src/ui/desktop/apps/file-manager/FileManagerGrid.tsx @@ -24,6 +24,7 @@ import { } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { FileItem } from "../../../types/index.js"; +import { LoadingOverlay } from "@/ui/components/LoadingOverlay"; interface CreateIntent { id: string; @@ -871,19 +872,8 @@ export function FileManagerGrid({ onUndo, ]); - if (isLoading) { - return ( -
-
-
-

{t("common.loading")}

-
-
- ); - } - return ( -
+
); } diff --git a/src/ui/desktop/apps/server/Server.tsx b/src/ui/desktop/apps/server/Server.tsx index 21879e0f..4cf5be8c 100644 --- a/src/ui/desktop/apps/server/Server.tsx +++ b/src/ui/desktop/apps/server/Server.tsx @@ -17,6 +17,7 @@ import { type StatsConfig, DEFAULT_STATS_CONFIG, } from "@/types/stats-widgets"; +import { LoadingOverlay } from "@/ui/components/LoadingOverlay"; import { CpuWidget, MemoryWidget, @@ -443,17 +444,8 @@ export function Server({
{metricsEnabled && showStatsUI && ( -
- {isLoadingMetrics && !metrics ? ( -
-
-
- - {t("serverStats.loadingMetrics")} - -
-
- ) : !metrics && serverStatus === "offline" ? ( +
+ {!metrics && serverStatus === "offline" ? (
@@ -476,6 +468,13 @@ export function Server({ ))}
)} + +
)} diff --git a/src/ui/desktop/apps/terminal/Terminal.tsx b/src/ui/desktop/apps/terminal/Terminal.tsx index 8b5d5eff..21f0e4ad 100644 --- a/src/ui/desktop/apps/terminal/Terminal.tsx +++ b/src/ui/desktop/apps/terminal/Terminal.tsx @@ -31,6 +31,7 @@ import { useCommandTracker } from "@/ui/hooks/useCommandTracker"; import { useCommandHistory } from "@/ui/hooks/useCommandHistory"; import { CommandHistoryDialog } from "./CommandHistoryDialog"; import { CommandAutocomplete } from "./CommandAutocomplete"; +import { LoadingOverlay } from "@/ui/components/LoadingOverlay"; interface HostConfig { id?: number; @@ -1450,17 +1451,13 @@ export const Terminal = forwardRef( onSelect={handleAutocompleteSelect} /> - {isConnecting && ( -
-
-
- {t("terminal.connecting")} -
-
- )} +
); }, -- 2.49.1 From a02f362fb05fc9d1db1d8e101c2f1070e2a841f7 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 21:11:06 +0800 Subject: [PATCH 40/42] feat: Add breathe light effect as alternate loading animation with random selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement dual-animation system with random selection: Breathe animation features: - Dynamic pulsing glow effect with 5-layer light halo - Sequential letter appearance with elastic bounce - Continuous floating animation for letters - Circular pulse rings expanding outward (3 colors: blue, purple, cyan) - Orbiting light dots on circular path (4 dots) - Particle burst effect in 8 directions - Text scaling with breathing rhythm Technical details: - 50/50 random selection between Glitch and Breathe on each load - Breathe cycle: 2.5s with smooth easing - Letter entrance: 0.8s with cubic-bezier bounce - Circular pulse rings to avoid visual artifacts - Optimized animation timing for smooth performance Visual improvements: - Stronger glow intensity (up to 150px radius) - Brightness variation (1.1 to 1.3) - Multi-colored effects (blue, purple, cyan gradient) - Smooth particle dispersal - Professional breathing rhythm 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/ui/components/LoadingOverlay.tsx | 300 +++++++++++++++++++++++++-- 1 file changed, 277 insertions(+), 23 deletions(-) diff --git a/src/ui/components/LoadingOverlay.tsx b/src/ui/components/LoadingOverlay.tsx index 448ed382..c619cd0a 100644 --- a/src/ui/components/LoadingOverlay.tsx +++ b/src/ui/components/LoadingOverlay.tsx @@ -20,11 +20,15 @@ export function LoadingOverlay({ }: LoadingOverlayProps) { const [isShowing, setIsShowing] = useState(false); const [isFadingOut, setIsFadingOut] = useState(false); + const [animationType, setAnimationType] = useState<'glitch' | 'breathe'>('glitch'); const showStartTimeRef = useRef(null); const minDurationTimerRef = useRef(null); useEffect(() => { if (visible) { + // Randomly choose animation type + setAnimationType(Math.random() > 0.5 ? 'glitch' : 'breathe'); + // Start showing immediately setIsShowing(true); setIsFadingOut(false); @@ -439,6 +443,198 @@ export function LoadingOverlay({ transform: translateX(0); } } + + /* Breathe Animation Styles */ + @keyframes breathe-glow { + 0%, 100% { + text-shadow: + 0 0 20px rgba(59, 130, 246, 0.8), + 0 0 40px rgba(59, 130, 246, 0.6), + 0 0 60px rgba(59, 130, 246, 0.4), + 0 0 80px rgba(59, 130, 246, 0.3), + 0 0 100px rgba(59, 130, 246, 0.2); + filter: brightness(1.1); + transform: scale(1); + } + 50% { + text-shadow: + 0 0 30px rgba(59, 130, 246, 1), + 0 0 60px rgba(59, 130, 246, 0.8), + 0 0 90px rgba(59, 130, 246, 0.6), + 0 0 120px rgba(59, 130, 246, 0.4), + 0 0 150px rgba(59, 130, 246, 0.3); + filter: brightness(1.3); + transform: scale(1.05); + } + } + + @keyframes letter-appear { + 0% { + opacity: 0; + transform: translateY(30px) scale(0.5); + filter: blur(10px); + } + 60% { + transform: translateY(-5px) scale(1.05); + } + 100% { + opacity: 1; + transform: translateY(0) scale(1); + filter: blur(0); + } + } + + @keyframes letter-float { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-8px); + } + } + + .breathe-container { + position: relative; + } + + .breathe-text { + position: relative; + color: #fff; + animation: breathe-glow 2.5s ease-in-out infinite; + } + + .breathe-text .letter { + display: inline-block; + opacity: 0; + animation: + letter-appear 0.8s cubic-bezier(0.34, 1.56, 0.64, 1) forwards, + letter-float 3s ease-in-out infinite; + } + + .breathe-text .letter:nth-child(1) { + animation-delay: 0s, 0.6s; + } + .breathe-text .letter:nth-child(2) { + animation-delay: 0.08s, 0.68s; + } + .breathe-text .letter:nth-child(3) { + animation-delay: 0.16s, 0.76s; + } + .breathe-text .letter:nth-child(4) { + animation-delay: 0.24s, 0.84s; + } + .breathe-text .letter:nth-child(5) { + animation-delay: 0.32s, 0.92s; + } + .breathe-text .letter:nth-child(6) { + animation-delay: 0.4s, 1s; + } + + @keyframes pulse-ring { + 0% { + transform: scale(0.8); + opacity: 0; + border-width: 3px; + } + 50% { + opacity: 0.6; + } + 100% { + transform: scale(1.5); + opacity: 0; + border-width: 0px; + } + } + + .pulse-ring { + position: absolute; + inset: -30px; + border: 2px solid rgba(59, 130, 246, 0.6); + border-radius: 50%; + animation: pulse-ring 2.5s ease-out infinite; + pointer-events: none; + } + + .pulse-ring:nth-child(2) { + animation-delay: 0.8s; + border-color: rgba(139, 92, 246, 0.5); + } + + .pulse-ring:nth-child(3) { + animation-delay: 1.6s; + border-color: rgba(6, 182, 212, 0.5); + } + + @keyframes orbit-dots { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + .orbit-container { + position: absolute; + inset: -60px; + animation: orbit-dots 8s linear infinite; + pointer-events: none; + } + + .orbit-dot { + position: absolute; + width: 6px; + height: 6px; + background: radial-gradient(circle, rgba(59, 130, 246, 1) 0%, rgba(59, 130, 246, 0.3) 100%); + border-radius: 50%; + box-shadow: 0 0 10px rgba(59, 130, 246, 0.8); + } + + .orbit-dot:nth-child(1) { top: 0; left: 50%; transform: translateX(-50%); } + .orbit-dot:nth-child(2) { top: 50%; right: 0; transform: translateY(-50%); } + .orbit-dot:nth-child(3) { bottom: 0; left: 50%; transform: translateX(-50%); } + .orbit-dot:nth-child(4) { top: 50%; left: 0; transform: translateY(-50%); } + + @keyframes particle-float { + 0% { + transform: translate(0, 0) scale(0); + opacity: 0; + } + 10% { + opacity: 1; + } + 90% { + opacity: 1; + } + 100% { + transform: translate(var(--tx), var(--ty)) scale(1); + opacity: 0; + } + } + + .particles { + position: absolute; + inset: 0; + pointer-events: none; + } + + .particle { + position: absolute; + width: 4px; + height: 4px; + background: radial-gradient(circle, rgba(59, 130, 246, 1) 0%, transparent 70%); + border-radius: 50%; + animation: particle-float 3s ease-out infinite; + } + + .particle:nth-child(1) { left: 50%; top: 50%; --tx: -80px; --ty: -80px; animation-delay: 0s; } + .particle:nth-child(2) { left: 50%; top: 50%; --tx: 80px; --ty: -80px; animation-delay: 0.3s; } + .particle:nth-child(3) { left: 50%; top: 50%; --tx: -80px; --ty: 80px; animation-delay: 0.6s; } + .particle:nth-child(4) { left: 50%; top: 50%; --tx: 80px; --ty: 80px; animation-delay: 0.9s; } + .particle:nth-child(5) { left: 50%; top: 50%; --tx: 0px; --ty: -100px; animation-delay: 0.15s; } + .particle:nth-child(6) { left: 50%; top: 50%; --tx: 0px; --ty: 100px; animation-delay: 0.45s; } + .particle:nth-child(7) { left: 50%; top: 50%; --tx: -100px; --ty: 0px; animation-delay: 0.75s; } + .particle:nth-child(8) { left: 50%; top: 50%; --tx: 100px; --ty: 0px; animation-delay: 1.05s; } `} @@ -450,34 +646,92 @@ export function LoadingOverlay({ )} style={{ backgroundColor: backgroundColor || "rgba(0, 0, 0, 0.92)" }} > -
-
+ {animationType === 'glitch' ? ( + <> +
+
-
-
- {/* TERMIX Glitch Text */} -
- TERMIX +
+
+ {/* TERMIX Glitch Text */} +
+ TERMIX +
+ + {/* Scan line effect */} +
+
+ + {message && ( +
+

+ {message} +

+
+ )}
+ + ) : ( + <> +
+
+ {/* Pulse rings */} +
+
+
- {/* Scan line effect */} -
-
+ {/* Orbiting dots */} +
+
+
+
+
+
- {message && ( -
-

- {message} -

+ {/* Particles */} +
+
+
+
+
+
+
+
+
+
+ + {/* TERMIX Breathe Text */} +
+ T + E + R + M + I + X +
+
+ + {message && ( +
+

+ {message} +

+
+ )}
- )} -
+ + )}
); -- 2.49.1 From 582cdd6d0286de2eecea19ef025cefc59223fbe2 Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Sun, 9 Nov 2025 21:18:01 +0800 Subject: [PATCH 41/42] feat: Add three new loading animations - Typewriter, Scanner, and Pulse Added three distinct loading animation styles to enhance brand presence: 1. Typewriter Effect - Letters appear sequentially like terminal typing - Includes blinking cursor animation - Reinforces terminal tool positioning - 150ms delay between each letter 2. Scanner Effect - Vertical and horizontal scan lines passing through text - Grid background with cyan glow effects - Hacker movie aesthetic - Multiple scan lines with different colors (cyan/magenta) 3. Pulse Ripple Effect - Expanding wave rings from center outward - Radar sweep effect rotating around text - Center dot with glow - 5 concentric waves with different delays - Modern tech feel Random Selection: - All 5 animations (Glitch, Breathe, Typewriter, Scanner, Pulse) now have equal 20% chance - Selection happens on each loading state trigger - Provides visual variety across application Technical Details: - CSS keyframe animations for all effects - Step-based typewriter timing - Linear scan line movements - Ease-out wave expansion - Consistent TERMIX monospace font across all styles --- src/ui/components/LoadingOverlay.tsx | 399 ++++++++++++++++++++++++++- 1 file changed, 395 insertions(+), 4 deletions(-) diff --git a/src/ui/components/LoadingOverlay.tsx b/src/ui/components/LoadingOverlay.tsx index c619cd0a..a660cdab 100644 --- a/src/ui/components/LoadingOverlay.tsx +++ b/src/ui/components/LoadingOverlay.tsx @@ -20,14 +20,18 @@ export function LoadingOverlay({ }: LoadingOverlayProps) { const [isShowing, setIsShowing] = useState(false); const [isFadingOut, setIsFadingOut] = useState(false); - const [animationType, setAnimationType] = useState<'glitch' | 'breathe'>('glitch'); + const [animationType, setAnimationType] = useState<'glitch' | 'breathe' | 'typewriter' | 'scanner' | 'pulse'>('glitch'); const showStartTimeRef = useRef(null); const minDurationTimerRef = useRef(null); useEffect(() => { if (visible) { - // Randomly choose animation type - setAnimationType(Math.random() > 0.5 ? 'glitch' : 'breathe'); + // Randomly choose animation type from 5 options + const animations: ('glitch' | 'breathe' | 'typewriter' | 'scanner' | 'pulse')[] = [ + 'glitch', 'breathe', 'typewriter', 'scanner', 'pulse' + ]; + const randomIndex = Math.floor(Math.random() * 5); + setAnimationType(animations[randomIndex]); // Start showing immediately setIsShowing(true); @@ -635,6 +639,292 @@ export function LoadingOverlay({ .particle:nth-child(6) { left: 50%; top: 50%; --tx: 0px; --ty: 100px; animation-delay: 0.45s; } .particle:nth-child(7) { left: 50%; top: 50%; --tx: -100px; --ty: 0px; animation-delay: 0.75s; } .particle:nth-child(8) { left: 50%; top: 50%; --tx: 100px; --ty: 0px; animation-delay: 1.05s; } + + /* Typewriter Animation Styles */ + @keyframes type-letter { + 0% { + opacity: 0; + transform: translateY(10px); + } + 100% { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes cursor-blink { + 0%, 49% { + opacity: 1; + background-color: rgba(59, 130, 246, 1); + } + 50%, 100% { + opacity: 0; + background-color: rgba(59, 130, 246, 0); + } + } + + .typewriter-container { + position: relative; + } + + .typewriter-text { + color: #fff; + display: inline-flex; + } + + .typewriter-text .type-letter { + display: inline-block; + opacity: 0; + animation: type-letter 0.1s forwards; + } + + .typewriter-text .type-letter:nth-child(1) { animation-delay: 0s; } + .typewriter-text .type-letter:nth-child(2) { animation-delay: 0.15s; } + .typewriter-text .type-letter:nth-child(3) { animation-delay: 0.3s; } + .typewriter-text .type-letter:nth-child(4) { animation-delay: 0.45s; } + .typewriter-text .type-letter:nth-child(5) { animation-delay: 0.6s; } + .typewriter-text .type-letter:nth-child(6) { animation-delay: 0.75s; } + + .typing-cursor { + display: inline-block; + width: 3px; + height: 1em; + margin-left: 4px; + background-color: rgba(59, 130, 246, 1); + animation: cursor-blink 1s infinite; + animation-delay: 0.9s; + } + + /* Scanner Animation Styles */ + @keyframes vertical-scan { + 0% { + top: -20%; + } + 100% { + top: 120%; + } + } + + @keyframes horizontal-scan { + 0% { + left: -20%; + } + 100% { + left: 120%; + } + } + + @keyframes scanner-glow { + 0%, 100% { + text-shadow: + 0 0 10px rgba(0, 255, 255, 0.3), + 0 0 20px rgba(0, 255, 255, 0.2); + } + 50% { + text-shadow: + 0 0 30px rgba(0, 255, 255, 1), + 0 0 60px rgba(0, 255, 255, 0.8), + 0 0 90px rgba(0, 255, 255, 0.6), + 0 0 120px rgba(0, 255, 255, 0.4); + } + } + + .scanner-container { + position: relative; + overflow: hidden; + } + + .scanner-text { + color: #fff; + animation: scanner-glow 3s ease-in-out infinite; + position: relative; + z-index: 1; + } + + .vertical-scan-line { + position: absolute; + width: 100%; + height: 80px; + left: 0; + background: linear-gradient( + to bottom, + transparent 0%, + rgba(0, 255, 255, 0.1) 20%, + rgba(0, 255, 255, 0.8) 50%, + rgba(0, 255, 255, 0.1) 80%, + transparent 100% + ); + animation: vertical-scan 3s linear infinite; + z-index: 2; + pointer-events: none; + box-shadow: 0 0 30px rgba(0, 255, 255, 0.6); + } + + .vertical-scan-line:nth-child(2) { + animation-delay: 1.5s; + background: linear-gradient( + to bottom, + transparent 0%, + rgba(255, 0, 255, 0.1) 20%, + rgba(255, 0, 255, 0.6) 50%, + rgba(255, 0, 255, 0.1) 80%, + transparent 100% + ); + box-shadow: 0 0 30px rgba(255, 0, 255, 0.5); + } + + .horizontal-scan-line { + position: absolute; + width: 80px; + height: 100%; + top: 0; + background: linear-gradient( + to right, + transparent 0%, + rgba(0, 255, 255, 0.1) 20%, + rgba(0, 255, 255, 0.5) 50%, + rgba(0, 255, 255, 0.1) 80%, + transparent 100% + ); + animation: horizontal-scan 2.5s linear infinite; + z-index: 2; + pointer-events: none; + box-shadow: 0 0 30px rgba(0, 255, 255, 0.4); + } + + .scanner-grid { + position: absolute; + inset: -40px; + background-image: + linear-gradient(rgba(0, 255, 255, 0.1) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 255, 255, 0.1) 1px, transparent 1px); + background-size: 20px 20px; + opacity: 0.3; + pointer-events: none; + } + + /* Pulse Ripple Animation Styles */ + @keyframes wave-expand { + 0% { + width: 80px; + height: 80px; + opacity: 1; + border-width: 4px; + } + 100% { + width: 500px; + height: 500px; + opacity: 0; + border-width: 1px; + } + } + + @keyframes pulse-text-glow { + 0%, 100% { + text-shadow: + 0 0 20px rgba(59, 130, 246, 0.8), + 0 0 40px rgba(59, 130, 246, 0.5), + 0 0 60px rgba(59, 130, 246, 0.3); + transform: scale(1); + } + 50% { + text-shadow: + 0 0 40px rgba(59, 130, 246, 1), + 0 0 80px rgba(59, 130, 246, 0.8), + 0 0 120px rgba(59, 130, 246, 0.6), + 0 0 160px rgba(59, 130, 246, 0.4); + transform: scale(1.02); + } + } + + @keyframes radar-sweep { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } + } + + .pulse-container { + position: relative; + } + + .pulse-text { + color: #fff; + animation: pulse-text-glow 2s ease-in-out infinite; + position: relative; + z-index: 10; + } + + .wave-ring { + position: absolute; + top: 50%; + left: 50%; + border: 3px solid rgba(59, 130, 246, 0.8); + border-radius: 50%; + transform: translate(-50%, -50%); + animation: wave-expand 2.5s ease-out infinite; + pointer-events: none; + } + + .wave-ring:nth-child(2) { + animation-delay: 0.5s; + border-color: rgba(139, 92, 246, 0.7); + } + + .wave-ring:nth-child(3) { + animation-delay: 1s; + border-color: rgba(6, 182, 212, 0.7); + } + + .wave-ring:nth-child(4) { + animation-delay: 1.5s; + border-color: rgba(59, 130, 246, 0.6); + } + + .wave-ring:nth-child(5) { + animation-delay: 2s; + border-color: rgba(139, 92, 246, 0.5); + } + + .radar-sweep { + position: absolute; + inset: -100px; + pointer-events: none; + animation: radar-sweep 4s linear infinite; + } + + .radar-sweep::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 2px; + height: 50%; + background: linear-gradient( + to bottom, + rgba(59, 130, 246, 0) 0%, + rgba(59, 130, 246, 0.8) 100% + ); + transform-origin: top center; + transform: translateX(-50%) translateY(-100%); + } + + .pulse-center-dot { + position: absolute; + top: 50%; + left: 50%; + width: 12px; + height: 12px; + background: radial-gradient(circle, rgba(59, 130, 246, 1) 0%, rgba(59, 130, 246, 0.3) 100%); + border-radius: 50%; + transform: translate(-50%, -50%); + box-shadow: + 0 0 20px rgba(59, 130, 246, 1), + 0 0 40px rgba(59, 130, 246, 0.8); + z-index: 5; + } `} @@ -677,7 +967,7 @@ export function LoadingOverlay({ )}
- ) : ( + ) : animationType === 'breathe' ? ( <>
@@ -722,6 +1012,107 @@ export function LoadingOverlay({
+ {message && ( +
+

+ {message} +

+
+ )} +
+ + ) : animationType === 'typewriter' ? ( + <> +
+
+ {/* TERMIX Typewriter Text */} +
+ T + E + R + M + I + X + +
+
+ + {message && ( +
+

+ {message} +

+
+ )} +
+ + ) : animationType === 'scanner' ? ( + <> +
+
+ {/* Scanner Grid Background */} +
+ + {/* Vertical Scan Lines */} +
+
+ + {/* Horizontal Scan Line */} +
+ + {/* TERMIX Scanner Text */} +
+ TERMIX +
+
+ + {message && ( +
+

+ {message} +

+
+ )} +
+ + ) : ( + <> +
+
+ {/* Wave Rings */} +
+
+
+
+
+ + {/* Radar Sweep */} +
+ + {/* Center Dot */} +
+ + {/* TERMIX Pulse Text */} +
+ TERMIX +
+
+ {message && (

-- 2.49.1 From a2d9c542e01f401fc2a259898cd04e0e4e29cbc3 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sun, 9 Nov 2025 20:40:50 -0600 Subject: [PATCH 42/42] fix: Reset loading effects --- package-lock.json | 60 +- src/ui/components/LoadingOverlay.tsx | 798 +++++++++++++++++++++++---- 2 files changed, 745 insertions(+), 113 deletions(-) diff --git a/package-lock.json b/package-lock.json index a00f83c2..61e5b7c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -154,6 +154,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -439,6 +440,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.19.1.tgz", "integrity": "sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", @@ -487,6 +489,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", @@ -513,6 +516,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/lang-css": "^6.0.0", @@ -540,6 +544,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.6.0", @@ -740,6 +745,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz", "integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", @@ -816,6 +822,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz", "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", "license": "MIT", + "peer": true, "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } @@ -837,6 +844,7 @@ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", "license": "MIT", + "peer": true, "dependencies": { "@codemirror/state": "^6.5.0", "crelt": "^1.0.6", @@ -1164,6 +1172,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1550,7 +1559,6 @@ "dev": true, "license": "BSD-2-Clause", "optional": true, - "peer": true, "dependencies": { "cross-dirname": "^0.1.0", "debug": "^4.3.4", @@ -1572,7 +1580,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2529,7 +2536,8 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.3.0.tgz", "integrity": "sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@lezer/cpp": { "version": "1.1.3", @@ -2569,6 +2577,7 @@ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "license": "MIT", + "peer": true, "dependencies": { "@lezer/common": "^1.3.0" } @@ -2600,6 +2609,7 @@ "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", "license": "MIT", + "peer": true, "dependencies": { "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.1.3", @@ -2622,6 +2632,7 @@ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", "license": "MIT", + "peer": true, "dependencies": { "@lezer/common": "^1.0.0" } @@ -4845,6 +4856,7 @@ "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -5002,6 +5014,7 @@ "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -5124,6 +5137,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.2.tgz", "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -5166,6 +5180,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5176,6 +5191,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -5343,6 +5359,7 @@ "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/types": "8.46.2", @@ -5719,7 +5736,8 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/7zip-bin": { "version": "5.2.0", @@ -5754,6 +5772,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6176,6 +6195,7 @@ "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -6310,6 +6330,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -7313,6 +7334,7 @@ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -7389,8 +7411,7 @@ "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -7849,6 +7870,7 @@ "integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.0.12", "builder-util": "26.0.11", @@ -7946,8 +7968,7 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz", "integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true + "license": "(MPL-2.0 OR Apache-2.0)" }, "node_modules/dot-prop": { "version": "5.3.0", @@ -8299,7 +8320,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "@electron/asar": "^3.2.1", "debug": "^4.1.1", @@ -8320,7 +8340,6 @@ "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", @@ -8336,7 +8355,6 @@ "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "graceful-fs": "^4.1.6" } @@ -8347,7 +8365,6 @@ "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4.0.0" } @@ -8610,6 +8627,7 @@ "integrity": "sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10208,6 +10226,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.27.6" }, @@ -11749,7 +11768,6 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -13777,7 +13795,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "commander": "^9.4.0" }, @@ -13795,7 +13812,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": "^12.20.0 || >=14" } @@ -14247,6 +14263,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14256,6 +14273,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14282,6 +14300,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.0.tgz", "integrity": "sha512-xXBqsWGKrY46ZqaHDo+ZUYiMUgi8suYu5kdrS20EG8KiL7VRQitEbNjm+UcrDYrNi1YLyfpmAeGjCZYXLT9YBw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -14429,6 +14448,7 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -14637,7 +14657,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -15958,7 +15979,6 @@ "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" @@ -15999,7 +16019,6 @@ "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "minimist": "^1.2.6" }, @@ -16014,7 +16033,6 @@ "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "glob": "^7.1.3" }, @@ -16119,6 +16137,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16324,6 +16343,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -16736,6 +16756,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.12.tgz", "integrity": "sha512-ZWyE8YXEXqJrrSLvYgrRP7p62OziLW7xI5HYGWFzOvupfAlrLvURSzv/FyGyy0eidogEM3ujU+kUG1zuHgb6Ug==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -16827,6 +16848,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/src/ui/components/LoadingOverlay.tsx b/src/ui/components/LoadingOverlay.tsx index a660cdab..d6869c8d 100644 --- a/src/ui/components/LoadingOverlay.tsx +++ b/src/ui/components/LoadingOverlay.tsx @@ -20,16 +20,22 @@ export function LoadingOverlay({ }: LoadingOverlayProps) { const [isShowing, setIsShowing] = useState(false); const [isFadingOut, setIsFadingOut] = useState(false); - const [animationType, setAnimationType] = useState<'glitch' | 'breathe' | 'typewriter' | 'scanner' | 'pulse'>('glitch'); + const [animationType, setAnimationType] = useState< + "glitch" | "breathe" | "typewriter" | "scanner" | "pulse" + >("glitch"); const showStartTimeRef = useRef(null); const minDurationTimerRef = useRef(null); useEffect(() => { if (visible) { // Randomly choose animation type from 5 options - const animations: ('glitch' | 'breathe' | 'typewriter' | 'scanner' | 'pulse')[] = [ - 'glitch', 'breathe', 'typewriter', 'scanner', 'pulse' - ]; + const animations: ( + | "glitch" + | "breathe" + | "typewriter" + | "scanner" + | "pulse" + )[] = ["glitch", "breathe", "typewriter", "scanner", "pulse"]; const randomIndex = Math.floor(Math.random() * 5); setAnimationType(animations[randomIndex]); @@ -448,7 +454,96 @@ export function LoadingOverlay({ } } - /* Breathe Animation Styles */ + /* Enhanced Glitch Fullscreen Effects */ + @keyframes rgb-split-bg { + 0%, 100% { + transform: translate(0, 0); + } + 33% { + transform: translate(-10px, 5px); + } + 66% { + transform: translate(10px, -5px); + } + } + + @keyframes signal-distort { + 0%, 100% { + clip-path: inset(0 0 0 0); + } + 10% { + clip-path: inset(20% 0 0 0); + } + 20% { + clip-path: inset(0 0 30% 0); + } + 30% { + clip-path: inset(40% 0 0 0); + } + 40% { + clip-path: inset(0 0 50% 0); + } + 50% { + clip-path: inset(0 0 0 0); + } + } + + .glitch-fullscreen { + position: absolute; + inset: 0; + overflow: hidden; + background: transparent; + } + + .rgb-split-layers { + position: absolute; + inset: 0; + pointer-events: none; + } + + .rgb-layer { + position: absolute; + inset: 0; + mix-blend-mode: screen; + animation: rgb-split-bg 0.5s steps(1, end) infinite; + } + + .rgb-layer.red { + background: radial-gradient(circle at 30% 40%, rgba(255, 0, 100, 0.15) 0%, transparent 50%); + animation-delay: 0s; + } + + .rgb-layer.green { + background: radial-gradient(circle at 70% 60%, rgba(0, 255, 100, 0.15) 0%, transparent 50%); + animation-delay: 0.1s; + } + + .rgb-layer.blue { + background: radial-gradient(circle at 50% 50%, rgba(0, 100, 255, 0.15) 0%, transparent 50%); + animation-delay: 0.2s; + } + + .signal-bars { + position: absolute; + inset: 0; + pointer-events: none; + animation: signal-distort 4s steps(1, end) infinite; + } + + .signal-bar { + position: absolute; + width: 100%; + height: 3px; + background: rgba(255, 255, 255, 0.1); + animation: signal-distort 3s steps(1, end) infinite; + } + + .signal-bar:nth-child(1) { top: 20%; animation-delay: 0s; } + .signal-bar:nth-child(2) { top: 40%; animation-delay: 0.5s; } + .signal-bar:nth-child(3) { top: 60%; animation-delay: 1s; } + .signal-bar:nth-child(4) { top: 80%; animation-delay: 1.5s; } + + /* Breathe Animation Styles - Elegant Dream Theme */ @keyframes breathe-glow { 0%, 100% { text-shadow: @@ -472,6 +567,88 @@ export function LoadingOverlay({ } } + @keyframes float-particle { + 0%, 100% { + transform: translate(0, 0) scale(1); + opacity: 0.3; + } + 25% { + transform: translate(var(--dx1), var(--dy1)) scale(1.2); + opacity: 0.6; + } + 50% { + transform: translate(var(--dx2), var(--dy2)) scale(0.8); + opacity: 0.4; + } + 75% { + transform: translate(var(--dx3), var(--dy3)) scale(1.1); + opacity: 0.5; + } + } + + @keyframes bg-gradient-shift { + 0%, 100% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + } + + .breathe-fullscreen { + position: absolute; + inset: 0; + overflow: hidden; + background: radial-gradient(ellipse at 30% 40%, rgba(59, 130, 246, 0.15) 0%, transparent 50%), + radial-gradient(ellipse at 70% 60%, rgba(139, 92, 246, 0.15) 0%, transparent 50%), + radial-gradient(ellipse at 50% 50%, rgba(6, 182, 212, 0.1) 0%, transparent 60%); + background-size: 200% 200%; + animation: bg-gradient-shift 10s ease-in-out infinite; + } + + .breathe-particles-field { + position: absolute; + inset: 0; + pointer-events: none; + } + + .float-particle { + position: absolute; + width: 8px; + height: 8px; + background: radial-gradient(circle, rgba(59, 130, 246, 0.8) 0%, transparent 70%); + border-radius: 50%; + animation: float-particle 12s ease-in-out infinite; + box-shadow: 0 0 15px rgba(59, 130, 246, 0.6); + } + + .float-particle:nth-child(1) { left: 10%; top: 20%; --dx1: 60px; --dy1: -80px; --dx2: -40px; --dy2: 60px; --dx3: 20px; --dy3: -30px; animation-delay: 0s; } + .float-particle:nth-child(2) { left: 20%; top: 60%; --dx1: -50px; --dy1: -60px; --dx2: 70px; --dy2: 40px; --dx3: -30px; --dy3: -50px; animation-delay: -2s; } + .float-particle:nth-child(3) { left: 80%; top: 30%; --dx1: -60px; --dy1: 70px; --dx2: 40px; --dy2: -80px; --dx3: -20px; --dy3: 40px; animation-delay: -4s; } + .float-particle:nth-child(4) { left: 70%; top: 70%; --dx1: 50px; --dy1: 60px; --dx2: -60px; --dy2: -40px; --dx3: 30px; --dy3: 50px; animation-delay: -6s; } + .float-particle:nth-child(5) { left: 40%; top: 15%; --dx1: -70px; --dy1: 50px; --dx2: 60px; --dy2: -60px; --dx3: -40px; --dy3: 30px; animation-delay: -1s; } + .float-particle:nth-child(6) { left: 60%; top: 85%; --dx1: 40px; --dy1: -70px; --dx2: -50px; --dy2: 50px; --dx3: 60px; --dy3: -40px; animation-delay: -3s; } + .float-particle:nth-child(7) { left: 15%; top: 80%; --dx1: 70px; --dy1: -50px; --dx2: -60px; --dy2: 70px; --dx3: 40px; --dy3: -60px; animation-delay: -5s; } + .float-particle:nth-child(8) { left: 85%; top: 50%; --dx1: -40px; --dy1: 60px; --dx2: 50px; --dy2: -50px; --dx3: -70px; --dy3: 40px; animation-delay: -7s; } + .float-particle:nth-child(9) { left: 50%; top: 10%; --dx1: 30px; --dy1: 80px; --dx2: -70px; --dy2: -30px; --dx3: 50px; --dy3: 60px; animation-delay: -8s; } + .float-particle:nth-child(10) { left: 30%; top: 90%; --dx1: -80px; --dy1: -40px; --dx2: 60px; --dy2: 60px; --dx3: -50px; --dy3: -70px; animation-delay: -9s; } + .float-particle:nth-child(11) { left: 90%; top: 80%; --dx1: 60px; --dy1: 40px; --dx2: -80px; --dy2: -60px; --dx3: 70px; --dy3: 50px; animation-delay: -10s; } + .float-particle:nth-child(12) { left: 5%; top: 40%; --dx1: -50px; --dy1: -70px; --dx2: 80px; --dy2: 50px; --dx3: -60px; --dy3: -80px; animation-delay: -11s; } + + .float-particle.large { + width: 12px; + height: 12px; + background: radial-gradient(circle, rgba(139, 92, 246, 0.6) 0%, transparent 70%); + box-shadow: 0 0 20px rgba(139, 92, 246, 0.5); + } + + .float-particle.small { + width: 4px; + height: 4px; + background: radial-gradient(circle, rgba(6, 182, 212, 0.7) 0%, transparent 70%); + box-shadow: 0 0 10px rgba(6, 182, 212, 0.4); + } + @keyframes letter-appear { 0% { opacity: 0; @@ -640,7 +817,7 @@ export function LoadingOverlay({ .particle:nth-child(7) { left: 50%; top: 50%; --tx: -100px; --ty: 0px; animation-delay: 0.75s; } .particle:nth-child(8) { left: 50%; top: 50%; --tx: 100px; --ty: 0px; animation-delay: 1.05s; } - /* Typewriter Animation Styles */ + /* Typewriter Animation Styles - Retro Terminal Theme */ @keyframes type-letter { 0% { opacity: 0; @@ -655,21 +832,135 @@ export function LoadingOverlay({ @keyframes cursor-blink { 0%, 49% { opacity: 1; - background-color: rgba(59, 130, 246, 1); + background-color: rgba(0, 255, 0, 1); } 50%, 100% { opacity: 0; - background-color: rgba(59, 130, 246, 0); + background-color: rgba(0, 255, 0, 0); } } + @keyframes char-rain { + 0% { + transform: translateY(-20px); + opacity: 0; + } + 10% { + opacity: 0.7; + } + 90% { + opacity: 0.3; + } + 100% { + transform: translateY(100vh); + opacity: 0; + } + } + + @keyframes crt-scan { + 0% { + top: 0; + } + 100% { + top: 100%; + } + } + + @keyframes cursor-trail { + 0% { + transform: translate(0, 0); + opacity: 0.6; + } + 100% { + transform: translate(var(--trail-x), var(--trail-y)); + opacity: 0; + } + } + + .typewriter-fullscreen { + position: absolute; + inset: 0; + overflow: hidden; + background: + linear-gradient(rgba(0, 0, 0, 0) 50%, rgba(0, 20, 0, 0.05) 50%), + linear-gradient(90deg, rgba(255, 0, 0, 0.03), rgba(0, 255, 0, 0.02), rgba(0, 0, 255, 0.03)); + background-size: 100% 4px, 3px 100%; + } + + .terminal-chars-rain { + position: absolute; + inset: 0; + pointer-events: none; + font-family: 'Courier New', monospace; + font-size: 16px; + color: rgba(0, 255, 0, 0.4); + } + + .char-column { + position: absolute; + top: -50px; + animation: char-rain linear infinite; + text-shadow: 0 0 8px rgba(0, 255, 0, 0.6); + white-space: pre; + } + + .char-column:nth-child(1) { left: 8%; animation-duration: 6s; animation-delay: 0s; } + .char-column:nth-child(2) { left: 18%; animation-duration: 8s; animation-delay: -1s; } + .char-column:nth-child(3) { left: 28%; animation-duration: 7s; animation-delay: -2s; } + .char-column:nth-child(4) { left: 38%; animation-duration: 9s; animation-delay: -0.5s; } + .char-column:nth-child(5) { left: 48%; animation-duration: 6.5s; animation-delay: -1.5s; } + .char-column:nth-child(6) { left: 58%; animation-duration: 8.5s; animation-delay: -3s; } + .char-column:nth-child(7) { left: 68%; animation-duration: 7.5s; animation-delay: -2.5s; } + .char-column:nth-child(8) { left: 78%; animation-duration: 9.5s; animation-delay: -1.8s; } + .char-column:nth-child(9) { left: 88%; animation-duration: 6.8s; animation-delay: -0.8s; } + + .crt-scanline { + position: absolute; + width: 100%; + height: 100px; + left: 0; + top: 0; + background: linear-gradient( + to bottom, + transparent 0%, + rgba(0, 255, 0, 0.03) 50%, + transparent 100% + ); + animation: crt-scan 6s linear infinite; + pointer-events: none; + } + + .cursor-trails { + position: absolute; + inset: 0; + pointer-events: none; + } + + .cursor-trail { + position: absolute; + width: 3px; + height: 20px; + background: rgba(0, 255, 0, 0.6); + box-shadow: 0 0 10px rgba(0, 255, 0, 0.8); + animation: cursor-trail 3s ease-out infinite; + } + + .cursor-trail:nth-child(1) { left: 20%; top: 30%; --trail-x: 200px; --trail-y: -150px; animation-delay: 0s; } + .cursor-trail:nth-child(2) { left: 60%; top: 70%; --trail-x: -180px; --trail-y: 120px; animation-delay: 1s; } + .cursor-trail:nth-child(3) { left: 80%; top: 20%; --trail-x: -220px; --trail-y: 180px; animation-delay: 2s; } + .cursor-trail:nth-child(4) { left: 40%; top: 80%; --trail-x: 150px; --trail-y: -200px; animation-delay: 0.5s; } + .cursor-trail:nth-child(5) { left: 70%; top: 50%; --trail-x: -160px; --trail-y: -140px; animation-delay: 1.5s; } + .typewriter-container { position: relative; + z-index: 10; } .typewriter-text { - color: #fff; + color: #0f0; display: inline-flex; + text-shadow: 0 0 10px rgba(0, 255, 0, 0.6); + filter: drop-shadow(0 0 5px rgba(0, 255, 0, 0.4)); } .typewriter-text .type-letter { @@ -690,12 +981,13 @@ export function LoadingOverlay({ width: 3px; height: 1em; margin-left: 4px; - background-color: rgba(59, 130, 246, 1); + background-color: rgba(0, 255, 0, 1); animation: cursor-blink 1s infinite; animation-delay: 0.9s; + box-shadow: 0 0 8px rgba(0, 255, 0, 0.8); } - /* Scanner Animation Styles */ + /* Scanner Animation Styles - Matrix/Hacker Theme */ @keyframes vertical-scan { 0% { top: -20%; @@ -705,105 +997,179 @@ export function LoadingOverlay({ } } - @keyframes horizontal-scan { + @keyframes code-fall { 0% { - left: -20%; + transform: translateY(-100%); + opacity: 0; + } + 10% { + opacity: 1; + } + 90% { + opacity: 1; } 100% { - left: 120%; + transform: translateY(100vh); + opacity: 0; } } @keyframes scanner-glow { 0%, 100% { text-shadow: - 0 0 10px rgba(0, 255, 255, 0.3), - 0 0 20px rgba(0, 255, 255, 0.2); + 0 0 20px rgba(0, 255, 0, 0.6), + 0 0 40px rgba(0, 255, 0, 0.4), + 0 0 60px rgba(0, 255, 0, 0.2); + color: #0f0; } 50% { text-shadow: - 0 0 30px rgba(0, 255, 255, 1), - 0 0 60px rgba(0, 255, 255, 0.8), - 0 0 90px rgba(0, 255, 255, 0.6), - 0 0 120px rgba(0, 255, 255, 0.4); + 0 0 40px rgba(0, 255, 0, 1), + 0 0 80px rgba(0, 255, 0, 0.8), + 0 0 120px rgba(0, 255, 0, 0.6), + 0 0 160px rgba(0, 255, 0, 0.4); + color: #0ff; } } + @keyframes code-flicker { + 0%, 100% { + opacity: 0.05; + } + 50% { + opacity: 0.15; + } + } + + .scanner-fullscreen { + position: absolute; + inset: 0; + overflow: hidden; + background: radial-gradient(ellipse at center, rgba(0, 20, 0, 0.3) 0%, rgba(0, 0, 0, 0.95) 100%); + } + .scanner-container { position: relative; - overflow: hidden; + overflow: visible; + z-index: 10; } .scanner-text { - color: #fff; - animation: scanner-glow 3s ease-in-out infinite; + color: #0f0; + animation: scanner-glow 2s ease-in-out infinite; position: relative; - z-index: 1; + z-index: 10; + filter: drop-shadow(0 0 10px rgba(0, 255, 0, 0.8)); } + /* Matrix digital rain */ + .matrix-rain { + position: absolute; + inset: 0; + overflow: hidden; + pointer-events: none; + } + + .matrix-column { + position: absolute; + top: 0; + width: 20px; + height: 100%; + font-family: 'Courier New', monospace; + font-size: 14px; + color: #0f0; + opacity: 0.6; + animation: code-fall linear infinite; + text-shadow: 0 0 5px rgba(0, 255, 0, 0.8); + white-space: pre; + line-height: 20px; + } + + /* Stagger the columns */ + .matrix-column:nth-child(1) { left: 5%; animation-duration: 8s; animation-delay: 0s; } + .matrix-column:nth-child(2) { left: 15%; animation-duration: 10s; animation-delay: -2s; } + .matrix-column:nth-child(3) { left: 25%; animation-duration: 7s; animation-delay: -4s; } + .matrix-column:nth-child(4) { left: 35%; animation-duration: 9s; animation-delay: -1s; } + .matrix-column:nth-child(5) { left: 45%; animation-duration: 11s; animation-delay: -3s; } + .matrix-column:nth-child(6) { left: 55%; animation-duration: 8s; animation-delay: -5s; } + .matrix-column:nth-child(7) { left: 65%; animation-duration: 10s; animation-delay: -2.5s; } + .matrix-column:nth-child(8) { left: 75%; animation-duration: 9s; animation-delay: -4.5s; } + .matrix-column:nth-child(9) { left: 85%; animation-duration: 7s; animation-delay: -1.5s; } + .matrix-column:nth-child(10) { left: 95%; animation-duration: 10s; animation-delay: -3.5s; } + + /* Powerful scan beam */ .vertical-scan-line { position: absolute; width: 100%; - height: 80px; + height: 150px; left: 0; background: linear-gradient( to bottom, transparent 0%, - rgba(0, 255, 255, 0.1) 20%, - rgba(0, 255, 255, 0.8) 50%, - rgba(0, 255, 255, 0.1) 80%, + rgba(0, 255, 0, 0.05) 20%, + rgba(0, 255, 255, 0.4) 45%, + rgba(0, 255, 255, 1) 50%, + rgba(0, 255, 255, 0.4) 55%, + rgba(0, 255, 0, 0.05) 80%, transparent 100% ); - animation: vertical-scan 3s linear infinite; - z-index: 2; + animation: vertical-scan 4s linear infinite; + z-index: 5; pointer-events: none; - box-shadow: 0 0 30px rgba(0, 255, 255, 0.6); + box-shadow: + 0 0 50px rgba(0, 255, 255, 0.8), + 0 0 100px rgba(0, 255, 255, 0.4); + filter: blur(1px); } - .vertical-scan-line:nth-child(2) { - animation-delay: 1.5s; + .vertical-scan-line::before { + content: ''; + position: absolute; + inset: 0; background: linear-gradient( to bottom, - transparent 0%, - rgba(255, 0, 255, 0.1) 20%, - rgba(255, 0, 255, 0.6) 50%, - rgba(255, 0, 255, 0.1) 80%, - transparent 100% + transparent 48%, + rgba(255, 255, 255, 0.8) 50%, + transparent 52% ); - box-shadow: 0 0 30px rgba(255, 0, 255, 0.5); - } - - .horizontal-scan-line { - position: absolute; - width: 80px; - height: 100%; - top: 0; - background: linear-gradient( - to right, - transparent 0%, - rgba(0, 255, 255, 0.1) 20%, - rgba(0, 255, 255, 0.5) 50%, - rgba(0, 255, 255, 0.1) 80%, - transparent 100% - ); - animation: horizontal-scan 2.5s linear infinite; - z-index: 2; - pointer-events: none; - box-shadow: 0 0 30px rgba(0, 255, 255, 0.4); } + /* Dense grid */ .scanner-grid { position: absolute; - inset: -40px; + inset: 0; background-image: - linear-gradient(rgba(0, 255, 255, 0.1) 1px, transparent 1px), - linear-gradient(90deg, rgba(0, 255, 255, 0.1) 1px, transparent 1px); - background-size: 20px 20px; - opacity: 0.3; + linear-gradient(rgba(0, 255, 0, 0.15) 1px, transparent 1px), + linear-gradient(90deg, rgba(0, 255, 0, 0.15) 1px, transparent 1px); + background-size: 15px 15px; + opacity: 0.4; pointer-events: none; + animation: code-flicker 3s ease-in-out infinite; } - /* Pulse Ripple Animation Styles */ + /* Random code snippets */ + .code-fragments { + position: absolute; + inset: 0; + pointer-events: none; + font-family: 'Courier New', monospace; + font-size: 12px; + color: rgba(0, 255, 0, 0.3); + } + + .code-fragment { + position: absolute; + animation: code-flicker 2s ease-in-out infinite; + text-shadow: 0 0 5px rgba(0, 255, 0, 0.5); + } + + .code-fragment:nth-child(1) { top: 10%; left: 10%; animation-delay: 0s; } + .code-fragment:nth-child(2) { top: 20%; right: 15%; animation-delay: 0.5s; } + .code-fragment:nth-child(3) { top: 40%; left: 20%; animation-delay: 1s; } + .code-fragment:nth-child(4) { bottom: 30%; right: 25%; animation-delay: 1.5s; } + .code-fragment:nth-child(5) { bottom: 15%; left: 30%; animation-delay: 0.8s; } + + /* Pulse Ripple Animation Styles - Sonar/Radar Theme */ @keyframes wave-expand { 0% { width: 80px; @@ -846,6 +1212,130 @@ export function LoadingOverlay({ } } + @keyframes target-blink { + 0%, 100% { + opacity: 0.3; + transform: scale(1); + } + 50% { + opacity: 1; + transform: scale(1.2); + } + } + + @keyframes sonar-pulse { + 0% { + transform: scale(0.5); + opacity: 0; + } + 50% { + opacity: 0.8; + } + 100% { + transform: scale(2); + opacity: 0; + } + } + + .pulse-fullscreen { + position: absolute; + inset: 0; + overflow: hidden; + background: radial-gradient(circle at center, rgba(0, 30, 60, 0.3) 0%, rgba(0, 0, 0, 0.95) 100%); + } + + .radar-grid { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + + .radar-circle { + position: absolute; + border: 1px solid rgba(59, 130, 246, 0.2); + border-radius: 50%; + opacity: 0.4; + } + + .radar-circle:nth-child(1) { width: 200px; height: 200px; } + .radar-circle:nth-child(2) { width: 350px; height: 350px; } + .radar-circle:nth-child(3) { width: 500px; height: 500px; } + .radar-circle:nth-child(4) { width: 650px; height: 650px; } + .radar-circle:nth-child(5) { width: 800px; height: 800px; } + + .radar-lines { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + + .radar-line { + position: absolute; + width: 1px; + height: 100%; + background: linear-gradient(to bottom, transparent 45%, rgba(59, 130, 246, 0.15) 50%, transparent 55%); + } + + .radar-line:nth-child(1) { transform: rotate(0deg); } + .radar-line:nth-child(2) { transform: rotate(45deg); } + .radar-line:nth-child(3) { transform: rotate(90deg); } + .radar-line:nth-child(4) { transform: rotate(135deg); } + + .sonar-waves { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + } + + .sonar-wave { + position: absolute; + border: 2px solid rgba(59, 130, 246, 0.6); + border-radius: 50%; + width: 100px; + height: 100px; + animation: sonar-pulse 3s ease-out infinite; + } + + .sonar-wave:nth-child(1) { animation-delay: 0s; } + .sonar-wave:nth-child(2) { animation-delay: 0.6s; } + .sonar-wave:nth-child(3) { animation-delay: 1.2s; } + .sonar-wave:nth-child(4) { animation-delay: 1.8s; } + .sonar-wave:nth-child(5) { animation-delay: 2.4s; } + + .radar-targets { + position: absolute; + inset: 0; + pointer-events: none; + } + + .radar-target { + position: absolute; + width: 8px; + height: 8px; + background: rgba(0, 255, 255, 0.8); + border-radius: 50%; + box-shadow: 0 0 15px rgba(0, 255, 255, 0.8); + animation: target-blink 2s ease-in-out infinite; + } + + .radar-target:nth-child(1) { left: 20%; top: 25%; animation-delay: 0s; } + .radar-target:nth-child(2) { left: 75%; top: 35%; animation-delay: 0.5s; } + .radar-target:nth-child(3) { left: 45%; top: 70%; animation-delay: 1s; } + .radar-target:nth-child(4) { left: 65%; top: 60%; animation-delay: 1.5s; } + .radar-target:nth-child(5) { left: 30%; top: 80%; animation-delay: 0.3s; } + .radar-target:nth-child(6) { left: 85%; top: 75%; animation-delay: 0.8s; } + .radar-target:nth-child(7) { left: 15%; top: 55%; animation-delay: 1.3s; } + .radar-target:nth-child(8) { left: 55%; top: 20%; animation-delay: 0.6s; } + .pulse-container { position: relative; } @@ -932,14 +1422,32 @@ export function LoadingOverlay({ className={cn( "absolute inset-0 flex items-center justify-center z-50 transition-opacity duration-300", isFadingOut ? "opacity-0" : "opacity-100", - className + className, )} style={{ backgroundColor: backgroundColor || "rgba(0, 0, 0, 0.92)" }} > - {animationType === 'glitch' ? ( + {animationType === "glitch" ? ( <> -

-
+ {/* Fullscreen Glitch Background */} +
+ {/* RGB Split Layers */} +
+
+
+
+
+ + {/* Signal Distortion Bars */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ + {/* Original Effects */} +
+
+
@@ -947,8 +1455,10 @@ export function LoadingOverlay({
TERMIX @@ -959,7 +1469,7 @@ export function LoadingOverlay({
{message && ( -
+

{message}

@@ -967,8 +1477,24 @@ export function LoadingOverlay({ )}
- ) : animationType === 'breathe' ? ( + ) : animationType === "breathe" ? ( <> + {/* Fullscreen Elegant Background */} +
+ {/* Floating Particles Field */} +
+ {Array.from({ length: 12 }).map((_, i) => ( +
+ ))} +
+
+
{/* Pulse rings */} @@ -1000,7 +1526,8 @@ export function LoadingOverlay({
T @@ -1013,7 +1540,7 @@ export function LoadingOverlay({
{message && ( -
+

{message}

@@ -1021,15 +1548,38 @@ export function LoadingOverlay({ )}
- ) : animationType === 'typewriter' ? ( + ) : animationType === "typewriter" ? ( <> + {/* Fullscreen Retro Terminal Background */} +
+ {/* ASCII Character Rain */} +
+ {Array.from({ length: 9 }).map((_, i) => ( +
+ {`$\n>\n_\n{\n}\n[\n]\n|\n/\n\\\n-\n+\n*\n#\n@\n%`} +
+ ))} +
+ + {/* CRT Scanline */} +
+ + {/* Cursor Trails */} +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+
{/* TERMIX Typewriter Text */}
T @@ -1043,33 +1593,58 @@ export function LoadingOverlay({
{message && ( -
-

+

+

{message}

)}
- ) : animationType === 'scanner' ? ( + ) : animationType === "scanner" ? ( <> + {/* Fullscreen Matrix Background */} +
+ {/* Grid Background */} +
+ + {/* Matrix Digital Rain */} +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ {`01\n10\n11\n00\n01\n10\n11\n00\n01\n10\n11\n00\n01\n10\n11\n00\n01\n10\n11\n00`} +
+ ))} +
+ + {/* Random Code Fragments */} +
+
+ {"{"} ssh: 22 {"}"} +
+
+ {"<"} connect... {">"} +
+
0x4A3F2B1D
+
[SCANNING...]
+
{">"} _
+
+ + {/* Powerful Scan Beam */} +
+
+
- {/* Scanner Grid Background */} -
- - {/* Vertical Scan Lines */} -
-
- - {/* Horizontal Scan Line */} -
- {/* TERMIX Scanner Text */}
TERMIX @@ -1077,8 +1652,11 @@ export function LoadingOverlay({
{message && ( -
-

+

+

{message}

@@ -1087,6 +1665,37 @@ export function LoadingOverlay({ ) : ( <> + {/* Fullscreen Radar/Sonar Background */} +
+ {/* Radar Circular Grid */} +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ + {/* Radar Cross Lines */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ + {/* Sonar Pulse Waves */} +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+ + {/* Radar Targets (Detection Points) */} +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ ))} +
+
+
{/* Wave Rings */} @@ -1106,7 +1715,8 @@ export function LoadingOverlay({
TERMIX @@ -1114,7 +1724,7 @@ export function LoadingOverlay({
{message && ( -
+

{message}

-- 2.49.1