diff --git a/.dockerignore b/.dockerignore index 0ea0e700..68f7d1d5 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,17 +1,20 @@ -# Git and version control -.git -.gitignore - -# Node.js +# Dependencies node_modules npm-debug.log* yarn-debug.log* yarn-error.log* -# Environment and configuration -.env -.env.* -!.env.example +# Build outputs +dist +build +.next +.nuxt + +# Development files +.env.local +.env.development.local +.env.test.local +.env.production.local # IDE and editor files .vscode @@ -29,6 +32,28 @@ yarn-error.log* ehthumbs.db Thumbs.db +# Git +.git +.gitignore + +# Documentation +README.md +README-CN.md +CONTRIBUTING.md +LICENSE + +# Docker files (avoid copying docker files into docker) +docker/ + +# Repository images +repo-images/ + +# Uploads directory +uploads/ + +# Fonts (Electron-only, not needed for Docker) +public/fonts/ + # Logs logs *.log @@ -42,66 +67,54 @@ pids # Coverage directory used by tools like istanbul coverage -# Build directories (we build inside Docker) -dist/ -build/ +# nyc test coverage +.nyc_output -# Temporary files -tmp/ -temp/ +# Dependency directories +jspm_packages/ -# SSL certificates (generated at runtime) -ssl/ -*.crt -*.key -*.pem +# Optional npm cache directory +.npm -# Database files (use volumes) -*.sqlite -*.db - -# Docker files (avoid recursion) -Dockerfile* -docker-compose*.yml -.dockerignore - -# Documentation -README*.md -CONTRIBUTING.md -LICENSE -*.md - -# Repository images and assets (not needed in container) -repo-images/ - -# Testing -test/ -tests/ -*.test.js -*.spec.js - -# Uploads directory (use volumes) -uploads/ - -# Backup files -*.bak -*.backup -*.old - -# Cache directories -.cache/ -.npm/ -.yarn/ - -# TypeScript build info -*.tsbuildinfo - -# ESLint cache +# Optional eslint cache .eslintcache -# Prettier -.prettierignore -.prettierrc* +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ -# Local configuration -.claude/ \ No newline at end of file +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 0cb53035..ec3efc70 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,18 +1,20 @@ name: Build and Push Docker Image on: - push: - branches: - - development - paths-ignore: - - "**.md" - - ".gitignore" workflow_dispatch: inputs: tag_name: description: "Custom tag name for the Docker image" required: false default: "" + registry: + description: "Docker registry to push to" + required: true + default: "ghcr" + type: choice + options: + - "ghcr" + - "dockerhub" jobs: build: @@ -57,12 +59,20 @@ jobs: ${{ runner.os }}-buildx- - name: Login to Docker Registry + if: github.event.inputs.registry == 'dockerhub' || github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Login to Docker Hub + if: github.event.inputs.registry == 'dockerhub' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Determine Docker image tag run: | echo "REPO_OWNER=$(echo ${{ github.repository_owner }} | tr '[:upper:]' '[:lower:]')" >> $GITHUB_ENV @@ -76,6 +86,15 @@ jobs: IMAGE_TAG="${{ github.ref_name }}" fi echo "IMAGE_TAG=$IMAGE_TAG" >> $GITHUB_ENV + + # Determine registry and image name + if [ "${{ github.event.inputs.registry }}" == "dockerhub" ]; then + echo "REGISTRY=docker.io" >> $GITHUB_ENV + echo "IMAGE_NAME=${{ secrets.DOCKERHUB_USERNAME }}/termix" >> $GITHUB_ENV + else + echo "REGISTRY=ghcr.io" >> $GITHUB_ENV + echo "IMAGE_NAME=${{ env.REPO_OWNER }}/termix" >> $GITHUB_ENV + fi - name: Build and Push Multi-Arch Docker Image uses: docker/build-push-action@v6 @@ -84,7 +103,7 @@ jobs: file: ./docker/Dockerfile push: true platforms: linux/amd64,linux/arm64 - tags: ghcr.io/${{ env.REPO_OWNER }}/termix:${{ env.IMAGE_TAG }} + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} labels: | org.opencontainers.image.source=https://github.com/${{ github.repository }} org.opencontainers.image.revision=${{ github.sha }} @@ -101,7 +120,7 @@ jobs: mv /tmp/.buildx-cache-new /tmp/.buildx-cache - name: Delete all untagged image versions - if: success() + if: success() && (github.event.inputs.registry != 'dockerhub' && github.event_name == 'push') uses: quartx-analytics/ghcr-cleaner@v1 with: owner-type: user diff --git a/docker/Dockerfile b/docker/Dockerfile index 0d8419df..0cbc6215 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -35,11 +35,11 @@ RUN npm rebuild better-sqlite3 --force RUN npm run build:backend -# Stage 4: Production dependencies with native modules +# Stage 4: Production dependencies only FROM node:24-alpine AS production-deps WORKDIR /app -RUN apk add --no-cache python3 make g++ +RUN apk add --no-cache python3 make g++ COPY package*.json ./ @@ -47,42 +47,37 @@ ENV npm_config_target_platform=linux ENV npm_config_target_arch=x64 ENV npm_config_target_libc=glibc -# Install production dependencies and rebuild native modules in one stage -RUN npm ci --omit=dev --ignore-scripts --force && \ +RUN npm ci --only=production --ignore-scripts --force && \ npm rebuild better-sqlite3 bcryptjs --force && \ - npm cache clean --force && \ - rm -rf ~/.npm /tmp/* /var/cache/apk/* + npm cache clean --force -# Stage 6: Final image +# Stage 5: Final optimized image FROM node:24-alpine +WORKDIR /app + ENV DATA_DIR=/app/data \ PORT=8080 \ NODE_ENV=production -RUN apk add --no-cache nginx gettext su-exec openssl && \ - mkdir -p /app/data /app/config /app/ssl && \ - chown -R node:node /app/data /app/config /app/ssl +RUN apk add --no-cache nginx gettext su-exec && \ + mkdir -p /app/data && \ + chown -R node:node /app/data COPY docker/nginx.conf /etc/nginx/nginx.conf -COPY docker/nginx-https.conf /etc/nginx/nginx-https.conf COPY --from=frontend-builder /app/dist /usr/share/nginx/html COPY --from=frontend-builder /app/src/locales /usr/share/nginx/html/locales RUN chown -R nginx:nginx /usr/share/nginx/html -WORKDIR /app - COPY --from=production-deps /app/node_modules /app/node_modules COPY --from=backend-builder /app/dist/backend ./dist/backend COPY package.json ./ -RUN chown -R node:node /app && \ - chmod 755 /app/config && \ - chmod 755 /app/ssl && \ - chmod 755 /app/data +COPY .env ./.env +RUN chown -R node:node /app VOLUME ["/app/data"] -EXPOSE ${PORT} 8081 8082 8083 8084 8085 +EXPOSE ${PORT} 30001 30002 30003 30004 30005 COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/docker/nginx-https.conf b/docker/nginx-https.conf index 8ae42ae3..d05a7a8a 100644 --- a/docker/nginx-https.conf +++ b/docker/nginx-https.conf @@ -46,7 +46,7 @@ http { } location ~ ^/users(/.*)?$ { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -55,7 +55,7 @@ http { } location ~ ^/version(/.*)?$ { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -64,7 +64,7 @@ http { } location ~ ^/releases(/.*)?$ { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -73,7 +73,7 @@ http { } location ~ ^/alerts(/.*)?$ { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -82,7 +82,7 @@ http { } location ~ ^/credentials(/.*)?$ { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -91,7 +91,7 @@ http { } location /ssh/ { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -102,7 +102,7 @@ http { # WebSocket proxy for authenticated terminal connections location /ssh/websocket/ { # Pass to WebSocket server with authentication support - proxy_pass http://127.0.0.1:8082/; + proxy_pass http://127.0.0.1:30002/; proxy_http_version 1.1; # WebSocket upgrade headers @@ -132,7 +132,7 @@ http { } location /ssh/tunnel/ { - proxy_pass http://127.0.0.1:8083; + proxy_pass http://127.0.0.1:30003; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -141,7 +141,7 @@ http { } location /ssh/file_manager/recent { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -150,7 +150,7 @@ http { } location /ssh/file_manager/pinned { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -159,7 +159,7 @@ http { } location /ssh/file_manager/shortcuts { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -168,7 +168,7 @@ http { } location /ssh/file_manager/ssh/ { - proxy_pass http://127.0.0.1:8084; + proxy_pass http://127.0.0.1:30004; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -177,7 +177,7 @@ http { } location /health { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -186,7 +186,7 @@ http { } location ~ ^/status(/.*)?$ { - proxy_pass http://127.0.0.1:8085; + proxy_pass http://127.0.0.1:30005; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -195,7 +195,7 @@ http { } location ~ ^/metrics(/.*)?$ { - proxy_pass http://127.0.0.1:8085; + proxy_pass http://127.0.0.1:30005; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/docker/nginx.conf b/docker/nginx.conf index 0b452d04..e422c585 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -32,7 +32,7 @@ http { } location ~ ^/users(/.*)?$ { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -41,7 +41,7 @@ http { } location ~ ^/version(/.*)?$ { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -50,7 +50,7 @@ http { } location ~ ^/releases(/.*)?$ { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -59,7 +59,7 @@ http { } location ~ ^/alerts(/.*)?$ { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -68,7 +68,7 @@ http { } location ~ ^/credentials(/.*)?$ { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -77,7 +77,7 @@ http { } location /ssh/ { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -88,7 +88,7 @@ http { # WebSocket proxy for authenticated terminal connections location /ssh/websocket/ { # Pass to WebSocket server with authentication support - proxy_pass http://127.0.0.1:8082/; + proxy_pass http://127.0.0.1:30002/; proxy_http_version 1.1; # WebSocket upgrade headers @@ -118,7 +118,7 @@ http { } location /ssh/tunnel/ { - proxy_pass http://127.0.0.1:8083; + proxy_pass http://127.0.0.1:30003; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -127,7 +127,7 @@ http { } location /ssh/file_manager/recent { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -136,7 +136,7 @@ http { } location /ssh/file_manager/pinned { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -145,7 +145,7 @@ http { } location /ssh/file_manager/shortcuts { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -154,7 +154,7 @@ http { } location /ssh/file_manager/ssh/ { - proxy_pass http://127.0.0.1:8084; + proxy_pass http://127.0.0.1:30004; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -163,7 +163,7 @@ http { } location /health { - proxy_pass http://127.0.0.1:8081; + proxy_pass http://127.0.0.1:30001; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -172,7 +172,7 @@ http { } location ~ ^/status(/.*)?$ { - proxy_pass http://127.0.0.1:8085; + proxy_pass http://127.0.0.1:30005; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -181,7 +181,7 @@ http { } location ~ ^/metrics(/.*)?$ { - proxy_pass http://127.0.0.1:8085; + proxy_pass http://127.0.0.1:30005; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index edc7564f..bfc8da52 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -1263,7 +1263,7 @@ app.use( }, ); -const HTTP_PORT = 8081; +const HTTP_PORT = 30001; const HTTPS_PORT = process.env.SSL_PORT || 8443; async function initializeSecurity() { diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 91a82281..affdf044 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -148,8 +148,8 @@ async function initializeDatabaseAsync(): Promise { backupPath: migrationResult.backupPath, }); - // ๐Ÿ”ฅ CRITICAL: Migration failure with existing data - console.error("๐Ÿšจ DATABASE MIGRATION FAILED - THIS IS CRITICAL!"); + // CRITICAL: Migration failure with existing data + console.error("DATABASE MIGRATION FAILED - THIS IS CRITICAL!"); console.error("Migration error:", migrationResult.error); console.error("Backup available at:", migrationResult.backupPath); console.error("Manual intervention required to recover data."); @@ -177,9 +177,9 @@ async function initializeDatabaseAsync(): Promise { databaseKeyLength: process.env.DATABASE_KEY?.length || 0, }); - // ๐Ÿ”ฅ CRITICAL: Never silently ignore database decryption failures! + // CRITICAL: Never silently ignore database decryption failures! // This causes complete data loss for users - console.error("๐Ÿšจ DATABASE DECRYPTION FAILED - THIS IS CRITICAL!"); + console.error("DATABASE DECRYPTION FAILED - THIS IS CRITICAL!"); console.error("Error details:", error instanceof Error ? error.message : error); console.error("Encrypted file exists:", DatabaseFileEncryption.isEncryptedDatabaseFile(encryptedDbPath)); console.error("DATABASE_KEY available:", !!process.env.DATABASE_KEY); @@ -382,11 +382,6 @@ const addColumnIfNotExists = ( .get(); } catch (e) { try { - databaseLogger.debug(`Adding column ${column} to ${table}`, { - operation: "schema_migration", - table, - column, - }); sqlite.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition};`); databaseLogger.success(`Column ${column} added to ${table}`, { @@ -515,22 +510,9 @@ async function saveMemoryDatabaseToFile() { if (enableFileEncryption) { // Save as encrypted file await DatabaseFileEncryption.encryptDatabaseFromBuffer(buffer, encryptedDbPath); - - databaseLogger.debug("In-memory database saved to encrypted file", { - operation: "memory_db_save_encrypted", - bufferSize: buffer.length, - encryptedPath: encryptedDbPath, - }); } else { // Fallback: save as unencrypted SQLite file to prevent data loss fs.writeFileSync(dbPath, buffer); - - databaseLogger.debug("In-memory database saved to unencrypted file", { - operation: "memory_db_save_unencrypted", - bufferSize: buffer.length, - unencryptedPath: dbPath, - warning: "File encryption disabled - data saved unencrypted", - }); } } catch (error) { databaseLogger.error("Failed to save in-memory database", error, { @@ -643,9 +625,6 @@ async function cleanupDatabase() { try { if (sqlite) { sqlite.close(); - databaseLogger.debug("Database connection closed", { - operation: "db_close", - }); } } catch (error) { databaseLogger.warn("Error closing database connection", { @@ -669,9 +648,6 @@ async function cleanupDatabase() { try { fs.rmdirSync(tempDir); - databaseLogger.debug("Temp directory cleaned up", { - operation: "temp_dir_cleanup", - }); } catch { // Ignore directory removal errors } @@ -745,12 +721,6 @@ function getMemoryDatabaseBuffer(): Buffer { try { // Export in-memory database to buffer const buffer = memoryDatabase.serialize(); - - databaseLogger.debug("Memory database serialized to buffer", { - operation: "memory_db_serialize", - bufferSize: buffer.length, - }); - return buffer; } catch (error) { databaseLogger.error( diff --git a/src/backend/database/routes/credentials.ts b/src/backend/database/routes/credentials.ts index 7ea11077..596cab23 100644 --- a/src/backend/database/routes/credentials.ts +++ b/src/backend/database/routes/credentials.ts @@ -28,8 +28,6 @@ function generateSSHKeyPair( publicKey?: string; error?: string; } { - console.log("Generating SSH key pair with ssh2:", keyType); - try { // Convert our keyType to ssh2 format let ssh2Type = keyType; @@ -54,17 +52,12 @@ function generateSSHKeyPair( // Use ssh2's native key generation const keyPair = ssh2Utils.generateKeyPairSync(ssh2Type as any, options); - console.log("SSH key pair generated successfully!"); - console.log("Private key length:", keyPair.private.length); - console.log("Public key preview:", keyPair.public.substring(0, 50) + "..."); - return { success: true, privateKey: keyPair.private, publicKey: keyPair.public, }; } catch (error) { - console.error("SSH key generation failed:", error); return { success: false, error: @@ -785,20 +778,12 @@ router.post( async (req: Request, res: Response) => { const { privateKey, keyPassword } = req.body; - console.log("=== Key Detection API Called ==="); - console.log("Request body keys:", Object.keys(req.body)); - console.log("Private key provided:", !!privateKey); - console.log("Private key type:", typeof privateKey); - if (!privateKey || typeof privateKey !== "string") { - console.log("Invalid private key provided"); return res.status(400).json({ error: "Private key is required" }); } try { - console.log("Calling parseSSHKey..."); const keyInfo = parseSSHKey(privateKey, keyPassword); - console.log("parseSSHKey result:", keyInfo); const response = { success: keyInfo.success, @@ -808,10 +793,8 @@ router.post( error: keyInfo.error || null, }; - console.log("Sending response:", response); res.json(response); } catch (error) { - console.error("Exception in detect-key-type endpoint:", error); authLogger.error("Failed to detect key type", error); res.status(500).json({ error: @@ -829,20 +812,12 @@ router.post( async (req: Request, res: Response) => { const { publicKey } = req.body; - console.log("=== Public Key Detection API Called ==="); - console.log("Request body keys:", Object.keys(req.body)); - console.log("Public key provided:", !!publicKey); - console.log("Public key type:", typeof publicKey); - if (!publicKey || typeof publicKey !== "string") { - console.log("Invalid public key provided"); return res.status(400).json({ error: "Public key is required" }); } try { - console.log("Calling parsePublicKey..."); const keyInfo = parsePublicKey(publicKey); - console.log("parsePublicKey result:", keyInfo); const response = { success: keyInfo.success, @@ -851,10 +826,8 @@ router.post( error: keyInfo.error || null, }; - console.log("Sending response:", response); res.json(response); } catch (error) { - console.error("Exception in detect-public-key-type endpoint:", error); authLogger.error("Failed to detect public key type", error); res.status(500).json({ error: @@ -874,29 +847,20 @@ router.post( async (req: Request, res: Response) => { const { privateKey, publicKey, keyPassword } = req.body; - console.log("=== Key Pair Validation API Called ==="); - console.log("Request body keys:", Object.keys(req.body)); - console.log("Private key provided:", !!privateKey); - console.log("Public key provided:", !!publicKey); - if (!privateKey || typeof privateKey !== "string") { - console.log("Invalid private key provided"); return res.status(400).json({ error: "Private key is required" }); } if (!publicKey || typeof publicKey !== "string") { - console.log("Invalid public key provided"); return res.status(400).json({ error: "Public key is required" }); } try { - console.log("Calling validateKeyPair..."); const validationResult = validateKeyPair( privateKey, publicKey, keyPassword, ); - console.log("validateKeyPair result:", validationResult); const response = { isValid: validationResult.isValid, @@ -906,10 +870,8 @@ router.post( error: validationResult.error || null, }; - console.log("Sending response:", response); res.json(response); } catch (error) { - console.error("Exception in validate-key-pair endpoint:", error); authLogger.error("Failed to validate key pair", error); res.status(500).json({ error: @@ -929,11 +891,6 @@ router.post( async (req: Request, res: Response) => { const { keyType = "ssh-ed25519", keySize = 2048, passphrase } = req.body; - console.log("=== Generate Key Pair API Called ==="); - console.log("Key type:", keyType); - console.log("Key size:", keySize); - console.log("Has passphrase:", !!passphrase); - try { // Generate SSH keys directly with ssh2 const result = generateSSHKeyPair(keyType, keySize, passphrase); @@ -950,17 +907,14 @@ router.post( curve: keyType === "ecdsa-sha2-nistp256" ? "nistp256" : undefined, }; - console.log("SSH key pair generated successfully:", keyType); res.json(response); } else { - console.error("SSH key generation failed:", result.error); res.status(500).json({ success: false, error: result.error || "Failed to generate SSH key pair", }); } } catch (error) { - console.error("Exception in generate-key-pair endpoint:", error); authLogger.error("Failed to generate key pair", error); res.status(500).json({ success: false, @@ -981,23 +935,11 @@ router.post( async (req: Request, res: Response) => { const { privateKey, keyPassword } = req.body; - console.log("=== Generate Public Key API Called ==="); - console.log("Request body keys:", Object.keys(req.body)); - console.log("Private key provided:", !!privateKey); - console.log("Private key type:", typeof privateKey); - if (!privateKey || typeof privateKey !== "string") { - console.log("Invalid private key provided"); return res.status(400).json({ error: "Private key is required" }); } try { - console.log( - "Using Node.js crypto to generate public key from private key...", - ); - console.log("Private key length:", privateKey.length); - console.log("Private key first 100 chars:", privateKey.substring(0, 100)); - // First try to create private key object from the input let privateKeyObj; let parseAttempts = []; @@ -1008,7 +950,6 @@ router.post( key: privateKey, passphrase: keyPassword, }); - console.log("Successfully parsed with passphrase method"); } catch (error) { parseAttempts.push(`Method 1 (with passphrase): ${error.message}`); } @@ -1017,7 +958,6 @@ router.post( if (!privateKeyObj) { try { privateKeyObj = crypto.createPrivateKey(privateKey); - console.log("Successfully parsed without passphrase"); } catch (error) { parseAttempts.push(`Method 2 (without passphrase): ${error.message}`); } @@ -1031,7 +971,6 @@ router.post( format: "pem", type: "pkcs8", }); - console.log("Successfully parsed as PKCS#8"); } catch (error) { parseAttempts.push(`Method 3 (PKCS#8): ${error.message}`); } @@ -1048,7 +987,6 @@ router.post( format: "pem", type: "pkcs1", }); - console.log("Successfully parsed as PKCS#1 RSA"); } catch (error) { parseAttempts.push(`Method 4 (PKCS#1): ${error.message}`); } @@ -1065,7 +1003,6 @@ router.post( format: "pem", type: "sec1", }); - console.log("Successfully parsed as SEC1 EC"); } catch (error) { parseAttempts.push(`Method 5 (SEC1): ${error.message}`); } @@ -1073,23 +1010,11 @@ router.post( // Final attempt: Try using ssh2 as fallback if (!privateKeyObj) { - console.log("Attempting fallback to parseSSHKey function..."); try { const keyInfo = parseSSHKey(privateKey, keyPassword); - console.log("parseSSHKey fallback result:", keyInfo); if (keyInfo.success && keyInfo.publicKey) { - // Ensure SSH2 fallback also returns proper string const publicKeyString = String(keyInfo.publicKey); - console.log( - "SSH2 fallback public key type:", - typeof publicKeyString, - ); - console.log( - "SSH2 fallback public key length:", - publicKeyString.length, - ); - return res.json({ success: true, publicKey: publicKeyString, @@ -1106,7 +1031,6 @@ router.post( } if (!privateKeyObj) { - console.error("All parsing attempts failed:", parseAttempts); return res.status(400).json({ success: false, error: "Unable to parse private key. Tried multiple formats.", @@ -1121,30 +1045,12 @@ router.post( format: "pem", }); - // Debug: Check what we're actually generating - console.log("Generated public key type:", typeof publicKeyPem); - console.log( - "Generated public key is Buffer:", - Buffer.isBuffer(publicKeyPem), - ); - // Ensure publicKeyPem is a string const publicKeyString = typeof publicKeyPem === "string" ? publicKeyPem : publicKeyPem.toString("utf8"); - console.log("Public key string length:", publicKeyString.length); - console.log( - "Generated public key first 100 chars:", - publicKeyString.substring(0, 100), - ); - console.log("Public key is string:", typeof publicKeyString === "string"); - console.log( - "Public key contains PEM header:", - publicKeyString.includes("-----BEGIN PUBLIC KEY-----"), - ); - // Detect key type from the private key object let keyType = "unknown"; const asymmetricKeyType = privateKeyObj.asymmetricKeyType; @@ -1169,12 +1075,9 @@ router.post( const base64Data = publicKeyBuffer.toString("base64"); finalPublicKey = `${keyType} ${base64Data}`; formatType = "ssh"; - console.log("SSH format public key generated!"); - } else { - console.warn("ssh2 parsing failed, using PEM format"); } } catch (sshError) { - console.warn("ssh2 failed, using PEM format"); + // Use PEM format as fallback } const response = { @@ -1184,20 +1087,8 @@ router.post( format: formatType, }; - console.log("Final response publicKey type:", typeof response.publicKey); - console.log("Final response publicKey format:", response.format); - console.log( - "Final response publicKey length:", - response.publicKey.length, - ); - console.log( - "Public key generated successfully using crypto module:", - keyType, - ); - res.json(response); } catch (error) { - console.error("Exception in generate-public-key endpoint:", error); authLogger.error("Failed to generate public key", error); res.status(500).json({ success: false, diff --git a/src/backend/database/routes/ssh.ts b/src/backend/database/routes/ssh.ts index 0bf474c4..9147b16e 100644 --- a/src/backend/database/routes/ssh.ts +++ b/src/backend/database/routes/ssh.ts @@ -74,21 +74,6 @@ router.get("/db/host/internal", async (req: Request, res: Response) => { ) ); - console.log("=== AUTOSTART QUERY DEBUG ==="); - console.log("Found autostart hosts count:", autostartHosts.length); - autostartHosts.forEach((host, index) => { - console.log(`Host ${index + 1}:`, { - id: host.id, - ip: host.ip, - username: host.username, - hasAutostartPassword: !!host.autostartPassword, - hasAutostartKey: !!host.autostartKey, - autostartPasswordLength: host.autostartPassword?.length || 0, - autostartKeyLength: host.autostartKey?.length || 0 - }); - }); - console.log("=== END AUTOSTART QUERY DEBUG ==="); - sshLogger.info("Internal autostart endpoint accessed", { operation: "autostart_internal_access", configCount: autostartHosts.length, @@ -102,20 +87,6 @@ router.get("/db/host/internal", async (req: Request, res: Response) => { ? JSON.parse(host.tunnelConnections) : []; - // Debug: Log what we're reading from database - sshLogger.info(`Autostart host from DB:`, { - hostId: host.id, - ip: host.ip, - username: host.username, - hasAutostartPassword: !!host.autostartPassword, - hasAutostartKey: !!host.autostartKey, - hasEncryptedPassword: !!host.password, - hasEncryptedKey: !!host.key, - authType: host.authType, - autostartPasswordLength: host.autostartPassword?.length || 0, - autostartKeyLength: host.autostartKey?.length || 0, - }); - return { id: host.id, userId: host.userId, @@ -179,22 +150,6 @@ router.get("/db/host/internal/all", async (req: Request, res: Response) => { ? JSON.parse(host.tunnelConnections) : []; - // Debug: Log what we're reading from database for all hosts - sshLogger.info(`All hosts endpoint - host from DB:`, { - hostId: host.id, - ip: host.ip, - username: host.username, - hasAutostartPassword: !!host.autostartPassword, - hasAutostartKey: !!host.autostartKey, - hasEncryptedPassword: !!host.password, - hasEncryptedKey: !!host.key, - authType: host.authType, - autostartPasswordLength: host.autostartPassword?.length || 0, - autostartKeyLength: host.autostartKey?.length || 0, - encryptedPasswordLength: host.password?.length || 0, - encryptedKeyLength: host.key?.length || 0, - }); - return { id: host.id, userId: host.userId, @@ -1474,17 +1429,6 @@ router.post( // Decrypt sensitive fields const decryptedConfig = DataCrypto.decryptRecord("ssh_data", config, userId, userDataKey); - // Debug: Log what we're about to save - console.log("=== AUTOSTART DEBUG: Decrypted credentials ==="); - console.log("sshConfigId:", sshConfigId); - console.log("authType:", config.authType); - console.log("hasPassword:", !!decryptedConfig.password); - console.log("hasKey:", !!decryptedConfig.key); - console.log("hasKeyPassword:", !!decryptedConfig.keyPassword); - console.log("passwordLength:", decryptedConfig.password?.length || 0); - console.log("keyLength:", decryptedConfig.key?.length || 0); - console.log("=== END AUTOSTART DEBUG ==="); - // Also handle tunnel connections - populate endpoint credentials let updatedTunnelConnections = config.tunnelConnections; if (config.tunnelConnections) { @@ -1495,9 +1439,6 @@ router.post( const resolvedConnections = await Promise.all( tunnelConnections.map(async (tunnel: any) => { if (tunnel.autoStart && tunnel.endpointHost && !tunnel.endpointPassword && !tunnel.endpointKey) { - console.log("=== RESOLVING ENDPOINT CREDENTIALS ==="); - console.log("endpointHost:", tunnel.endpointHost); - // Find endpoint host by name or username@ip const endpointHosts = await db.select() .from(sshData) @@ -1509,17 +1450,9 @@ router.post( ); if (endpointHost) { - console.log("Found endpoint host:", endpointHost.id, endpointHost.ip); - // Decrypt endpoint host credentials const decryptedEndpoint = DataCrypto.decryptRecord("ssh_data", endpointHost, userId, userDataKey); - console.log("Endpoint credentials:", { - hasPassword: !!decryptedEndpoint.password, - hasKey: !!decryptedEndpoint.key, - passwordLength: decryptedEndpoint.password?.length || 0 - }); - // Add endpoint credentials to tunnel connection return { ...tunnel, @@ -1535,9 +1468,11 @@ router.post( ); updatedTunnelConnections = JSON.stringify(resolvedConnections); - console.log("=== UPDATED TUNNEL CONNECTIONS ==="); } catch (error) { - console.log("=== TUNNEL CONNECTION UPDATE FAILED ===", error); + sshLogger.warn("Failed to update tunnel connections", { + operation: "tunnel_connections_update_failed", + error: error instanceof Error ? error.message : "Unknown error" + }); } } @@ -1551,36 +1486,14 @@ router.post( }) .where(eq(sshData.id, sshConfigId)); - // Debug: Log update result - console.log("=== AUTOSTART DEBUG: Update result ==="); - console.log("updateResult:", updateResult); - console.log("update completed for sshConfigId:", sshConfigId); - console.log("=== END UPDATE DEBUG ==="); - // Force database save after autostart update try { await DatabaseSaveTrigger.triggerSave(); - console.log("=== DATABASE SAVE TRIGGERED AFTER AUTOSTART ==="); } catch (saveError) { - console.log("=== DATABASE SAVE FAILED ===", saveError); - } - - // Verify the data was actually saved - try { - const verifyQuery = await db.select() - .from(sshData) - .where(eq(sshData.id, sshConfigId)); - - if (verifyQuery.length > 0) { - const saved = verifyQuery[0]; - console.log("=== VERIFICATION: Data actually saved ==="); - console.log("autostartPassword exists:", !!saved.autostartPassword); - console.log("autostartKey exists:", !!saved.autostartKey); - console.log("autostartPassword length:", saved.autostartPassword?.length || 0); - console.log("=== END VERIFICATION ==="); - } - } catch (verifyError) { - console.log("=== VERIFICATION FAILED ===", verifyError); + sshLogger.warn("Database save failed after autostart", { + operation: "autostart_db_save_failed", + error: saveError instanceof Error ? saveError.message : "Unknown error" + }); } sshLogger.success("AutoStart enabled successfully", { diff --git a/src/backend/database/routes/users.ts b/src/backend/database/routes/users.ts index 742120db..b77c47f4 100644 --- a/src/backend/database/routes/users.ts +++ b/src/backend/database/routes/users.ts @@ -505,7 +505,7 @@ router.get("/oidc/authorize", async (req, res) => { "http://localhost:5173"; if (origin.includes("localhost")) { - origin = "http://localhost:8081"; + origin = "http://localhost:30001"; } const redirectUri = `${origin}/users/oidc/callback`; diff --git a/src/backend/ssh/file-manager.ts b/src/backend/ssh/file-manager.ts index 7a3fb816..c1fb0f98 100644 --- a/src/backend/ssh/file-manager.ts +++ b/src/backend/ssh/file-manager.ts @@ -95,7 +95,6 @@ function scheduleSessionCleanup(sessionId: string) { // Increase timeout to 30 minutes of inactivity session.timeout = setTimeout(() => { - fileLogger.info(`Cleaning up inactive SSH session: ${sessionId}`); cleanupSession(sessionId); }, 30 * 60 * 1000); // 30 minutes - increased from 10 minutes } @@ -342,12 +341,6 @@ app.post("/ssh/file_manager/ssh/keepalive", (req, res) => { session.lastActive = Date.now(); scheduleSessionCleanup(sessionId); - fileLogger.debug(`SSH session keepalive: ${sessionId}`, { - operation: "ssh_keepalive", - sessionId, - lastActive: session.lastActive, - }); - res.json({ status: "success", connected: true, @@ -2124,7 +2117,7 @@ app.post("/ssh/file_manager/ssh/executeFile", async (req, res) => { }); }); -const PORT = 8084; +const PORT = 30004; app.listen(PORT, async () => { fileLogger.success("File Manager API server started", { operation: "server_start", diff --git a/src/backend/ssh/server-stats.ts b/src/backend/ssh/server-stats.ts index 4c7141b9..18602d02 100644 --- a/src/backend/ssh/server-stats.ts +++ b/src/backend/ssh/server-stats.ts @@ -412,19 +412,6 @@ async function resolveHostCredentials( if (credentials.length > 0) { const credential = credentials[0]; - statsLogger.debug( - `Using credential ${credential.id} for host ${host.id}`, - { - operation: "credential_resolve", - credentialId: credential.id, - authType: credential.authType, - hasPassword: !!credential.password, - hasKey: !!credential.key, - passwordLength: credential.password?.length || 0, - keyLength: credential.key?.length || 0, - }, - ); - baseHost.credentialId = credential.id; baseHost.username = credential.username; baseHost.authType = credential.authType; @@ -471,20 +458,6 @@ function addLegacyCredentials(baseHost: any, host: any): void { } function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { - statsLogger.debug(`Building SSH config for host ${host.ip}`, { - operation: "ssh_config", - authType: host.authType, - hasPassword: !!host.password, - hasKey: !!host.key, - username: host.username, - passwordLength: host.password?.length || 0, - keyLength: host.key?.length || 0, - passwordType: typeof host.password, - passwordRaw: host.password - ? JSON.stringify(host.password.substring(0, 20)) - : null, - }); - const base: ConnectConfig = { host: host.ip, port: host.port || 22, @@ -521,26 +494,12 @@ function buildSshConfig(host: SSHHostWithCredentials): ConnectConfig { if (!host.password) { throw new Error(`No password available for host ${host.ip}`); } - statsLogger.debug(`Using password auth for ${host.ip}`, { - operation: "ssh_config", - passwordLength: host.password.length, - passwordFirst3: host.password.substring(0, 3), - passwordLast3: host.password.substring(host.password.length - 3), - passwordType: typeof host.password, - passwordIsString: typeof host.password === "string", - }); (base as any).password = host.password; } else if (host.authType === "key") { if (!host.key) { throw new Error(`No SSH key available for host ${host.ip}`); } - statsLogger.debug(`Using key auth for ${host.ip}`, { - operation: "ssh_config", - keyPreview: host.key.substring(0, Math.min(50, host.key.length)) + "...", - hasPassphrase: !!host.keyPassword, - }); - try { if (!host.key.includes("-----BEGIN") || !host.key.includes("-----END")) { throw new Error("Invalid private key format"); @@ -988,7 +947,7 @@ process.on("SIGTERM", () => { process.exit(0); }); -const PORT = 8085; +const PORT = 30005; app.listen(PORT, async () => { statsLogger.success("Server Stats API server started", { operation: "server_start", diff --git a/src/backend/ssh/terminal.ts b/src/backend/ssh/terminal.ts index b341ebc9..547ea0cd 100644 --- a/src/backend/ssh/terminal.ts +++ b/src/backend/ssh/terminal.ts @@ -17,7 +17,7 @@ const userCrypto = UserCrypto.getInstance(); const userConnections = new Map>(); const wss = new WebSocketServer({ - port: 8082, + port: 30002, // WebSocket authentication during handshake verifyClient: async (info) => { try { @@ -90,7 +90,7 @@ const wss = new WebSocketServer({ sshLogger.success("SSH Terminal WebSocket server started with authentication", { operation: "server_start", - port: 8082, + port: 30002, features: ["JWT_auth", "connection_limits", "data_access_control"] }); @@ -369,26 +369,6 @@ wss.on("connection", async (ws: WebSocket, req) => { } }, 60000); - sshLogger.debug(`Terminal SSH setup`, { - operation: "terminal_ssh", - hostId: id, - ip, - authType, - hasPassword: !!password, - passwordLength: password?.length || 0, - hasCredentialId: !!credentialId, - }); - - // SECURITY: Never log password information - removed password preview logging - sshLogger.debug(`SSH authentication setup`, { - operation: "terminal_ssh_auth_setup", - userId, - hostId: id, - authType, - hasPassword: !!password, - hasCredentialId: !!credentialId, - }); - let resolvedCredentials = { password, key, keyPassword, keyType, authType }; if (credentialId && id && hostConfig.userId) { try { @@ -502,12 +482,6 @@ wss.on("connection", async (ws: WebSocket, req) => { // Change to initial path if specified if (initialPath && initialPath.trim() !== "") { - sshLogger.debug(`Changing to initial path: ${initialPath}`, { - operation: "ssh_initial_path", - hostId: id, - path: initialPath, - }); - // Send cd command to change directory const cdCommand = `cd "${initialPath.replace(/"/g, '\\"')}" && pwd\n`; stream.write(cdCommand); @@ -515,12 +489,6 @@ wss.on("connection", async (ws: WebSocket, req) => { // Execute command if specified if (executeCommand && executeCommand.trim() !== "") { - sshLogger.debug(`Executing command: ${executeCommand}`, { - operation: "ssh_execute_command", - hostId: id, - command: executeCommand, - }); - // Wait a moment for the cd command to complete, then execute the command setTimeout(() => { const command = `${executeCommand}\n`; diff --git a/src/backend/ssh/tunnel.ts b/src/backend/ssh/tunnel.ts index 6c79b86b..95297d33 100644 --- a/src/backend/ssh/tunnel.ts +++ b/src/backend/ssh/tunnel.ts @@ -1283,7 +1283,7 @@ async function initializeAutoStartTunnels(): Promise { // Get autostart hosts for tunnel configs const autostartResponse = await axios.get( - "http://localhost:8081/ssh/db/host/internal", + "http://localhost:30001/ssh/db/host/internal", { headers: { "Content-Type": "application/json", @@ -1294,7 +1294,7 @@ async function initializeAutoStartTunnels(): Promise { // Get all hosts for endpointHost resolution const allHostsResponse = await axios.get( - "http://localhost:8081/ssh/db/host/internal/all", + "http://localhost:30001/ssh/db/host/internal/all", { headers: { "Content-Type": "application/json", @@ -1420,7 +1420,7 @@ async function initializeAutoStartTunnels(): Promise { } } -const PORT = 8083; +const PORT = 30003; app.listen(PORT, () => { tunnelLogger.success("SSH Tunnel API server started", { operation: "server_start", diff --git a/src/backend/utils/auth-manager.ts b/src/backend/utils/auth-manager.ts index 7efadecb..a0daddb9 100644 --- a/src/backend/utils/auth-manager.ts +++ b/src/backend/utils/auth-manager.ts @@ -121,10 +121,6 @@ class AuthManager { migratedFieldsCount: migrationResult.migratedFieldsCount, }); } else { - databaseLogger.debug("No lazy encryption migration needed for user", { - operation: "lazy_encryption_migration_not_needed", - userId, - }); } } catch (error) { diff --git a/src/backend/utils/auto-ssl-setup.ts b/src/backend/utils/auto-ssl-setup.ts index 786b7a5d..bdb987f1 100644 --- a/src/backend/utils/auto-ssl-setup.ts +++ b/src/backend/utils/auto-ssl-setup.ts @@ -24,13 +24,13 @@ export class AutoSSLSetup { */ static async initialize(): Promise { try { - systemLogger.info("๐Ÿ” Initializing SSL/TLS configuration...", { + systemLogger.info("Initializing SSL/TLS configuration...", { operation: "ssl_auto_init" }); // Check if SSL is already properly configured if (await this.isSSLConfigured()) { - systemLogger.info("โœ… SSL configuration already exists and is valid", { + systemLogger.info("SSL configuration already exists and is valid", { operation: "ssl_already_configured" }); return; @@ -42,19 +42,19 @@ export class AutoSSLSetup { // Setup environment variables for SSL await this.setupEnvironmentVariables(); - systemLogger.success("๐Ÿš€ SSL/TLS configuration completed successfully", { + systemLogger.success("SSL/TLS configuration completed successfully", { operation: "ssl_auto_init_complete", https_port: process.env.SSL_PORT || "8443", note: "HTTPS/WSS is now enabled by default" }); } catch (error) { - systemLogger.error("โŒ Failed to initialize SSL configuration", error, { + systemLogger.error("Failed to initialize SSL configuration", error, { operation: "ssl_auto_init_failed" }); // Don't crash the application - fallback to HTTP - systemLogger.warn("โš ๏ธ Falling back to HTTP-only mode", { + systemLogger.warn("Falling back to HTTP-only mode", { operation: "ssl_fallback_http" }); } @@ -84,7 +84,7 @@ export class AutoSSLSetup { * Generate SSL certificates automatically */ private static async generateSSLCertificates(): Promise { - systemLogger.info("๐Ÿ”‘ Generating SSL certificates for local development...", { + systemLogger.info("Generating SSL certificates for local development...", { operation: "ssl_cert_generation" }); @@ -142,7 +142,7 @@ IP.2 = ::1 // Clean up temp config await fs.unlink(configFile); - systemLogger.success("โœ… SSL certificates generated successfully", { + systemLogger.success("SSL certificates generated successfully", { operation: "ssl_cert_generated", cert_path: this.CERT_FILE, key_path: this.KEY_FILE, @@ -158,7 +158,7 @@ IP.2 = ::1 * Setup environment variables for SSL configuration */ private static async setupEnvironmentVariables(): Promise { - systemLogger.info("โš™๏ธ Configuring SSL environment variables...", { + systemLogger.info("Configuring SSL environment variables...", { operation: "ssl_env_setup" }); @@ -207,7 +207,7 @@ IP.2 = ::1 if (hasChanges || !envContent) { await fs.writeFile(this.ENV_FILE, updatedContent.trim() + '\n'); - systemLogger.info("โœ… SSL environment variables configured", { + systemLogger.info("SSL environment variables configured", { operation: "ssl_env_configured", file: this.ENV_FILE, variables: Object.keys(sslEnvVars) @@ -248,12 +248,12 @@ IP.2 = ::1 โ•‘ HTTP Port: ${(process.env.PORT || "8080").padEnd(47)} โ•‘ โ•‘ Domain: ${config.domain.padEnd(47)} โ•‘ โ•‘ โ•‘ -โ•‘ ๐ŸŒ Access URLs: โ•‘ +โ•‘ Access URLs: โ•‘ โ•‘ โ€ข HTTPS: https://localhost:${config.port.toString().padEnd(31)} โ•‘ โ•‘ โ€ข HTTP: http://localhost:${(process.env.PORT || "8080").padEnd(32)} โ•‘ โ•‘ โ•‘ -โ•‘ ๐Ÿ” WebSocket connections automatically use WSS over HTTPS โ•‘ -โ•‘ โš ๏ธ Self-signed certificate will show browser warnings โ•‘ +โ•‘ WebSocket connections automatically use WSS over HTTPS โ•‘ +โ•‘ Self-signed certificate will show browser warnings โ•‘ โ•šโ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• `); } diff --git a/src/backend/utils/database-file-encryption.ts b/src/backend/utils/database-file-encryption.ts index d646df6e..efe24276 100644 --- a/src/backend/utils/database-file-encryption.ts +++ b/src/backend/utils/database-file-encryption.ts @@ -440,10 +440,6 @@ class DatabaseFileEncryption { for (const tempFile of tempFiles) { if (fs.existsSync(tempFile)) { fs.unlinkSync(tempFile); - databaseLogger.debug("Cleaned up temporary file", { - operation: "temp_cleanup", - file: tempFile, - }); } } } catch (error) { diff --git a/src/backend/utils/database-migration.ts b/src/backend/utils/database-migration.ts index 0cb772c9..913961b4 100644 --- a/src/backend/utils/database-migration.ts +++ b/src/backend/utils/database-migration.ts @@ -183,12 +183,6 @@ export class DatabaseMigration { }); return false; } - - databaseLogger.debug("Table verification passed", { - operation: "migration_verify_table_success", - table: table.name, - rows: originalCount.count, - }); } databaseLogger.success("Migration integrity verification completed", { @@ -253,11 +247,6 @@ export class DatabaseMigration { for (const table of tables) { memoryDb.exec(table.sql); migratedTables++; - - databaseLogger.debug("Table structure created", { - operation: "migration_table_created", - table: table.name, - }); } // 6. ็ฆ็”จๅค–้”ฎ็บฆๆŸไปฅ้ฟๅ…ๆ’ๅ…ฅ้กบๅบ้—ฎ้ข˜ @@ -287,12 +276,6 @@ export class DatabaseMigration { insertTransaction(rows); migratedRows += rows.length; - - databaseLogger.debug("Table data migrated", { - operation: "migration_table_data", - table: table.name, - rows: rows.length, - }); } } @@ -424,10 +407,6 @@ export class DatabaseMigration { for (const file of [...backupsToDelete, ...migratedToDelete]) { try { fs.unlinkSync(file.path); - databaseLogger.debug("Cleaned up old migration file", { - operation: "migration_cleanup", - file: file.name, - }); } catch (error) { databaseLogger.warn("Failed to cleanup old migration file", { operation: "migration_cleanup_failed", diff --git a/src/backend/utils/database-save-trigger.ts b/src/backend/utils/database-save-trigger.ts index 8a729860..137acd34 100644 --- a/src/backend/utils/database-save-trigger.ts +++ b/src/backend/utils/database-save-trigger.ts @@ -42,27 +42,13 @@ export class DatabaseSaveTrigger { // ้˜ฒๆŠ–๏ผšๅปถ่ฟŸ2็ง’ๆ‰ง่กŒ๏ผŒๅฆ‚ๆžœ2็ง’ๅ†…ๆœ‰ๆ–ฐ็š„ไฟๅญ˜่ฏทๆฑ‚๏ผŒๅˆ™้‡ๆ–ฐ่ฎกๆ—ถ this.saveTimeout = setTimeout(async () => { if (this.pendingSave) { - databaseLogger.debug("Database save already in progress, skipping", { - operation: "db_save_trigger_skip", - reason, - }); return; } this.pendingSave = true; try { - databaseLogger.debug("Triggering database save", { - operation: "db_save_trigger_start", - reason, - }); - await this.saveFunction!(); - - databaseLogger.debug("Database save completed", { - operation: "db_save_trigger_success", - reason, - }); } catch (error) { databaseLogger.error("Database save failed", error, { operation: "db_save_trigger_failed", @@ -94,10 +80,6 @@ export class DatabaseSaveTrigger { } if (this.pendingSave) { - databaseLogger.debug("Database save already in progress, waiting", { - operation: "db_save_trigger_force_wait", - reason, - }); return; } diff --git a/src/backend/utils/lazy-field-encryption.ts b/src/backend/utils/lazy-field-encryption.ts index c46637ab..50efd9e6 100644 --- a/src/backend/utils/lazy-field-encryption.ts +++ b/src/backend/utils/lazy-field-encryption.ts @@ -41,22 +41,11 @@ export class LazyFieldEncryption { if (this.isPlaintextField(fieldValue)) { // ๆ˜Žๆ–‡ๆ•ฐๆฎ๏ผŒ็›ดๆŽฅ่ฟ”ๅ›ž - databaseLogger.debug("Field detected as plaintext, returning as-is", { - operation: "lazy_encryption_plaintext_detected", - recordId, - fieldName, - valuePreview: fieldValue.substring(0, 10) + "...", - }); return fieldValue; } else { // ๅŠ ๅฏ†ๆ•ฐๆฎ๏ผŒ้œ€่ฆ่งฃๅฏ† try { const decrypted = FieldCrypto.decryptField(fieldValue, userKEK, recordId, fieldName); - databaseLogger.debug("Field decrypted successfully", { - operation: "lazy_encryption_decrypt_success", - recordId, - fieldName, - }); return decrypted; } catch (error) { databaseLogger.error("Failed to decrypt field", error, { @@ -108,11 +97,6 @@ export class LazyFieldEncryption { } } else { // ๅทฒ็ปๅŠ ๅฏ†๏ผŒๆ— ้œ€ๅค„็† - databaseLogger.debug("Field already encrypted, no migration needed", { - operation: "lazy_encryption_already_encrypted", - recordId, - fieldName, - }); return { encrypted: fieldValue, wasPlaintext: false }; } } @@ -149,12 +133,6 @@ export class LazyFieldEncryption { updatedRecord[fieldName] = encrypted; migratedFields.push(fieldName); needsUpdate = true; - - databaseLogger.debug("Record field migrated to encrypted", { - operation: "lazy_encryption_record_field_migrated", - recordId, - fieldName, - }); } catch (error) { databaseLogger.error("Failed to migrate record field", error, { operation: "lazy_encryption_record_field_failed", diff --git a/src/backend/utils/logger.ts b/src/backend/utils/logger.ts index 598e10a8..5b24c111 100644 --- a/src/backend/utils/logger.ts +++ b/src/backend/utils/logger.ts @@ -14,10 +14,23 @@ export interface LogContext { [key: string]: any; } +// Sensitive fields that should be masked in logs +const SENSITIVE_FIELDS = [ + 'password', 'passphrase', 'key', 'privateKey', 'publicKey', 'token', 'secret', + 'clientSecret', 'keyPassword', 'autostartPassword', 'autostartKey', 'autostartKeyPassword', + 'credentialId', 'authToken', 'jwt', 'session', 'cookie' +]; + +// Fields that should be truncated if too long +const TRUNCATE_FIELDS = ['data', 'content', 'body', 'response', 'request']; + class Logger { private serviceName: string; private serviceIcon: string; private serviceColor: string; + private logCounts = new Map(); + private readonly RATE_LIMIT_WINDOW = 60000; // 1 minute + private readonly RATE_LIMIT_MAX = 10; // Max logs per minute for same message constructor(serviceName: string, serviceIcon: string, serviceColor: string) { this.serviceName = serviceName; @@ -29,6 +42,32 @@ class Logger { return chalk.gray(`[${new Date().toLocaleTimeString()}]`); } + private sanitizeContext(context: LogContext): LogContext { + const sanitized = { ...context }; + + // Mask sensitive fields + for (const field of SENSITIVE_FIELDS) { + if (sanitized[field] !== undefined) { + if (typeof sanitized[field] === 'string' && sanitized[field].length > 0) { + sanitized[field] = '[MASKED]'; + } else if (typeof sanitized[field] === 'boolean') { + sanitized[field] = sanitized[field] ? '[PRESENT]' : '[ABSENT]'; + } else { + sanitized[field] = '[MASKED]'; + } + } + } + + // Truncate long fields + for (const field of TRUNCATE_FIELDS) { + if (sanitized[field] && typeof sanitized[field] === 'string' && sanitized[field].length > 100) { + sanitized[field] = sanitized[field].substring(0, 100) + '...'; + } + } + + return sanitized; + } + private formatMessage( level: LogLevel, message: string, @@ -41,14 +80,15 @@ class Logger { let contextStr = ""; if (context) { + const sanitizedContext = this.sanitizeContext(context); const contextParts = []; - if (context.operation) contextParts.push(`op:${context.operation}`); - if (context.userId) contextParts.push(`user:${context.userId}`); - if (context.hostId) contextParts.push(`host:${context.hostId}`); - if (context.tunnelName) contextParts.push(`tunnel:${context.tunnelName}`); - if (context.sessionId) contextParts.push(`session:${context.sessionId}`); - if (context.requestId) contextParts.push(`req:${context.requestId}`); - if (context.duration) contextParts.push(`duration:${context.duration}ms`); + if (sanitizedContext.operation) contextParts.push(`op:${sanitizedContext.operation}`); + if (sanitizedContext.userId) contextParts.push(`user:${sanitizedContext.userId}`); + if (sanitizedContext.hostId) contextParts.push(`host:${sanitizedContext.hostId}`); + if (sanitizedContext.tunnelName) contextParts.push(`tunnel:${sanitizedContext.tunnelName}`); + if (sanitizedContext.sessionId) contextParts.push(`session:${sanitizedContext.sessionId}`); + if (sanitizedContext.requestId) contextParts.push(`req:${sanitizedContext.requestId}`); + if (sanitizedContext.duration) contextParts.push(`duration:${sanitizedContext.duration}ms`); if (contextParts.length > 0) { contextStr = chalk.gray(` [${contextParts.join(",")}]`); @@ -75,30 +115,51 @@ class Logger { } } - private shouldLog(level: LogLevel): boolean { + private shouldLog(level: LogLevel, message: string): boolean { if (level === "debug" && process.env.NODE_ENV === "production") { return false; } + + // Rate limiting for frequent messages + const now = Date.now(); + const logKey = `${level}:${message}`; + const logInfo = this.logCounts.get(logKey); + + if (logInfo) { + if (now - logInfo.lastLog < this.RATE_LIMIT_WINDOW) { + logInfo.count++; + if (logInfo.count > this.RATE_LIMIT_MAX) { + return false; // Rate limited + } + } else { + // Reset counter for new window + logInfo.count = 1; + logInfo.lastLog = now; + } + } else { + this.logCounts.set(logKey, { count: 1, lastLog: now }); + } + return true; } debug(message: string, context?: LogContext): void { - if (!this.shouldLog("debug")) return; + if (!this.shouldLog("debug", message)) return; console.debug(this.formatMessage("debug", message, context)); } info(message: string, context?: LogContext): void { - if (!this.shouldLog("info")) return; + if (!this.shouldLog("info", message)) return; console.log(this.formatMessage("info", message, context)); } warn(message: string, context?: LogContext): void { - if (!this.shouldLog("warn")) return; + if (!this.shouldLog("warn", message)) return; console.warn(this.formatMessage("warn", message, context)); } error(message: string, error?: unknown, context?: LogContext): void { - if (!this.shouldLog("error")) return; + if (!this.shouldLog("error", message)) return; console.error(this.formatMessage("error", message, context)); if (error) { console.error(error); @@ -106,7 +167,7 @@ class Logger { } success(message: string, context?: LogContext): void { - if (!this.shouldLog("success")) return; + if (!this.shouldLog("success", message)) return; console.log(this.formatMessage("success", message, context)); } diff --git a/src/backend/utils/simple-db-ops.ts b/src/backend/utils/simple-db-ops.ts index 24fd6f82..cbb4b2d8 100644 --- a/src/backend/utils/simple-db-ops.ts +++ b/src/backend/utils/simple-db-ops.ts @@ -53,13 +53,6 @@ class SimpleDBOps { userDataKey ); - databaseLogger.debug(`Inserted encrypted record into ${tableName}`, { - operation: "simple_insert", - table: tableName, - userId, - recordId: result[0].id, - }); - return decryptedResult as T; } @@ -111,13 +104,6 @@ class SimpleDBOps { userDataKey ); - databaseLogger.debug(`Selected single record from ${tableName}`, { - operation: "simple_select_one", - table: tableName, - userId, - recordId: result.id, - }); - return decryptedResult; } @@ -155,13 +141,6 @@ class SimpleDBOps { userDataKey ); - databaseLogger.debug(`Updated records in ${tableName}`, { - operation: "simple_update", - table: tableName, - userId, - updatedCount: result.length, - }); - return decryptedResults as T[]; } diff --git a/src/backend/utils/ssh-key-utils.ts b/src/backend/utils/ssh-key-utils.ts index 417f473f..1de435b6 100644 --- a/src/backend/utils/ssh-key-utils.ts +++ b/src/backend/utils/ssh-key-utils.ts @@ -57,7 +57,6 @@ function detectKeyTypeFromContent(keyContent: string): string { // Default to RSA for OpenSSH format if we can't detect specifically return "ssh-rsa"; } catch (error) { - console.warn("Failed to decode OpenSSH key content:", error); // If decoding fails, default to RSA as it's most common for OpenSSH format return "ssh-rsa"; } @@ -103,7 +102,6 @@ function detectKeyTypeFromContent(keyContent: string): string { } } catch (error) { // If decoding fails, fall back to length-based detection - console.warn("Failed to decode private key for type detection:", error); } // Fallback: Try to detect key type from the content structure @@ -176,7 +174,6 @@ function detectPublicKeyTypeFromContent(publicKeyContent: string): string { } } catch (error) { // If decoding fails, fall back to length-based detection - console.warn("Failed to decode public key for type detection:", error); } // Fallback: Try to guess based on key length @@ -246,15 +243,6 @@ export function parseSSHKey( privateKeyData: string, passphrase?: string, ): KeyInfo { - console.log("=== SSH Key Parsing Debug ==="); - console.log("Key length:", privateKeyData?.length || "undefined"); - console.log( - "First 100 chars:", - privateKeyData?.substring(0, 100) || "undefined", - ); - console.log("ssh2Utils available:", typeof ssh2Utils); - console.log("parseKey function available:", typeof ssh2Utils?.parseKey); - try { let keyType = "unknown"; let publicKey = ""; @@ -263,30 +251,17 @@ export function parseSSHKey( // Try SSH2 first if available if (ssh2Utils && typeof ssh2Utils.parseKey === "function") { try { - console.log("Calling ssh2Utils.parseKey..."); const parsedKey = ssh2Utils.parseKey(privateKeyData, passphrase); - console.log( - "parseKey returned:", - typeof parsedKey, - parsedKey instanceof Error ? parsedKey.message : "success", - ); if (!(parsedKey instanceof Error)) { // Extract key type if (parsedKey.type) { keyType = parsedKey.type; } - console.log("Extracted key type:", keyType); // Generate public key in SSH format try { - console.log("Attempting to generate public key..."); const publicKeyBuffer = parsedKey.getPublicSSH(); - console.log("Public key buffer type:", typeof publicKeyBuffer); - console.log( - "Public key buffer is Buffer:", - Buffer.isBuffer(publicKeyBuffer), - ); // ssh2's getPublicSSH() returns binary SSH protocol data, not text // We need to convert this to proper SSH public key format @@ -304,53 +279,26 @@ export function parseSSHKey( } else { publicKey = `${keyType} ${base64Data}`; } - - console.log( - "Generated SSH public key format, length:", - publicKey.length, - ); - console.log( - "Public key starts with:", - publicKey.substring(0, 50), - ); } else { - console.warn("Unexpected public key buffer type"); publicKey = ""; } } catch (error) { - console.warn("Failed to generate public key:", error); publicKey = ""; } useSSH2 = true; - console.log(`SSH key parsed successfully with SSH2: ${keyType}`); - } else { - console.warn("SSH2 parsing failed:", parsedKey.message); } } catch (error) { - console.warn( - "SSH2 parsing exception:", - error instanceof Error ? error.message : error, - ); + // SSH2 parsing failed, will fall back to content detection } - } else { - console.warn("SSH2 parseKey function not available"); } // Fallback to content-based detection if (!useSSH2) { - console.log("Using fallback key type detection..."); keyType = detectKeyTypeFromContent(privateKeyData); - console.log(`Fallback detected key type: ${keyType}`); // For fallback, we can't generate public key but the detection is still useful publicKey = ""; - - if (keyType !== "unknown") { - console.log( - `SSH key type detected successfully with fallback: ${keyType}`, - ); - } } return { @@ -360,17 +308,10 @@ export function parseSSHKey( success: keyType !== "unknown", }; } catch (error) { - console.error("Exception during SSH key parsing:", error); - console.error( - "Error stack:", - error instanceof Error ? error.stack : "No stack", - ); - // Final fallback - try content detection try { const fallbackKeyType = detectKeyTypeFromContent(privateKeyData); if (fallbackKeyType !== "unknown") { - console.log(`Final fallback detection successful: ${fallbackKeyType}`); return { privateKey: privateKeyData, publicKey: "", @@ -379,7 +320,7 @@ export function parseSSHKey( }; } } catch (fallbackError) { - console.error("Even fallback detection failed:", fallbackError); + // Even fallback detection failed } return { @@ -397,16 +338,8 @@ export function parseSSHKey( * Parse SSH public key and extract type information */ export function parsePublicKey(publicKeyData: string): PublicKeyInfo { - console.log("=== SSH Public Key Parsing Debug ==="); - console.log("Public key length:", publicKeyData?.length || "undefined"); - console.log( - "First 100 chars:", - publicKeyData?.substring(0, 100) || "undefined", - ); - try { const keyType = detectPublicKeyTypeFromContent(publicKeyData); - console.log(`Public key type detected: ${keyType}`); return { publicKey: publicKeyData, @@ -414,7 +347,6 @@ export function parsePublicKey(publicKeyData: string): PublicKeyInfo { success: keyType !== "unknown", }; } catch (error) { - console.error("Exception during SSH public key parsing:", error); return { publicKey: publicKeyData, keyType: "unknown", @@ -469,26 +401,11 @@ export function validateKeyPair( publicKeyData: string, passphrase?: string, ): KeyPairValidationResult { - console.log("=== Key Pair Validation Debug ==="); - console.log("Private key length:", privateKeyData?.length || "undefined"); - console.log("Public key length:", publicKeyData?.length || "undefined"); - try { // First parse the private key and try to generate public key const privateKeyInfo = parseSSHKey(privateKeyData, passphrase); const publicKeyInfo = parsePublicKey(publicKeyData); - console.log( - "Private key parsing result:", - privateKeyInfo.success, - privateKeyInfo.keyType, - ); - console.log( - "Public key parsing result:", - publicKeyInfo.success, - publicKeyInfo.keyType, - ); - if (!privateKeyInfo.success) { return { isValid: false, @@ -522,9 +439,6 @@ export function validateKeyPair( const generatedPublicKey = privateKeyInfo.publicKey.trim(); const providedPublicKey = publicKeyData.trim(); - console.log("Generated public key length:", generatedPublicKey.length); - console.log("Provided public key length:", providedPublicKey.length); - // Compare the key data part (excluding comments) const generatedKeyParts = generatedPublicKey.split(" "); const providedKeyParts = providedPublicKey.split(" "); @@ -535,15 +449,6 @@ export function validateKeyPair( generatedKeyParts[0] + " " + generatedKeyParts[1]; const providedKeyData = providedKeyParts[0] + " " + providedKeyParts[1]; - console.log( - "Generated key data:", - generatedKeyData.substring(0, 50) + "...", - ); - console.log( - "Provided key data:", - providedKeyData.substring(0, 50) + "...", - ); - if (generatedKeyData === providedKeyData) { return { isValid: true, @@ -571,7 +476,6 @@ export function validateKeyPair( error: "Unable to verify key pair match, but key types are compatible", }; } catch (error) { - console.error("Exception during key pair validation:", error); return { isValid: false, privateKeyType: "unknown", diff --git a/src/backend/utils/system-crypto.ts b/src/backend/utils/system-crypto.ts index 2135d003..bdf984d3 100644 --- a/src/backend/utils/system-crypto.ts +++ b/src/backend/utils/system-crypto.ts @@ -41,10 +41,6 @@ class SystemCrypto { const envSecret = process.env.JWT_SECRET; if (envSecret && envSecret.length >= 64) { this.jwtSecret = envSecret; - databaseLogger.info("โœ… Using JWT secret from environment variable", { - operation: "jwt_env_loaded", - source: "environment" - }); return; } @@ -82,10 +78,6 @@ class SystemCrypto { const envKey = process.env.DATABASE_KEY; if (envKey && envKey.length >= 64) { this.databaseKey = Buffer.from(envKey, 'hex'); - databaseLogger.info("โœ… Using database key from environment variable", { - operation: "db_key_env_loaded", - source: "environment" - }); return; } @@ -123,10 +115,6 @@ class SystemCrypto { const envToken = process.env.INTERNAL_AUTH_TOKEN; if (envToken && envToken.length >= 32) { this.internalAuthToken = envToken; - databaseLogger.info("โœ… Using internal auth token from environment variable", { - operation: "internal_auth_env_loaded", - source: "environment" - }); return; } @@ -164,7 +152,7 @@ class SystemCrypto { // Auto-save to .env file await this.updateEnvFile("JWT_SECRET", newSecret); - databaseLogger.success("๐Ÿ” JWT secret auto-generated and saved to .env", { + databaseLogger.success("JWT secret auto-generated and saved to .env", { operation: "jwt_auto_generated", instanceId, envVarName: "JWT_SECRET", @@ -210,7 +198,7 @@ class SystemCrypto { // Auto-save to .env file await this.updateEnvFile("INTERNAL_AUTH_TOKEN", newToken); - databaseLogger.success("๐Ÿ”‘ Internal auth token auto-generated and saved to .env", { + databaseLogger.success("Internal auth token auto-generated and saved to .env", { operation: "internal_auth_auto_generated", instanceId, envVarName: "INTERNAL_AUTH_TOKEN", diff --git a/src/locales/en/translation.json b/src/locales/en/translation.json index fa404b7e..653ab6a1 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -87,7 +87,7 @@ "keyPassphraseOptional": "Optional: leave empty if your key has no passphrase", "leaveEmptyToKeepCurrent": "Leave empty to keep current value", "uploadKeyFile": "Upload Key File", - "generateKeyPair": "Generate Key Pair", + "generateKeyPairButton": "Generate Key Pair", "sshKeyGenerationNotImplemented": "SSH key generation feature coming soon", "connectionTestingNotImplemented": "Connection testing feature coming soon", "testConnection": "Test Connection", @@ -123,7 +123,7 @@ "editCredentialDescription": "Update the credential information", "listView": "List", "folderView": "Folders", - "unknown": "Unknown", + "unknownCredential": "Unknown", "confirmRemoveFromFolder": "Are you sure you want to remove \"{{name}}\" from folder \"{{folder}}\"? The credential will be moved to \"Uncategorized\".", "removedFromFolder": "Credential \"{{name}}\" removed from folder successfully", "failedToRemoveFromFolder": "Failed to remove credential from folder", @@ -144,7 +144,7 @@ "detectedKeyType": "Detected key type", "detectingKeyType": "detecting...", "optional": "Optional", - "generateKeyPair": "Generate New Key Pair", + "generateKeyPairNew": "Generate New Key Pair", "generateEd25519": "Generate Ed25519", "generateECDSA": "Generate ECDSA", "generateRSA": "Generate RSA", @@ -155,6 +155,16 @@ "detectionError": "Detection Error", "unknown": "Unknown" }, + "dragIndicator": { + "error": "Error: {{error}}", + "dragging": "Dragging {{fileName}}", + "preparing": "Preparing {{fileName}}", + "readySingle": "Ready to download {{fileName}}", + "readyMultiple": "Ready to download {{count}} files", + "batchDrag": "Drag {{count}} files to desktop", + "dragToDesktop": "Drag to desktop", + "canDragAnywhere": "You can drag files anywhere on your desktop" + }, "sshTools": { "title": "SSH Tools", "closeTools": "Close SSH Tools", @@ -190,7 +200,7 @@ "saveError": "Error saving configuration", "saving": "Saving...", "saveConfig": "Save Configuration", - "helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:8081 or https://your-server.com)" + "helpText": "Enter the URL where your Termix server is running (e.g., http://localhost:30001 or https://your-server.com)" }, "common": { "close": "Close", @@ -294,7 +304,10 @@ "failedToInitiatePasswordReset": "Failed to initiate password reset", "failedToVerifyResetCode": "Failed to verify reset code", "failedToCompletePasswordReset": "Failed to complete password reset", - "documentation": "Documentation" + "documentation": "Documentation", + "retry": "Retry", + "checking": "Checking...", + "checkingDatabase": "Checking database connection..." }, "nav": { "home": "Home", @@ -719,6 +732,9 @@ "failedToCreateFile": "Failed to create file", "folderCreatedSuccessfully": "Folder \"{{name}}\" created successfully", "failedToCreateFolder": "Failed to create folder", + "failedToCreateItem": "Failed to create item", + "operationFailed": "{{operation}} operation failed for {{name}}: {{error}}", + "failedToResolveSymlink": "Failed to resolve symlink", "itemDeletedSuccessfully": "{{type}} deleted successfully", "itemsDeletedSuccessfully": "{{count}} items deleted successfully", "failedToDeleteItems": "Failed to delete items", @@ -774,7 +790,7 @@ "serverError": "Server Error", "error": "Error", "requestFailed": "Request failed with status code", - "unknown": "unknown", + "unknownFileError": "unknown", "cannotReadFile": "Cannot read file", "noSshSessionId": "No SSH session ID available", "noFilePath": "No file path available", @@ -925,7 +941,7 @@ "disconnected": "Disconnected", "connecting": "Connecting...", "disconnecting": "Disconnecting...", - "unknown": "Unknown", + "unknownTunnelStatus": "Unknown", "error": "Error", "failed": "Failed", "retrying": "Retrying", @@ -962,7 +978,7 @@ "dynamic": "Dynamic", "noSshTunnels": "No SSH Tunnels", "createFirstTunnelMessage": "Create your first SSH tunnel to get started. Use the SSH Manager to add hosts with tunnel connections.", - "unknown": "Unknown", + "unknownConnectionStatus": "Unknown", "connected": "Connected", "connecting": "Connecting...", "disconnecting": "Disconnecting...", @@ -1105,7 +1121,7 @@ "forbidden": "Access forbidden", "serverError": "Server error", "networkError": "Network error", - "databaseConnection": "Could not connect to the database. Please try again later.", + "databaseConnection": "Could not connect to the database.", "unknownError": "Unknown error", "failedPasswordReset": "Failed to initiate password reset", "failedVerifyCode": "Failed to verify reset code", @@ -1143,7 +1159,15 @@ "reconnecting": "Reconnecting...", "processing": "Processing...", "pleaseWait": "Please wait...", - "registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator." + "registrationDisabled": "New account registration is currently disabled by an admin. Please log in or contact an administrator.", + "databaseConnected": "Database connected successfully", + "databaseConnectionFailed": "Failed to connect to the database server", + "checkServerConnection": "Please check your server connection and try again", + "resetCodeSent": "Reset code sent to your email", + "codeVerified": "Code verified successfully", + "passwordResetSuccess": "Password reset successfully", + "loginSuccess": "Login successful", + "registrationSuccess": "Registration successful" }, "profile": { "title": "User Profile", @@ -1306,6 +1330,9 @@ "updateKey": "Update Key", "productionFolder": "Production", "databaseServer": "Database Server", + "developmentServer": "Development Server", + "developmentFolder": "Development", + "webServerProduction": "Web Server - Production", "unknownError": "Unknown error", "failedToInitiatePasswordReset": "Failed to initiate password reset", "failedToVerifyResetCode": "Failed to verify reset code", diff --git a/src/locales/zh/translation.json b/src/locales/zh/translation.json index fc26cf38..b4c908ad 100644 --- a/src/locales/zh/translation.json +++ b/src/locales/zh/translation.json @@ -87,7 +87,7 @@ "keyPassphraseOptional": "ๅฏ้€‰๏ผšๅฆ‚ๆžœๆ‚จ็š„ๅฏ†้’ฅๆฒกๆœ‰ๅฏ†็ ๏ผŒ่ฏท็•™็ฉบ", "leaveEmptyToKeepCurrent": "็•™็ฉบไปฅไฟๆŒๅฝ“ๅ‰ๅ€ผ", "uploadKeyFile": "ไธŠไผ ๅฏ†้’ฅๆ–‡ไปถ", - "generateKeyPair": "็”Ÿๆˆๅฏ†้’ฅๅฏน", + "generateKeyPairButton": "็”Ÿๆˆๅฏ†้’ฅๅฏน", "sshKeyGenerationNotImplemented": "SSHๅฏ†้’ฅ็”ŸๆˆๅŠŸ่ƒฝๅณๅฐ†ๆŽจๅ‡บ", "connectionTestingNotImplemented": "่ฟžๆŽฅๆต‹่ฏ•ๅŠŸ่ƒฝๅณๅฐ†ๆŽจๅ‡บ", "testConnection": "ๆต‹่ฏ•่ฟžๆŽฅ", @@ -122,7 +122,7 @@ "editCredentialDescription": "ๆ›ดๆ–ฐๅ‡ญๆฎไฟกๆฏ", "listView": "ๅˆ—่กจ", "folderView": "ๆ–‡ไปถๅคน", - "unknown": "ๆœช็Ÿฅ", + "unknownCredential": "ๆœช็Ÿฅ", "confirmRemoveFromFolder": "็กฎๅฎš่ฆๅฐ†\"{{name}}\"ไปŽๆ–‡ไปถๅคน\"{{folder}}\"ไธญ็งป้™คๅ—๏ผŸๅ‡ญๆฎๅฐ†่ขซ็งปๅŠจๅˆฐ\"ๆœชๅˆ†็ฑป\"ใ€‚", "removedFromFolder": "ๅ‡ญๆฎ\"{{name}}\"ๅทฒๆˆๅŠŸไปŽๆ–‡ไปถๅคนไธญ็งป้™ค", "failedToRemoveFromFolder": "ไปŽๆ–‡ไปถๅคนไธญ็งป้™คๅ‡ญๆฎๅคฑ่ดฅ", @@ -143,7 +143,7 @@ "detectedKeyType": "ๆฃ€ๆต‹ๅˆฐ็š„ๅฏ†้’ฅ็ฑปๅž‹", "detectingKeyType": "ๆฃ€ๆต‹ไธญ...", "optional": "ๅฏ้€‰", - "generateKeyPair": "็”Ÿๆˆๆ–ฐ็š„ๅฏ†้’ฅๅฏน", + "generateKeyPairNew": "็”Ÿๆˆๆ–ฐ็š„ๅฏ†้’ฅๅฏน", "generateEd25519": "็”Ÿๆˆ Ed25519", "generateECDSA": "็”Ÿๆˆ ECDSA", "generateRSA": "็”Ÿๆˆ RSA", @@ -151,8 +151,17 @@ "failedToGenerateKeyPair": "็”Ÿๆˆๅฏ†้’ฅๅฏนๅคฑ่ดฅ", "generateKeyPairNote": "็›ดๆŽฅ็”Ÿๆˆๆ–ฐ็š„SSHๅฏ†้’ฅๅฏนใ€‚่ฟ™ๅฐ†ๆ›ฟๆข่กจๅ•ไธญ็š„็Žฐๆœ‰ๅฏ†้’ฅใ€‚", "invalidKey": "ๆ— ๆ•ˆๅฏ†้’ฅ", - "detectionError": "ๆฃ€ๆต‹้”™่ฏฏ", - "unknown": "ๆœช็Ÿฅ" + "detectionError": "ๆฃ€ๆต‹้”™่ฏฏ" + }, + "dragIndicator": { + "error": "้”™่ฏฏ๏ผš{{error}}", + "dragging": "ๆญฃๅœจๆ‹–ๆ‹ฝ {{fileName}}", + "preparing": "ๆญฃๅœจๅ‡†ๅค‡ {{fileName}}", + "readySingle": "ๅ‡†ๅค‡ไธ‹่ฝฝ {{fileName}}", + "readyMultiple": "ๅ‡†ๅค‡ไธ‹่ฝฝ {{count}} ไธชๆ–‡ไปถ", + "batchDrag": "ๆ‹–ๆ‹ฝ {{count}} ไธชๆ–‡ไปถๅˆฐๆกŒ้ข", + "dragToDesktop": "ๆ‹–ๆ‹ฝๅˆฐๆกŒ้ข", + "canDragAnywhere": "ๆ‚จๅฏไปฅๅฐ†ๆ–‡ไปถๆ‹–ๆ‹ฝๅˆฐๆกŒ้ข็š„ไปปไฝ•ไฝ็ฝฎ" }, "sshTools": { "title": "SSH ๅทฅๅ…ท", @@ -189,7 +198,7 @@ "saveError": "ไฟๅญ˜้…็ฝฎๆ—ถๅ‡บ้”™", "saving": "ไฟๅญ˜ไธญ...", "saveConfig": "ไฟๅญ˜้…็ฝฎ", - "helpText": "่พ“ๅ…ฅๆ‚จ็š„ Termix ๆœๅŠกๅ™จ่ฟ่กŒๅœฐๅ€๏ผˆไพ‹ๅฆ‚๏ผšhttp://localhost:8081 ๆˆ– https://your-server.com๏ผ‰" + "helpText": "่พ“ๅ…ฅๆ‚จ็š„ Termix ๆœๅŠกๅ™จ่ฟ่กŒๅœฐๅ€๏ผˆไพ‹ๅฆ‚๏ผšhttp://localhost:30001 ๆˆ– https://your-server.com๏ผ‰" }, "common": { "close": "ๅ…ณ้—ญ", @@ -281,7 +290,10 @@ "failedToInitiatePasswordReset": "ๅฏๅŠจๅฏ†็ ้‡็ฝฎๅคฑ่ดฅ", "failedToVerifyResetCode": "้ชŒ่ฏ้‡็ฝฎไปฃ็ ๅคฑ่ดฅ", "failedToCompletePasswordReset": "ๅฎŒๆˆๅฏ†็ ้‡็ฝฎๅคฑ่ดฅ", - "documentation": "ๆ–‡ๆกฃ" + "documentation": "ๆ–‡ๆกฃ", + "retry": "้‡่ฏ•", + "checking": "ๆฃ€ๆŸฅไธญ...", + "checkingDatabase": "ๆญฃๅœจๆฃ€ๆŸฅๆ•ฐๆฎๅบ“่ฟžๆŽฅ..." }, "nav": { "home": "้ฆ–้กต", @@ -742,6 +754,9 @@ "failedToCreateFile": "ๅˆ›ๅปบๆ–‡ไปถๅคฑ่ดฅ", "folderCreatedSuccessfully": "ๆ–‡ไปถๅคน \"{{name}}\" ๅˆ›ๅปบๆˆๅŠŸ", "failedToCreateFolder": "ๅˆ›ๅปบๆ–‡ไปถๅคนๅคฑ่ดฅ", + "failedToCreateItem": "ๅˆ›ๅปบ้กน็›ฎๅคฑ่ดฅ", + "operationFailed": "{{operation}} ๆ“ไฝœๅคฑ่ดฅ๏ผŒๆ–‡ไปถ {{name}}๏ผš{{error}}", + "failedToResolveSymlink": "่งฃๆž็ฌฆๅท้“พๆŽฅๅคฑ่ดฅ", "itemDeletedSuccessfully": "{{type}}ๅˆ ้™คๆˆๅŠŸ", "itemsDeletedSuccessfully": "{{count}} ไธช้กน็›ฎๅˆ ้™คๆˆๅŠŸ", "failedToDeleteItems": "ๅˆ ้™ค้กน็›ฎๅคฑ่ดฅ", @@ -800,7 +815,7 @@ "serverError": "ๆœๅŠกๅ™จ้”™่ฏฏ", "error": "้”™่ฏฏ", "requestFailed": "่ฏทๆฑ‚ๅคฑ่ดฅ๏ผŒ็Šถๆ€็ ", - "unknown": "ๆœช็Ÿฅ", + "unknownFileError": "ๆœช็Ÿฅ", "cannotReadFile": "ๆ— ๆณ•่ฏปๅ–ๆ–‡ไปถ", "noSshSessionId": "ๆฒกๆœ‰ๅฏ็”จ็š„ SSH ไผš่ฏ ID", "noFilePath": "ๆฒกๆœ‰ๅฏ็”จ็š„ๆ–‡ไปถ่ทฏๅพ„", @@ -940,7 +955,7 @@ "disconnected": "ๅทฒๆ–ญๅผ€่ฟžๆŽฅ", "connecting": "่ฟžๆŽฅไธญ...", "disconnecting": "ๆ–ญๅผ€่ฟžๆŽฅไธญ...", - "unknown": "ๆœช็Ÿฅ", + "unknownTunnelStatus": "ๆœช็Ÿฅ", "error": "้”™่ฏฏ", "failed": "ๅคฑ่ดฅ", "retrying": "้‡่ฏ•ไธญ", @@ -1106,7 +1121,7 @@ "forbidden": "่ฎฟ้—ฎ่ขซ็ฆๆญข", "serverError": "ๆœๅŠกๅ™จ้”™่ฏฏ", "networkError": "็ฝ‘็ปœ้”™่ฏฏ", - "databaseConnection": "ๆ— ๆณ•่ฟžๆŽฅๅˆฐๆ•ฐๆฎๅบ“ใ€‚่ฏท็จๅŽๅ†่ฏ•ใ€‚", + "databaseConnection": "ๆ— ๆณ•่ฟžๆŽฅๅˆฐๆ•ฐๆฎๅบ“ใ€‚", "unknownError": "ๆœช็Ÿฅ้”™่ฏฏ", "failedPasswordReset": "ๆ— ๆณ•ๅฏๅŠจๅฏ†็ ้‡็ฝฎ", "failedVerifyCode": "้ชŒ่ฏ้‡็ฝฎไปฃ็ ๅคฑ่ดฅ", @@ -1144,7 +1159,15 @@ "reconnecting": "้‡ๆ–ฐ่ฟžๆŽฅไธญ...", "processing": "ๅค„็†ไธญ...", "pleaseWait": "่ฏท็จๅ€™...", - "registrationDisabled": "ๆ–ฐ็”จๆˆทๆณจๅ†Œๅทฒ่ขซ็ฎก็†ๅ‘˜็ฆ็”จใ€‚่ฏท็™ปๅฝ•ๆˆ–่”็ณป็ฎก็†ๅ‘˜ใ€‚" + "registrationDisabled": "ๆ–ฐ็”จๆˆทๆณจๅ†Œๅทฒ่ขซ็ฎก็†ๅ‘˜็ฆ็”จใ€‚่ฏท็™ปๅฝ•ๆˆ–่”็ณป็ฎก็†ๅ‘˜ใ€‚", + "databaseConnected": "ๆ•ฐๆฎๅบ“่ฟžๆŽฅๆˆๅŠŸ", + "databaseConnectionFailed": "ๆ— ๆณ•่ฟžๆŽฅๅˆฐๆ•ฐๆฎๅบ“ๆœๅŠกๅ™จ", + "checkServerConnection": "่ฏทๆฃ€ๆŸฅๆ‚จ็š„ๆœๅŠกๅ™จ่ฟžๆŽฅๅนถ้‡่ฏ•", + "resetCodeSent": "้‡็ฝฎไปฃ็ ๅทฒๅ‘้€ๅˆฐๆ‚จ็š„้‚ฎ็ฎฑ", + "codeVerified": "ไปฃ็ ้ชŒ่ฏๆˆๅŠŸ", + "passwordResetSuccess": "ๅฏ†็ ้‡็ฝฎๆˆๅŠŸ", + "loginSuccess": "็™ปๅฝ•ๆˆๅŠŸ", + "registrationSuccess": "ๆณจๅ†ŒๆˆๅŠŸ" }, "profile": { "title": "็”จๆˆท่ต„ๆ–™", @@ -1308,6 +1331,9 @@ "sshServerConfigRequired": "้œ€่ฆ SSH ๆœๅŠกๅ™จ้…็ฝฎ", "productionFolder": "็”Ÿไบง็Žฏๅขƒ", "databaseServer": "ๆ•ฐๆฎๅบ“ๆœๅŠกๅ™จ", + "developmentServer": "ๅผ€ๅ‘ๆœๅŠกๅ™จ", + "developmentFolder": "ๅผ€ๅ‘็Žฏๅขƒ", + "webServerProduction": "Web ๆœๅŠกๅ™จ - ็”Ÿไบง็Žฏๅขƒ", "unknownError": "ๆœช็Ÿฅ้”™่ฏฏ", "failedToInitiatePasswordReset": "ๅฏๅŠจๅฏ†็ ้‡็ฝฎๅคฑ่ดฅ", "failedToVerifyResetCode": "้ชŒ่ฏ้‡็ฝฎไปฃ็ ๅคฑ่ดฅ", diff --git a/src/types/index.ts b/src/types/index.ts index 132be3dd..ae57b7ce 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -383,26 +383,6 @@ export interface FileManagerProps { initialHost?: SSHHost | null; } -export interface FileManagerLeftSidebarProps { - onSelectView?: (view: string) => void; - onOpenFile: (file: any) => void; - tabs: Tab[]; - host: SSHHost; - onOperationComplete?: () => void; - onError?: (error: string) => void; - onSuccess?: (message: string) => void; - onPathChange?: (path: string) => void; - onDeleteItem?: (item: any) => void; -} - -export interface FileManagerOperationsProps { - currentPath: string; - sshSessionId: string | null; - onOperationComplete?: () => void; - onError?: (error: string) => void; - onSuccess?: (message: string) => void; -} - export interface AlertCardProps { alert: TermixAlert; onDismiss: (alertId: string) => void; diff --git a/src/ui/Desktop/Admin/AdminSettings.tsx b/src/ui/Desktop/Admin/AdminSettings.tsx index 7978d459..fbe8d28b 100644 --- a/src/ui/Desktop/Admin/AdminSettings.tsx +++ b/src/ui/Desktop/Admin/AdminSettings.tsx @@ -295,7 +295,7 @@ export function AdminSettings({ const jwt = getCookie("jwt"); const apiUrl = isElectron() ? `${(window as any).configuredServerUrl}/database/export` - : "http://localhost:8081/database/export"; + : "http://localhost:30001/database/export"; const response = await fetch(apiUrl, { method: "POST", @@ -355,7 +355,7 @@ export function AdminSettings({ const jwt = getCookie("jwt"); const apiUrl = isElectron() ? `${(window as any).configuredServerUrl}/database/import` - : "http://localhost:8081/database/import"; + : "http://localhost:30001/database/import"; // Create FormData for file upload const formData = new FormData(); @@ -927,12 +927,29 @@ export function AdminSettings({

{t("admin.importDescription")}

- setImportFile(e.target.files?.[0] || null)} - className="block w-full text-xs file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:text-xs file:bg-muted file:text-foreground mb-2" - /> +
+ setImportFile(e.target.files?.[0] || null)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> + +
{importFile && (
diff --git a/src/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx b/src/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx deleted file mode 100644 index a98395da..00000000 --- a/src/ui/Desktop/Apps/File Manager/FIleManagerTopNavbar.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from "react"; -import { FileManagerTabList } from "./FileManagerTabList.tsx"; - -interface FileManagerTopNavbarProps { - tabs: { id: string | number; title: string }[]; - activeTab: string | number; - setActiveTab: (tab: string | number) => void; - closeTab: (tab: string | number) => void; - onHomeClick: () => void; -} - -export function FIleManagerTopNavbar( - props: FileManagerTopNavbarProps, -): React.ReactElement { - const { tabs, activeTab, setActiveTab, closeTab, onHomeClick } = props; - - return ( - - ); -} diff --git a/src/ui/Desktop/Apps/File Manager/FileManager.tsx b/src/ui/Desktop/Apps/File Manager/FileManager.tsx index 58444739..f10f8920 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManager.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManager.tsx @@ -1,15 +1,1850 @@ -import React from "react"; -import { FileManagerModern } from "@/ui/Desktop/Apps/File Manager/FileManagerModern.tsx"; -import type { SSHHost } from "../../../types/index.js"; +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { FileManagerGrid } from "./FileManagerGrid"; +import { FileManagerSidebar } from "./FileManagerSidebar"; +import { FileManagerContextMenu } from "./FileManagerContextMenu"; +import { useFileSelection } from "./hooks/useFileSelection"; +import { useDragAndDrop } from "./hooks/useDragAndDrop"; +import { WindowManager, useWindowManager } from "./components/WindowManager"; +import { FileWindow } from "./components/FileWindow"; +import { DiffWindow } from "./components/DiffWindow"; +import { useDragToDesktop } from "../../../hooks/useDragToDesktop"; +import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { toast } from "sonner"; +import { useTranslation } from "react-i18next"; +import { + Upload, + FolderPlus, + FilePlus, + RefreshCw, + Search, + Grid3X3, + List, + Eye, + Settings, +} from "lucide-react"; +import { TerminalWindow } from "./components/TerminalWindow"; +import type { SSHHost, FileItem } from "../../../types/index.js"; +import { + listSSHFiles, + uploadSSHFile, + downloadSSHFile, + createSSHFile, + createSSHFolder, + deleteSSHItem, + copySSHItem, + renameSSHItem, + moveSSHItem, + connectSSH, + getSSHStatus, + keepSSHAlive, + identifySSHSymlink, + addRecentFile, + addPinnedFile, + removePinnedFile, + removeRecentFile, + addFolderShortcut, + getPinnedFiles, +} from "@/ui/main-axios.ts"; +import type { SidebarItem } from "./FileManagerSidebar"; -export function FileManager({ - initialHost = null, - onClose, -}: { - onSelectView?: (view: string) => void; - embedded?: boolean; +interface FileManagerProps { initialHost?: SSHHost | null; onClose?: () => void; -}): React.ReactElement { - return ; +} + +// Linus-style data structure: creation intent completely separated from actual files +interface CreateIntent { + id: string; + type: 'file' | 'directory'; + defaultName: string; + currentName: string; +} + +// Internal component, uses window manager +function FileManagerContent({ initialHost, onClose }: FileManagerProps) { + const { openWindow } = useWindowManager(); + const { t } = useTranslation(); + + // State + const [currentHost, setCurrentHost] = useState( + initialHost || null, + ); + const [currentPath, setCurrentPath] = useState("/"); + const [files, setFiles] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [sshSessionId, setSshSessionId] = useState(null); + const [isReconnecting, setIsReconnecting] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [lastRefreshTime, setLastRefreshTime] = useState(0); + const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); + const [pinnedFiles, setPinnedFiles] = useState>(new Set()); + const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); + + // Context menu state + const [contextMenu, setContextMenu] = useState<{ + x: number; + y: number; + isVisible: boolean; + files: FileItem[]; + }>({ + x: 0, + y: 0, + isVisible: false, + files: [], + }); + + // Operation state + const [clipboard, setClipboard] = useState<{ + files: FileItem[]; + operation: "copy" | "cut"; + } | null>(null); + + // Undo history + interface UndoAction { + type: "copy" | "cut" | "delete"; + description: string; + data: { + operation: "copy" | "cut"; + copiedFiles?: { + originalPath: string; + targetPath: string; + targetName: string; + }[]; + deletedFiles?: { path: string; name: string }[]; + targetDirectory?: string; + }; + timestamp: number; + } + + const [undoHistory, setUndoHistory] = useState([]); + + // Linus-style state: creation intent separated from file editing + const [createIntent, setCreateIntent] = useState(null); + const [editingFile, setEditingFile] = useState(null); + + // Hooks + const { selectedFiles, selectFile, selectAll, clearSelection, setSelection } = + useFileSelection(); + + const { isDragging, dragHandlers } = useDragAndDrop({ + onFilesDropped: handleFilesDropped, + onError: (error) => toast.error(error), + maxFileSize: 5120, // 5GB - support large files like SSH tools should + }); + + // Drag to desktop functionality + const dragToDesktop = useDragToDesktop({ + sshSessionId: sshSessionId || "", + sshHost: currentHost!, + }); + + // System-level drag to desktop functionality (new approach) + const systemDrag = useDragToSystemDesktop({ + sshSessionId: sshSessionId || "", + sshHost: currentHost!, + }); + + // SSH keepalive function + const startKeepalive = useCallback(() => { + if (!sshSessionId) return; + + // Clear existing timer + if (keepaliveTimerRef.current) { + clearInterval(keepaliveTimerRef.current); + } + + // Send keepalive every 30 seconds to match backend SSH settings + keepaliveTimerRef.current = setInterval(async () => { + if (sshSessionId) { + try { + await keepSSHAlive(sshSessionId); + console.log("SSH keepalive sent successfully"); + } catch (error) { + console.error("SSH keepalive failed:", error); + // If keepalive fails, session might be dead - could trigger reconnect here + } + } + }, 30 * 1000); // 30 seconds - matches backend keepaliveInterval + }, [sshSessionId]); + + const stopKeepalive = useCallback(() => { + if (keepaliveTimerRef.current) { + clearInterval(keepaliveTimerRef.current); + keepaliveTimerRef.current = null; + } + }, []); + + // Initialize SSH connection + useEffect(() => { + if (currentHost) { + initializeSSHConnection(); + } + }, [currentHost]); + + // Start/stop keepalive based on SSH session + useEffect(() => { + if (sshSessionId) { + startKeepalive(); + } else { + stopKeepalive(); + } + + // Cleanup on unmount + return () => { + stopKeepalive(); + }; + }, [sshSessionId, startKeepalive, stopKeepalive]); + + // Track if initial directory load is done to prevent duplicate loading + const initialLoadDoneRef = useRef(false); + // Track last path change to prevent rapid navigation issues + const lastPathChangeRef = useRef(""); + const pathChangeTimerRef = useRef(null); + // Track current loading request to handle cancellation + const currentLoadingPathRef = useRef(""); + // SSH keepalive timer + const keepaliveTimerRef = useRef(null); + + // Handle file drag to external + const handleFileDragStart = useCallback( + (files: FileItem[]) => { + // Record currently dragged files + systemDrag.startDragToSystem(files, { + enableToast: true, + onSuccess: () => { + clearSelection(); + }, + onError: (error) => { + console.error("Drag failed:", error); + }, + }); + }, + [systemDrag, clearSelection], + ); + + const handleFileDragEnd = useCallback( + (e: DragEvent, draggedFiles: FileItem[]) => { + // More conservative detection - only trigger download if clearly outside window + const margin = 10; // Very small margin to reduce false positives + const isOutside = + e.clientX < margin || + e.clientX > window.innerWidth - margin || + e.clientY < margin || + e.clientY > window.innerHeight - margin; + + // Only trigger download if clearly outside the window bounds + if (isOutside) { + console.log("Drag ended outside window bounds, triggering download"); + console.log("Dragged files:", draggedFiles); + console.log("Dragged files length:", draggedFiles.length); + + if (draggedFiles.length === 0) { + console.error("No files to drag - this should not happen"); + return; + } + + // Start system drag with the dragged files + systemDrag.startDragToSystem(draggedFiles, { + enableToast: true, + onSuccess: () => { + clearSelection(); + }, + onError: (error) => { + console.error("Drag failed:", error); + }, + }); + // Execute immediately to preserve user gesture context + systemDrag.handleDragEnd(e); + } else { + console.log("Drag ended inside window bounds, cancelling download"); + // Cancel drag - user probably didn't intend to download + systemDrag.cancelDragToSystem(); + } + }, + [systemDrag, clearSelection], + ); + + async function initializeSSHConnection() { + if (!currentHost) return; + + try { + setIsLoading(true); + // Reset initial load flag for new connections + initialLoadDoneRef.current = false; + + const sessionId = currentHost.id.toString(); + + const result = await connectSSH(sessionId, { + hostId: currentHost.id, + ip: currentHost.ip, + port: currentHost.port, + username: currentHost.username, + password: currentHost.password, + sshKey: currentHost.key, + keyPassword: currentHost.keyPassword, + authType: currentHost.authType, + credentialId: currentHost.credentialId, + userId: currentHost.userId, + }); + + setSshSessionId(sessionId); + + // Load initial directory immediately after connection to prevent jarring transition + try { + console.log("Loading initial directory:", currentPath); + const response = await listSSHFiles(sessionId, currentPath); + const files = Array.isArray(response) ? response : response?.files || []; + console.log("Initial directory loaded successfully:", files.length, "items"); + setFiles(files); + clearSelection(); + // Mark initial load as completed + initialLoadDoneRef.current = true; + } catch (dirError: any) { + console.error("Failed to load initial directory:", dirError); + // Don't show error toast here as it will be handled by the useEffect retry + // Also don't close tab here since the connection succeeded, just directory loading failed + } + } catch (error: any) { + console.error("SSH connection failed:", error); + toast.error( + t("fileManager.failedToConnect") + ": " + (error.message || error), + ); + // Close the tab when SSH connection fails + if (onClose) { + onClose(); + } + } finally { + setIsLoading(false); + } + } + + const loadDirectory = useCallback(async (path: string) => { + if (!sshSessionId) { + console.error("Cannot load directory: no SSH session ID"); + return; + } + + // Prevent concurrent loading requests + if (isLoading && currentLoadingPathRef.current !== path) { + console.log("Directory loading already in progress, skipping:", path); + return; + } + + // Set current loading path for tracking + currentLoadingPathRef.current = path; + setIsLoading(true); + + // Clear createIntent when changing directories + setCreateIntent(null); + + try { + console.log("Loading directory:", path); + + const response = await listSSHFiles(sshSessionId, path); + + // Check if this is still the current request (avoid race conditions) + if (currentLoadingPathRef.current !== path) { + console.log("Directory load canceled, newer request in progress:", path); + return; + } + + console.log("Directory response received:", response); + + const files = Array.isArray(response) ? response : response?.files || []; + + console.log("Directory loaded successfully:", files.length, "items"); + + setFiles(files); + clearSelection(); + } catch (error: any) { + // Only show error if this is still the current request + if (currentLoadingPathRef.current === path) { + console.error("Failed to load directory:", error); + + // Only show toast if this is not the initial load (to prevent duplicate toasts) + if (initialLoadDoneRef.current) { + toast.error( + t("fileManager.failedToLoadDirectory") + ": " + (error.message || error) + ); + } + + // Close the tab when directory loading fails due to SSH issues + if (error.message?.includes("connection") || error.message?.includes("SSH")) { + if (onClose) { + onClose(); + } + } + } + } finally { + // Only clear loading if this is still the current request + if (currentLoadingPathRef.current === path) { + setIsLoading(false); + currentLoadingPathRef.current = ""; + } + } + }, [sshSessionId, isLoading, clearSelection, t]); + + // Debounced directory loading for path changes + const debouncedLoadDirectory = useCallback((path: string) => { + // Clear any existing timer + if (pathChangeTimerRef.current) { + clearTimeout(pathChangeTimerRef.current); + } + + // Set new timer for debounced loading + pathChangeTimerRef.current = setTimeout(() => { + if (path !== lastPathChangeRef.current && sshSessionId) { + console.log("Loading directory after path change:", path); + lastPathChangeRef.current = path; + loadDirectory(path); + } + }, 150); // 150ms debounce for path changes + }, [sshSessionId, loadDirectory]); + + // File list update - only reload when path changes, not on initial connection + useEffect(() => { + if (sshSessionId && currentPath) { + // Skip the first load since it's handled in initializeSSHConnection + if (!initialLoadDoneRef.current) { + initialLoadDoneRef.current = true; + lastPathChangeRef.current = currentPath; + return; + } + + // Use debounced loading for path changes to prevent rapid clicking issues + debouncedLoadDirectory(currentPath); + } + + // Cleanup timer on unmount or dependency change + return () => { + if (pathChangeTimerRef.current) { + clearTimeout(pathChangeTimerRef.current); + } + }; + }, [sshSessionId, currentPath, debouncedLoadDirectory]); + + // Debounced refresh function - prevent excessive clicking + const handleRefreshDirectory = useCallback(() => { + const now = Date.now(); + const DEBOUNCE_MS = 500; // 500ms debounce + + if (now - lastRefreshTime < DEBOUNCE_MS) { + console.log("Refresh ignored - too frequent"); + return; + } + + setLastRefreshTime(now); + loadDirectory(currentPath); + }, [currentPath, lastRefreshTime, loadDirectory]); + + // Global keyboard shortcuts + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + // Check if input box or editable element has focus, skip if so + const activeElement = document.activeElement; + if ( + activeElement && + (activeElement.tagName === "INPUT" || + activeElement.tagName === "TEXTAREA" || + activeElement.contentEditable === "true") + ) { + return; + } + + // Handle Ctrl+Shift+T for opening terminal + if (event.key === "T" && event.ctrlKey && event.shiftKey) { + event.preventDefault(); + handleOpenTerminal(currentPath); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [currentPath]); + + function handleFilesDropped(fileList: FileList) { + if (!sshSessionId) { + toast.error(t("fileManager.noSSHConnection")); + return; + } + + Array.from(fileList).forEach((file) => { + handleUploadFile(file); + }); + } + + async function handleUploadFile(file: File) { + if (!sshSessionId) return; + + try { + // Ensure SSH connection is valid + await ensureSSHConnection(); + + // Read file content + const fileContent = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onerror = () => reject(reader.error); + + // Check file type to determine reading method + const isTextFile = + file.type.startsWith("text/") || + file.type === "application/json" || + file.type === "application/javascript" || + file.type === "application/xml" || + file.name.match( + /\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i, + ); + + if (isTextFile) { + reader.onload = () => { + if (reader.result) { + resolve(reader.result as string); + } else { + reject(new Error("Failed to read text file content")); + } + }; + reader.readAsText(file); + } else { + reader.onload = () => { + if (reader.result instanceof ArrayBuffer) { + const bytes = new Uint8Array(reader.result); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + const base64 = btoa(binary); + resolve(base64); + } else { + reject(new Error("Failed to read binary file")); + } + }; + reader.readAsArrayBuffer(file); + } + }); + + await uploadSSHFile( + sshSessionId, + currentPath, + file.name, + fileContent, + currentHost?.id, + undefined, // userId - will be handled by backend + ); + toast.success( + t("fileManager.fileUploadedSuccessfully", { name: file.name }), + ); + handleRefreshDirectory(); + } catch (error: any) { + if ( + error.message?.includes("connection") || + error.message?.includes("established") + ) { + toast.error( + `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, + ); + } else { + toast.error(t("fileManager.failedToUploadFile")); + } + console.error("Upload failed:", error); + } + } + + async function handleDownloadFile(file: FileItem) { + if (!sshSessionId) return; + + try { + // Ensure SSH connection is valid + await ensureSSHConnection(); + + const response = await downloadSSHFile(sshSessionId, file.path); + + if (response?.content) { + // Convert to blob and trigger download + const byteCharacters = atob(response.content); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { + type: response.mimeType || "application/octet-stream", + }); + + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = response.fileName || file.name; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success( + t("fileManager.fileDownloadedSuccessfully", { name: file.name }), + ); + } + } catch (error: any) { + if ( + error.message?.includes("connection") || + error.message?.includes("established") + ) { + toast.error( + `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, + ); + } else { + toast.error(t("fileManager.failedToDownloadFile")); + } + console.error("Download failed:", error); + } + } + + async function handleDeleteFiles(files: FileItem[]) { + if (!sshSessionId || files.length === 0) return; + + try { + // Ensure SSH connection is valid + await ensureSSHConnection(); + + for (const file of files) { + await deleteSSHItem( + sshSessionId, + file.path, + file.type === "directory", // isDirectory + currentHost?.id, + currentHost?.userId?.toString(), + ); + } + + // Record deletion history (although cannot truly undo) + const deletedFiles = files.map((file) => ({ + path: file.path, + name: file.name, + })); + + const undoAction: UndoAction = { + type: "delete", + description: t("fileManager.deletedItems", { count: files.length }), + data: { + operation: "cut", // Placeholder + deletedFiles, + targetDirectory: currentPath, + }, + timestamp: Date.now(), + }; + setUndoHistory((prev) => [...prev.slice(-9), undoAction]); + + toast.success( + t("fileManager.itemsDeletedSuccessfully", { count: files.length }), + ); + handleRefreshDirectory(); + clearSelection(); + } catch (error: any) { + if ( + error.message?.includes("connection") || + error.message?.includes("established") + ) { + toast.error( + `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, + ); + } else { + toast.error(t("fileManager.failedToDeleteItems")); + } + console.error("Delete failed:", error); + } + } + + // Linus-style creation: pure intent, no side effects + function handleCreateNewFolder() { + const defaultName = generateUniqueName("NewFolder", "directory"); + const newCreateIntent = { + id: Date.now().toString(), + type: 'directory' as const, + defaultName, + currentName: defaultName + }; + + + setCreateIntent(newCreateIntent); + } + + function handleCreateNewFile() { + const defaultName = generateUniqueName("NewFile.txt", "file"); + const newCreateIntent = { + id: Date.now().toString(), + type: 'file' as const, + defaultName, + currentName: defaultName + }; + setCreateIntent(newCreateIntent); + } + + // Handle symlink resolution + const handleSymlinkClick = async (file: FileItem) => { + if (!currentHost || !sshSessionId) { + toast.error(t("fileManager.noSSHConnection")); + return; + } + + try { + // Ensure SSH connection is valid + let currentSessionId = sshSessionId; + try { + const status = await getSSHStatus(currentSessionId); + if (!status.connected) { + const result = await connectSSH(currentSessionId, { + hostId: currentHost.id, + host: currentHost.ip, + port: currentHost.port, + username: currentHost.username, + authType: currentHost.authType, + password: currentHost.password, + key: currentHost.key, + keyPassword: currentHost.keyPassword, + credentialId: currentHost.credentialId, + }); + + if (!result.success) { + throw new Error(t("fileManager.failedToReconnectSSH")); + } + } + } catch (sessionErr) { + throw sessionErr; + } + + const symlinkInfo = await identifySSHSymlink(currentSessionId, file.path); + + if (symlinkInfo.type === "directory") { + // If symlink points to directory, navigate to it + setCurrentPath(symlinkInfo.target); + } else if (symlinkInfo.type === "file") { + // If symlink points to file, open file + // Calculate window position (slightly offset) + const windowCount = Date.now() % 10; + const offsetX = 120 + windowCount * 30; + const offsetY = 120 + windowCount * 30; + + // Create target file object + const targetFile: FileItem = { + ...file, + path: symlinkInfo.target, + }; + + // Create window component factory function + const createWindowComponent = (windowId: string) => ( + + ); + + openWindow({ + title: file.name, + x: offsetX, + y: offsetY, + width: 800, + height: 600, + isMaximized: false, + isMinimized: false, + component: createWindowComponent, + }); + } + } catch (error: any) { + toast.error( + error?.response?.data?.error || + error?.message || + t("fileManager.failedToResolveSymlink"), + ); + } + }; + + async function handleFileOpen(file: FileItem, editMode: boolean = false) { + if (file.type === "directory") { + setCurrentPath(file.path); + } else if (file.type === "link") { + // Handle symlinks + await handleSymlinkClick(file); + } else { + // Open file in new window + if (!sshSessionId) { + toast.error(t("fileManager.noSSHConnection")); + return; + } + + // Record to recent access for regular files + await recordRecentFile(file); + + // Calculate window position (slightly offset) + const windowCount = Date.now() % 10; // Simple offset calculation + const offsetX = 120 + windowCount * 30; + const offsetY = 120 + windowCount * 30; + + const windowTitle = file.name; // Remove mode identifier, controlled internally by FileViewer + + // Create window component factory function + const createWindowComponent = (windowId: string) => ( + + ); + + openWindow({ + title: windowTitle, + x: offsetX, + y: offsetY, + width: 800, + height: 600, + isMaximized: false, + isMinimized: false, + component: createWindowComponent, + }); + } + } + + // Dedicated file editing function + function handleFileEdit(file: FileItem) { + handleFileOpen(file, true); + } + + // Dedicated file viewing function (read-only) + function handleFileView(file: FileItem) { + handleFileOpen(file, false); + } + + function handleContextMenu(event: React.MouseEvent, file?: FileItem) { + event.preventDefault(); + + // If right-clicked file is already in selection list, use all selected files + // If right-clicked file is not in selection list, use only this file + let files: FileItem[]; + if (file) { + const isFileSelected = selectedFiles.some((f) => f.path === file.path); + files = isFileSelected ? selectedFiles : [file]; + } else { + files = selectedFiles; + } + + setContextMenu({ + x: event.clientX, + y: event.clientY, + isVisible: true, + files, + }); + } + + function handleCopyFiles(files: FileItem[]) { + setClipboard({ files, operation: "copy" }); + toast.success( + t("fileManager.filesCopiedToClipboard", { count: files.length }), + ); + } + + function handleCutFiles(files: FileItem[]) { + setClipboard({ files, operation: "cut" }); + toast.success( + t("fileManager.filesCutToClipboard", { count: files.length }), + ); + } + + async function handlePasteFiles() { + if (!clipboard || !sshSessionId) return; + + try { + await ensureSSHConnection(); + + const { files, operation } = clipboard; + + // Handle copy and cut operations + let successCount = 0; + const copiedItems: string[] = []; + + for (const file of files) { + try { + if (operation === "copy") { + // Copy operation: call copy API + const result = await copySSHItem( + sshSessionId, + file.path, + currentPath, + currentHost?.id, + currentHost?.userId?.toString(), + ); + copiedItems.push(result.uniqueName || file.name); + successCount++; + } else { + // Cut operation: move files to target directory + const targetPath = currentPath.endsWith("/") + ? `${currentPath}${file.name}` + : `${currentPath}/${file.name}`; + + // Only move when target path differs from original path + if (file.path !== targetPath) { + // Use dedicated moveSSHItem API for cross-directory movement + await moveSSHItem( + sshSessionId, + file.path, + targetPath, + currentHost?.id, + currentHost?.userId?.toString(), + ); + successCount++; + } + } + } catch (error: any) { + console.error(`Failed to ${operation} file ${file.name}:`, error); + toast.error( + t("fileManager.operationFailed", { operation: operation === "copy" ? t("fileManager.copy") : t("fileManager.move"), name: file.name, error: error.message }), + ); + } + } + + // Record undo history + if (successCount > 0) { + if (operation === "copy") { + const copiedFiles = files + .slice(0, successCount) + .map((file, index) => ({ + originalPath: file.path, + targetPath: `${currentPath}/${copiedItems[index] || file.name}`, + targetName: copiedItems[index] || file.name, + })); + + const undoAction: UndoAction = { + type: "copy", + description: t("fileManager.copiedItems", { count: successCount }), + data: { + operation: "copy", + copiedFiles, + targetDirectory: currentPath, + }, + timestamp: Date.now(), + }; + setUndoHistory((prev) => [...prev.slice(-9), undoAction]); // Keep max 10 undo records + } else if (operation === "cut") { + // Cut operation: record move info, can be moved back to original position on undo + const movedFiles = files.slice(0, successCount).map((file) => { + const targetPath = currentPath.endsWith("/") + ? `${currentPath}${file.name}` + : `${currentPath}/${file.name}`; + return { + originalPath: file.path, + targetPath: targetPath, + targetName: file.name, + }; + }); + + const undoAction: UndoAction = { + type: "cut", + description: t("fileManager.movedItems", { count: successCount }), + data: { + operation: "cut", + copiedFiles: movedFiles, // Reuse copiedFiles field to store move info + targetDirectory: currentPath, + }, + timestamp: Date.now(), + }; + setUndoHistory((prev) => [...prev.slice(-9), undoAction]); + } + } + + // Show success message + if (successCount > 0) { + const operationText = operation === "copy" ? t("fileManager.copy") : t("fileManager.move"); + if (operation === "copy" && copiedItems.length > 0) { + // Show detailed copy info, including renamed files + const hasRenamed = copiedItems.some( + (name) => !files.some((file) => file.name === name), + ); + + if (hasRenamed) { + toast.success( + t("fileManager.operationCompletedSuccessfully", { operation: operationText, count: successCount }), + ); + } else { + toast.success(t("fileManager.operationCompleted", { operation: operationText, count: successCount })); + } + } else { + toast.success(t("fileManager.operationCompleted", { operation: operationText, count: successCount })); + } + } + + // Refresh file list + handleRefreshDirectory(); + clearSelection(); + + // Clear clipboard (after cut operation, copy operation retains clipboard content) + if (operation === "cut") { + setClipboard(null); + } + } catch (error: any) { + toast.error(`${t("fileManager.pasteFailed")}: ${error.message || t("fileManager.unknownError")}`); + } + } + + async function handleUndo() { + if (undoHistory.length === 0) { + toast.info(t("fileManager.noUndoableActions")); + return; + } + + const lastAction = undoHistory[undoHistory.length - 1]; + + try { + await ensureSSHConnection(); + + // Execute undo logic based on different operation types + switch (lastAction.type) { + case "copy": + // Undo copy operation: delete copied target files + if (lastAction.data.copiedFiles) { + let successCount = 0; + for (const copiedFile of lastAction.data.copiedFiles) { + try { + const isDirectory = + files.find((f) => f.path === copiedFile.targetPath)?.type === + "directory"; + await deleteSSHItem( + sshSessionId!, + copiedFile.targetPath, + isDirectory, + currentHost?.id, + currentHost?.userId?.toString(), + ); + successCount++; + } catch (error: any) { + console.error( + `Failed to delete copied file ${copiedFile.targetName}:`, + error, + ); + toast.error( + t("fileManager.deleteCopiedFileFailed", { name: copiedFile.targetName, error: error.message }), + ); + } + } + + if (successCount > 0) { + // Remove last undo record + setUndoHistory((prev) => prev.slice(0, -1)); + toast.success( + t("fileManager.undoCopySuccess", { count: successCount }), + ); + } else { + toast.error(t("fileManager.undoCopyFailedDelete")); + return; + } + } else { + toast.error(t("fileManager.undoCopyFailedNoInfo")); + return; + } + break; + + case "cut": + // Undo cut operation: move files back to original position + if (lastAction.data.copiedFiles) { + let successCount = 0; + for (const movedFile of lastAction.data.copiedFiles) { + try { + // Move file from current position back to original position + await moveSSHItem( + sshSessionId!, + movedFile.targetPath, // Current position (target path) + movedFile.originalPath, // Move back to original position + currentHost?.id, + currentHost?.userId?.toString(), + ); + successCount++; + } catch (error: any) { + console.error( + `Failed to move back file ${movedFile.targetName}:`, + error, + ); + toast.error( + t("fileManager.moveBackFileFailed", { name: movedFile.targetName, error: error.message }), + ); + } + } + + if (successCount > 0) { + // Remove last undo record + setUndoHistory((prev) => prev.slice(0, -1)); + toast.success( + t("fileManager.undoMoveSuccess", { count: successCount }), + ); + } else { + toast.error(t("fileManager.undoMoveFailedMove")); + return; + } + } else { + toast.error(t("fileManager.undoMoveFailedNoInfo")); + return; + } + break; + + case "delete": + // Delete operation cannot be truly undone (file already deleted from server) + toast.info(t("fileManager.undoDeleteNotSupported")); + // Still remove history record as user already knows this limitation + setUndoHistory((prev) => prev.slice(0, -1)); + return; + + default: + toast.error(t("fileManager.undoTypeNotSupported")); + return; + } + + // Refresh file list + handleRefreshDirectory(); + } catch (error: any) { + toast.error(`${t("fileManager.undoOperationFailed")}: ${error.message || t("fileManager.unknownError")}`); + console.error("Undo failed:", error); + } + } + + function handleRenameFile(file: FileItem) { + setEditingFile(file); + } + + // Ensure SSH connection is valid - simplified version, prevent concurrent reconnection + async function ensureSSHConnection() { + if (!sshSessionId || !currentHost || isReconnecting) return; + + try { + const status = await getSSHStatus(sshSessionId); + + if (!status.connected && !isReconnecting) { + setIsReconnecting(true); + console.log("SSH disconnected, reconnecting..."); + + await connectSSH(sshSessionId, { + hostId: currentHost.id, + ip: currentHost.ip, + port: currentHost.port, + username: currentHost.username, + password: currentHost.password, + sshKey: currentHost.key, + keyPassword: currentHost.keyPassword, + authType: currentHost.authType, + credentialId: currentHost.credentialId, + userId: currentHost.userId, + }); + + console.log("SSH reconnection successful"); + } + } catch (error) { + console.log("SSH reconnection failed:", error); + // Close the tab when SSH reconnection fails + if (onClose) { + onClose(); + } + throw error; + } finally { + setIsReconnecting(false); + } + } + + // Linus-style creation confirmation: pure creation, no mixed logic + async function handleConfirmCreate(name: string) { + if (!createIntent || !sshSessionId) return; + + try { + await ensureSSHConnection(); + + console.log(`Creating ${createIntent.type}:`, name); + + if (createIntent.type === "file") { + await createSSHFile( + sshSessionId, + currentPath, + name, + "", + currentHost?.id, + currentHost?.userId?.toString(), + ); + toast.success(t("fileManager.fileCreatedSuccessfully", { name })); + } else { + await createSSHFolder( + sshSessionId, + currentPath, + name, + currentHost?.id, + currentHost?.userId?.toString(), + ); + toast.success(t("fileManager.folderCreatedSuccessfully", { name })); + } + + setCreateIntent(null); // Clear intent + handleRefreshDirectory(); + } catch (error: any) { + console.error("Create failed:", error); + toast.error(t("fileManager.failedToCreateItem")); + } + } + + // Linus-style cancel: zero side effects + function handleCancelCreate() { + setCreateIntent(null); // Just that simple! + console.log("Create cancelled - no side effects"); + } + + // Pure rename confirmation: only handle real files + async function handleRenameConfirm(file: FileItem, newName: string) { + if (!sshSessionId) return; + + try { + await ensureSSHConnection(); + + console.log("Renaming existing item:", { + from: file.path, + to: newName, + }); + + await renameSSHItem( + sshSessionId, + file.path, + newName, + currentHost?.id, + currentHost?.userId?.toString(), + ); + + toast.success(t("fileManager.itemRenamedSuccessfully", { name: newName })); + setEditingFile(null); + handleRefreshDirectory(); + } catch (error: any) { + console.error("Rename failed:", error); + toast.error(t("fileManager.failedToRenameItem")); + } + } + + // Start editing file name + function handleStartEdit(file: FileItem) { + setEditingFile(file); + } + + // Linus-style cancel edit: pure cancel, no side effects + function handleCancelEdit() { + setEditingFile(null); // Simple and elegant + console.log("Edit cancelled - no side effects"); + } + + // Generate unique name (handle name conflicts) + function generateUniqueName( + baseName: string, + type: "file" | "directory", + ): string { + const existingNames = files.map((f) => f.name.toLowerCase()); + let candidateName = baseName; + let counter = 1; + + // If name already exists, try adding number suffix + while (existingNames.includes(candidateName.toLowerCase())) { + if (type === "file" && baseName.includes(".")) { + // For files, add number between filename and extension + const lastDotIndex = baseName.lastIndexOf("."); + const nameWithoutExt = baseName.substring(0, lastDotIndex); + const extension = baseName.substring(lastDotIndex); + candidateName = `${nameWithoutExt}${counter}${extension}`; + } else { + // For folders or files without extension, add number directly + candidateName = `${baseName}${counter}`; + } + counter++; + } + + console.log(`Generated unique name: ${baseName} -> ${candidateName}`); + return candidateName; + } + + // Drag handling: file/folder drag to folder = move operation + async function handleFileDrop( + draggedFiles: FileItem[], + targetFolder: FileItem, + ) { + if (!sshSessionId || targetFolder.type !== "directory") return; + + try { + await ensureSSHConnection(); + + let successCount = 0; + const movedItems: string[] = []; + + for (const file of draggedFiles) { + try { + const targetPath = targetFolder.path.endsWith("/") + ? `${targetFolder.path}${file.name}` + : `${targetFolder.path}/${file.name}`; + + // Only move when target path differs from original path + if (file.path !== targetPath) { + await moveSSHItem( + sshSessionId, + file.path, + targetPath, + currentHost?.id, + currentHost?.userId?.toString(), + ); + movedItems.push(file.name); + successCount++; + } + } catch (error: any) { + console.error(`Failed to move file ${file.name}:`, error); + toast.error(t("fileManager.moveFileFailed", { name: file.name }) + ": " + error.message); + } + } + + if (successCount > 0) { + // Record undo history + const movedFiles = draggedFiles + .slice(0, successCount) + .map((file, index) => { + const targetPath = targetFolder.path.endsWith("/") + ? `${targetFolder.path}${file.name}` + : `${targetFolder.path}/${file.name}`; + return { + originalPath: file.path, + targetPath: targetPath, + targetName: file.name, + }; + }); + + const undoAction: UndoAction = { + type: "cut", + description: t("fileManager.dragMovedItems", { count: successCount, target: targetFolder.name }), + data: { + operation: "cut", + copiedFiles: movedFiles, + targetDirectory: targetFolder.path, + }, + timestamp: Date.now(), + }; + setUndoHistory((prev) => [...prev.slice(-9), undoAction]); + + toast.success( + t("fileManager.successfullyMovedItems", { count: successCount, target: targetFolder.name }), + ); + handleRefreshDirectory(); + clearSelection(); // Clear selection state + } + } catch (error: any) { + console.error("Drag move operation failed:", error); + toast.error(t("fileManager.moveOperationFailed") + ": " + error.message); + } + } + + // Drag handling: file drag to file = diff comparison operation + function handleFileDiff(file1: FileItem, file2: FileItem) { + if (file1.type !== "file" || file2.type !== "file") { + toast.error(t("fileManager.canOnlyCompareFiles")); + return; + } + + if (!sshSessionId) { + toast.error(t("fileManager.noSSHConnection")); + return; + } + + // Use dedicated DiffWindow for file comparison + console.log("Opening diff comparison:", file1.name, "vs", file2.name); + + // Calculate window position + const offsetX = 100; + const offsetY = 80; + + // Create diff window + const windowId = `diff-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + const createWindowComponent = (windowId: string) => ( + + ); + + openWindow({ + id: windowId, + type: "diff", + title: t("fileManager.fileComparison", { file1: file1.name, file2: file2.name }), + isMaximized: false, + component: createWindowComponent, + zIndex: Date.now(), + }); + + toast.success(t("fileManager.comparingFiles", { file1: file1.name, file2: file2.name })); + } + + // Drag to desktop handler function + async function handleDragToDesktop(files: FileItem[]) { + if (!currentHost || !sshSessionId) { + toast.error(t("fileManager.noSSHConnection")); + return; + } + + try { + // Prefer new system-level drag approach + if (systemDrag.isFileSystemAPISupported) { + await systemDrag.handleDragToSystem(files, { + enableToast: true, + onSuccess: () => { + console.log("System-level drag successful"); + }, + onError: (error) => { + console.error("System-level drag failed:", error); + }, + }); + } else { + // Fallback to Electron approach + if (files.length === 1) { + await dragToDesktop.dragFileToDesktop(files[0]); + } else if (files.length > 1) { + await dragToDesktop.dragFilesToDesktop(files); + } + } + } catch (error: any) { + console.error("Drag to desktop failed:", error); + toast.error(t("fileManager.dragFailed") + ": " + (error.message || t("fileManager.unknownError"))); + } + } + + // Open terminal handler function + function handleOpenTerminal(path: string) { + if (!currentHost) { + toast.error(t("fileManager.noHostSelected")); + return; + } + + // Create terminal window + const windowCount = Date.now() % 10; + const offsetX = 200 + windowCount * 40; + const offsetY = 150 + windowCount * 40; + + const createTerminalComponent = (windowId: string) => ( + + ); + + openWindow({ + title: t("fileManager.terminal", { host: currentHost.name, path }), + x: offsetX, + y: offsetY, + width: 800, + height: 500, + isMaximized: false, + isMinimized: false, + component: createTerminalComponent, + }); + + toast.success( + t("terminal.terminalWithPath", { host: currentHost.name, path }), + ); + } + + // Run executable file handler function + function handleRunExecutable(file: FileItem) { + if (!currentHost) { + toast.error(t("fileManager.noHostSelected")); + return; + } + + if (file.type !== "file" || !file.executable) { + toast.error(t("fileManager.onlyRunExecutableFiles")); + return; + } + + // Get file directory + const fileDir = file.path.substring(0, file.path.lastIndexOf("/")); + const fileName = file.name; + const executeCmd = `./${fileName}`; + + // Create terminal window for execution + const windowCount = Date.now() % 10; + const offsetX = 250 + windowCount * 40; + const offsetY = 200 + windowCount * 40; + + const createExecutionTerminal = (windowId: string) => ( + + ); + + openWindow({ + title: t("fileManager.runningFile", { file: file.name }), + x: offsetX, + y: offsetY, + width: 800, + height: 500, + isMaximized: false, + isMinimized: false, + component: createExecutionTerminal, + }); + + toast.success(t("fileManager.runningFile", { file: file.name })); + } + + // Load pinned files list + async function loadPinnedFiles() { + if (!currentHost?.id) return; + + try { + const pinnedData = await getPinnedFiles(currentHost.id); + const pinnedPaths = new Set(pinnedData.map((item: any) => item.path)); + setPinnedFiles(pinnedPaths); + } catch (error) { + console.error("Failed to load pinned files:", error); + } + } + + // PIN file + async function handlePinFile(file: FileItem) { + if (!currentHost?.id) return; + + try { + await addPinnedFile(currentHost.id, file.path, file.name); + setPinnedFiles((prev) => new Set([...prev, file.path])); + setSidebarRefreshTrigger((prev) => prev + 1); // Trigger sidebar refresh + toast.success(t("fileManager.filePinnedSuccessfully", { name: file.name })); + } catch (error) { + console.error("Failed to pin file:", error); + toast.error(t("fileManager.pinFileFailed")); + } + } + + // UNPIN file + async function handleUnpinFile(file: FileItem) { + if (!currentHost?.id) return; + + try { + await removePinnedFile(currentHost.id, file.path); + setPinnedFiles((prev) => { + const newSet = new Set(prev); + newSet.delete(file.path); + return newSet; + }); + setSidebarRefreshTrigger((prev) => prev + 1); // Trigger sidebar refresh + toast.success(t("fileManager.fileUnpinnedSuccessfully", { name: file.name })); + } catch (error) { + console.error("Failed to unpin file:", error); + toast.error(t("fileManager.unpinFileFailed")); + } + } + + // Add folder shortcut + async function handleAddShortcut(path: string) { + if (!currentHost?.id) return; + + try { + const folderName = path.split("/").pop() || path; + await addFolderShortcut(currentHost.id, path, folderName); + setSidebarRefreshTrigger((prev) => prev + 1); // Trigger sidebar refresh + toast.success(t("fileManager.shortcutAddedSuccessfully", { name: folderName })); + } catch (error) { + console.error("Failed to add shortcut:", error); + toast.error(t("fileManager.addShortcutFailed")); + } + } + + // Check if file is pinned + function isPinnedFile(file: FileItem): boolean { + return pinnedFiles.has(file.path); + } + + // Record recently accessed file + async function recordRecentFile(file: FileItem) { + if (!currentHost?.id || file.type === "directory") return; + + try { + await addRecentFile(currentHost.id, file.path, file.name); + setSidebarRefreshTrigger((prev) => prev + 1); // Trigger sidebar refresh + } catch (error) { + console.error("Failed to record recent file:", error); + } + } + + // Handle sidebar file opening + async function handleSidebarFileOpen(sidebarItem: SidebarItem) { + // Convert SidebarItem to FileItem format + const file: FileItem = { + name: sidebarItem.name, + path: sidebarItem.path, + type: "file", // Both recent and pinned are file types + }; + + // Call regular file opening handler + await handleFileOpen(file); + } + + // Handle file not found - cleanup from recent and pinned lists + async function handleFileNotFound(file: FileItem) { + if (!currentHost) return; + + try { + // Remove from recent files + await removeRecentFile(currentHost.id, file.path); + + // Remove from pinned files + await removePinnedFile(currentHost.id, file.path); + + // Trigger sidebar refresh to update the UI + setSidebarRefreshTrigger(prev => prev + 1); + + console.log(`Cleaned up missing file from recent/pinned lists: ${file.path}`); + } catch (error) { + console.error("Failed to cleanup missing file:", error); + } + } + + + // Clear createIntent when path changes + useEffect(() => { + setCreateIntent(null); + }, [currentPath]); + + // Load pinned files list (when host or connection changes) + useEffect(() => { + if (currentHost?.id) { + loadPinnedFiles(); + } + }, [currentHost?.id]); + + // Linus-style data separation: only filter real files + const filteredFiles = files.filter((file) => + file.name.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + + if (!currentHost) { + return ( +
+
+

+ {t("fileManager.selectHostToStart")} +

+
+
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+
+

{currentHost.name}

+ + {currentHost.ip}:{currentHost.port} + +
+ +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="pl-8 w-48 h-9 bg-dark-bg-button border-dark-border" + /> +
+ + {/* View toggle */} +
+ + +
+ + {/* Action buttons */} + + + + + + + +
+
+
+ + {/* Main content area */} +
+ {/* Left sidebar */} +
+ +
+ + {/* Right file grid */} +
+ {}} // No longer need this callback, use onSelectionChange + onFileOpen={handleFileOpen} + onSelectionChange={setSelection} + currentPath={currentPath} + isLoading={isLoading} + onPathChange={setCurrentPath} + onRefresh={handleRefreshDirectory} + onUpload={handleFilesDropped} + onDownload={(files) => files.forEach(handleDownloadFile)} + onContextMenu={handleContextMenu} + viewMode={viewMode} + onRename={handleRenameConfirm} + editingFile={editingFile} + onStartEdit={handleStartEdit} + onCancelEdit={handleCancelEdit} + onDelete={handleDeleteFiles} + onCopy={handleCopyFiles} + onCut={handleCutFiles} + onPaste={handlePasteFiles} + onUndo={handleUndo} + hasClipboard={!!clipboard} + onFileDrop={handleFileDrop} + onFileDiff={handleFileDiff} + onSystemDragStart={handleFileDragStart} + onSystemDragEnd={handleFileDragEnd} + createIntent={createIntent} + onConfirmCreate={handleConfirmCreate} + onCancelCreate={handleCancelCreate} + /> + + {/* Right-click menu */} + + setContextMenu((prev) => ({ ...prev, isVisible: false })) + } + onDownload={(files) => files.forEach(handleDownloadFile)} + onRename={handleRenameFile} + onCopy={handleCopyFiles} + onCut={handleCutFiles} + onPaste={handlePasteFiles} + onDelete={handleDeleteFiles} + onUpload={() => { + const input = document.createElement("input"); + input.type = "file"; + input.multiple = true; + input.onchange = (e) => { + const files = (e.target as HTMLInputElement).files; + if (files) handleFilesDropped(files); + }; + input.click(); + }} + onNewFolder={handleCreateNewFolder} + onNewFile={handleCreateNewFile} + onRefresh={handleRefreshDirectory} + hasClipboard={!!clipboard} + onDragToDesktop={() => handleDragToDesktop(contextMenu.files)} + onOpenTerminal={(path) => handleOpenTerminal(path)} + onRunExecutable={(file) => handleRunExecutable(file)} + onPinFile={handlePinFile} + onUnpinFile={handleUnpinFile} + onAddShortcut={handleAddShortcut} + isPinned={isPinnedFile} + currentPath={currentPath} + /> +
+
+
+ ); +} + +// Main export component, wrapped with WindowManager +export function FileManager({ + initialHost, + onClose, +}: FileManagerProps) { + return ( + + + + ); } diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx index 89194a97..db8d0e25 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerContextMenu.tsx @@ -257,14 +257,14 @@ export function FileManagerContextMenu({ }); } - // Download function - unified download that uses best available method - if (hasFiles && onDragToDesktop) { + // Download function - use proper download handler + if (hasFiles && onDownload) { menuItems.push({ icon: , label: isMultipleFiles ? t("fileManager.downloadFiles", { count: files.length }) : t("fileManager.downloadFile"), - action: () => onDragToDesktop(), + action: () => onDownload(files), shortcut: "Ctrl+D", }); } diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx deleted file mode 100644 index 4113be10..00000000 --- a/src/ui/Desktop/Apps/File Manager/FileManagerFileEditor.tsx +++ /dev/null @@ -1,348 +0,0 @@ -import React, { useEffect } from "react"; -import CodeMirror from "@uiw/react-codemirror"; -import { loadLanguage } from "@uiw/codemirror-extensions-langs"; -import { hyperLink } from "@uiw/codemirror-extensions-hyper-link"; -import { oneDark } from "@codemirror/theme-one-dark"; -import { EditorView } from "@codemirror/view"; - -interface FileManagerCodeEditorProps { - content: string; - fileName: string; - onContentChange: (value: string) => void; -} - -export function FileManagerFileEditor({ - content, - fileName, - onContentChange, -}: FileManagerCodeEditorProps) { - function getLanguageName(filename: string): string { - if (!filename || typeof filename !== "string") { - return "text"; - } - const lastDotIndex = filename.lastIndexOf("."); - if (lastDotIndex === -1) { - return "text"; - } - const ext = filename.slice(lastDotIndex + 1).toLowerCase(); - - switch (ext) { - case "ng": - return "angular"; - case "apl": - return "apl"; - case "asc": - return "asciiArmor"; - case "ast": - return "asterisk"; - case "bf": - return "brainfuck"; - case "c": - return "c"; - case "ceylon": - return "ceylon"; - case "clj": - return "clojure"; - case "cmake": - return "cmake"; - case "cob": - case "cbl": - return "cobol"; - case "coffee": - return "coffeescript"; - case "lisp": - return "commonLisp"; - case "cpp": - case "cc": - case "cxx": - return "cpp"; - case "cr": - return "crystal"; - case "cs": - return "csharp"; - case "css": - return "css"; - case "cypher": - return "cypher"; - case "d": - return "d"; - case "dart": - return "dart"; - case "diff": - case "patch": - return "diff"; - case "dockerfile": - return "dockerfile"; - case "dtd": - return "dtd"; - case "dylan": - return "dylan"; - case "ebnf": - return "ebnf"; - case "ecl": - return "ecl"; - case "eiffel": - return "eiffel"; - case "elm": - return "elm"; - case "erl": - return "erlang"; - case "factor": - return "factor"; - case "fcl": - return "fcl"; - case "fs": - return "forth"; - case "f90": - case "for": - return "fortran"; - case "s": - return "gas"; - case "feature": - return "gherkin"; - case "go": - return "go"; - case "groovy": - return "groovy"; - case "hs": - return "haskell"; - case "hx": - return "haxe"; - case "html": - case "htm": - return "html"; - case "http": - return "http"; - case "idl": - return "idl"; - case "java": - return "java"; - case "js": - case "mjs": - case "cjs": - return "javascript"; - case "jinja2": - case "j2": - return "jinja2"; - case "json": - return "json"; - case "jsx": - return "jsx"; - case "jl": - return "julia"; - case "kt": - case "kts": - return "kotlin"; - case "less": - return "less"; - case "lezer": - return "lezer"; - case "liquid": - return "liquid"; - case "litcoffee": - return "livescript"; - case "lua": - return "lua"; - case "md": - return "markdown"; - case "nb": - case "mat": - return "mathematica"; - case "mbox": - return "mbox"; - case "mmd": - return "mermaid"; - case "mrc": - return "mirc"; - case "moo": - return "modelica"; - case "mscgen": - return "mscgen"; - case "m": - return "mumps"; - case "sql": - return "mysql"; - case "nc": - return "nesC"; - case "nginx": - return "nginx"; - case "nix": - return "nix"; - case "nsi": - return "nsis"; - case "nt": - return "ntriples"; - case "mm": - return "objectiveCpp"; - case "octave": - return "octave"; - case "oz": - return "oz"; - case "pas": - return "pascal"; - case "pl": - case "pm": - return "perl"; - case "pgsql": - return "pgsql"; - case "php": - return "php"; - case "pig": - return "pig"; - case "ps1": - return "powershell"; - case "properties": - return "properties"; - case "proto": - return "protobuf"; - case "pp": - return "puppet"; - case "py": - return "python"; - case "q": - return "q"; - case "r": - return "r"; - case "rb": - return "ruby"; - case "rs": - return "rust"; - case "sas": - return "sas"; - case "sass": - case "scss": - return "sass"; - case "scala": - return "scala"; - case "scm": - return "scheme"; - case "shader": - return "shader"; - case "sh": - case "bash": - return "shell"; - case "siv": - return "sieve"; - case "st": - return "smalltalk"; - case "sol": - return "solidity"; - case "solr": - return "solr"; - case "rq": - return "sparql"; - case "xlsx": - case "ods": - case "csv": - return "spreadsheet"; - case "nut": - return "squirrel"; - case "tex": - return "stex"; - case "styl": - return "stylus"; - case "svelte": - return "svelte"; - case "swift": - return "swift"; - case "tcl": - return "tcl"; - case "textile": - return "textile"; - case "tiddlywiki": - return "tiddlyWiki"; - case "tiki": - return "tiki"; - case "toml": - return "toml"; - case "troff": - return "troff"; - case "tsx": - return "tsx"; - case "ttcn": - return "ttcn"; - case "ttl": - case "turtle": - return "turtle"; - case "ts": - return "typescript"; - case "vb": - return "vb"; - case "vbs": - return "vbscript"; - case "vm": - return "velocity"; - case "v": - return "verilog"; - case "vhd": - case "vhdl": - return "vhdl"; - case "vue": - return "vue"; - case "wat": - return "wast"; - case "webidl": - return "webIDL"; - case "xq": - case "xquery": - return "xQuery"; - case "xml": - return "xml"; - case "yacas": - return "yacas"; - case "yaml": - case "yml": - return "yaml"; - case "z80": - return "z80"; - default: - return "text"; - } - } - - useEffect(() => { - document.body.style.overflowX = "hidden"; - return () => { - document.body.style.overflowX = ""; - }; - }, []); - - return ( -
-
- onContentChange(value)} - theme={undefined} - height="100%" - basicSetup={{ - lineNumbers: true, - scrollPastEnd: false, - }} - className="min-h-full min-w-full flex-1" - /> -
-
- ); -} diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx index 4c869b0d..9576101e 100644 --- a/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx +++ b/src/ui/Desktop/Apps/File Manager/FileManagerGrid.tsx @@ -1,4 +1,5 @@ import React, { useState, useRef, useCallback, useEffect } from "react"; +import { createPortal } from "react-dom"; import { cn } from "@/lib/utils"; import { Folder, @@ -60,6 +61,7 @@ function formatFileSize(bytes?: number): string { interface DragState { type: "none" | "internal" | "external"; files: FileItem[]; + draggedFiles?: FileItem[]; target?: FileItem; counter: number; mousePosition?: { x: number; y: number }; @@ -91,7 +93,7 @@ interface FileManagerGridProps { onFileDrop?: (draggedFiles: FileItem[], targetFile: FileItem) => void; onFileDiff?: (file1: FileItem, file2: FileItem) => void; onSystemDragStart?: (files: FileItem[]) => void; - onSystemDragEnd?: (e: DragEvent) => void; + onSystemDragEnd?: (e: DragEvent, files: FileItem[]) => void; hasClipboard?: boolean; // Linus-style creation intent props createIntent?: CreateIntent | null; @@ -283,6 +285,7 @@ export function FileManagerGrid({ setDragState({ type: "internal", files: filesToDrag, + draggedFiles: filesToDrag, counter: 0, mousePosition: { x: e.clientX, y: e.clientY }, }); @@ -293,9 +296,6 @@ export function FileManagerGrid({ files: filesToDrag.map((f) => f.path), }; e.dataTransfer.setData("text/plain", JSON.stringify(dragData)); - - // Trigger system-level drag start - onSystemDragStart?.(filesToDrag); e.dataTransfer.effectAllowed = "move"; }; @@ -378,10 +378,11 @@ export function FileManagerGrid({ }; const handleFileDragEnd = (e: React.DragEvent) => { + const draggedFiles = dragState.draggedFiles || []; setDragState({ type: "none", files: [], counter: 0 }); - // Trigger system-level drag end detection - onSystemDragEnd?.(e.nativeEvent); + // Trigger system-level drag end detection with dragged files + onSystemDragEnd?.(e.nativeEvent, draggedFiles); }; const [isSelecting, setIsSelecting] = useState(false); @@ -1356,44 +1357,50 @@ export function FileManagerGrid({
- {/* Drag following tooltip */} + {/* Drag following tooltip - rendered as portal to ensure highest z-index */} {dragState.type === "internal" && - dragState.files.length > 0 && - dragState.mousePosition && ( + (dragState.files.length > 0 || dragState.draggedFiles?.length > 0) && + dragState.mousePosition && + createPortal(
- {dragState.target ? ( - dragState.target.type === "directory" ? ( - <> - - - Move to {dragState.target.name} - - + {(() => { + const files = dragState.files.length > 0 ? dragState.files : dragState.draggedFiles || []; + return dragState.target ? ( + dragState.target.type === "directory" ? ( + <> + + + Move to {dragState.target.name} + + + ) : ( + <> + + + Diff compare with {dragState.target.name} + + + ) ) : ( <> - + - Diff compare with {dragState.target.name} + Drag outside window to download ({files.length} files) - ) - ) : ( - <> - - - Drag outside window to download ({dragState.files.length} files) - - - )} + ); + })()}
-
+ , + document.body )} ); diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx deleted file mode 100644 index cc0feb26..00000000 --- a/src/ui/Desktop/Apps/File Manager/FileManagerHomeView.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import React from "react"; -import { Button } from "@/components/ui/button.tsx"; -import { Trash2, Folder, File, Plus, Pin } from "lucide-react"; -import { - Tabs, - TabsList, - TabsTrigger, - TabsContent, -} from "@/components/ui/tabs.tsx"; -import { Input } from "@/components/ui/input.tsx"; -import { useState } from "react"; -import { useTranslation } from "react-i18next"; -import type { FileItem, ShortcutItem } from "../../../types/index"; - -interface FileManagerHomeViewProps { - recent: FileItem[]; - pinned: FileItem[]; - shortcuts: ShortcutItem[]; - onOpenFile: (file: FileItem) => void; - onRemoveRecent: (file: FileItem) => void; - onPinFile: (file: FileItem) => void; - onUnpinFile: (file: FileItem) => void; - onOpenShortcut: (shortcut: ShortcutItem) => void; - onRemoveShortcut: (shortcut: ShortcutItem) => void; - onAddShortcut: (path: string) => void; -} - -export function FileManagerHomeView({ - recent, - pinned, - shortcuts, - onOpenFile, - onRemoveRecent, - onPinFile, - onUnpinFile, - onOpenShortcut, - onRemoveShortcut, - onAddShortcut, -}: FileManagerHomeViewProps) { - const { t } = useTranslation(); - const [tab, setTab] = useState<"recent" | "pinned" | "shortcuts">("recent"); - const [newShortcut, setNewShortcut] = useState(""); - - const renderFileCard = ( - file: FileItem, - onRemove: () => void, - onPin?: () => void, - isPinned = false, - ) => ( -
-
onOpenFile(file)} - > - {file.type === "directory" ? ( - - ) : ( - - )} -
-
- {file.name} -
-
-
-
- {onPin && ( - - )} - {onRemove && ( - - )} -
-
- ); - - const renderShortcutCard = (shortcut: ShortcutItem) => ( -
-
onOpenShortcut(shortcut)} - > - -
-
- {shortcut.path} -
-
-
-
- -
-
- ); - - return ( -
- setTab(v as "recent" | "pinned" | "shortcuts")} - className="w-full" - > - - - {t("fileManager.recent")} - - - {t("fileManager.pinned")} - - - {t("fileManager.folderShortcuts")} - - - - -
- {recent.length === 0 ? ( -
- - {t("fileManager.noRecentFiles")} - -
- ) : ( - recent.map((file) => - renderFileCard( - file, - () => onRemoveRecent(file), - () => (file.isPinned ? onUnpinFile(file) : onPinFile(file)), - file.isPinned, - ), - ) - )} -
-
- - -
- {pinned.length === 0 ? ( -
- - {t("fileManager.noPinnedFiles")} - -
- ) : ( - pinned.map((file) => - renderFileCard(file, undefined, () => onUnpinFile(file), true), - ) - )} -
-
- - -
- setNewShortcut(e.target.value)} - className="flex-1 bg-dark-bg-button border-2 border-dark-border text-white placeholder:text-muted-foreground" - onKeyDown={(e) => { - if (e.key === "Enter" && newShortcut.trim()) { - onAddShortcut(newShortcut.trim()); - setNewShortcut(""); - } - }} - /> - -
-
- {shortcuts.length === 0 ? ( -
- - {t("fileManager.noShortcuts")} - -
- ) : ( - shortcuts.map((shortcut) => renderShortcutCard(shortcut)) - )} -
-
-
-
- ); -} diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx deleted file mode 100644 index 79354181..00000000 --- a/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebar.tsx +++ /dev/null @@ -1,709 +0,0 @@ -import React, { - useEffect, - useState, - useRef, - forwardRef, - useImperativeHandle, -} from "react"; -import { - Folder, - File, - FileSymlink, - ArrowUp, - Pin, - MoreVertical, - Trash2, - Edit3, -} from "lucide-react"; -import { ScrollArea } from "@/components/ui/scroll-area.tsx"; -import { cn } from "@/lib/utils.ts"; -import { Input } from "@/components/ui/input.tsx"; -import { Button } from "@/components/ui/button.tsx"; -import { toast } from "sonner"; -import { useTranslation } from "react-i18next"; -import { - listSSHFiles, - renameSSHItem, - deleteSSHItem, - getFileManagerPinned, - addFileManagerPinned, - removeFileManagerPinned, - getSSHStatus, - connectSSH, - identifySSHSymlink, -} from "@/ui/main-axios.ts"; -import type { SSHHost } from "../../../types/index.js"; - -const FileManagerLeftSidebar = forwardRef(function FileManagerSidebar( - { - onOpenFile, - tabs, - host, - onOperationComplete, - onPathChange, - onDeleteItem, - }: { - onSelectView?: (view: string) => void; - onOpenFile: (file: any) => void; - tabs: any[]; - host: SSHHost; - onOperationComplete?: () => void; - onError?: (error: string) => void; - onSuccess?: (message: string) => void; - onPathChange?: (path: string) => void; - onDeleteItem?: (item: any) => void; - }, - ref, -) { - const { t } = useTranslation(); - const [currentPath, setCurrentPath] = useState("/"); - const [files, setFiles] = useState([]); - const pathInputRef = useRef(null); - - const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [fileSearch, setFileSearch] = useState(""); - const [debouncedFileSearch, setDebouncedFileSearch] = useState(""); - useEffect(() => { - const handler = setTimeout(() => setDebouncedSearch(search), 200); - return () => clearTimeout(handler); - }, [search]); - useEffect(() => { - const handler = setTimeout(() => setDebouncedSearch(fileSearch), 200); - return () => clearTimeout(handler); - }, [fileSearch]); - - const [sshSessionId, setSshSessionId] = useState(null); - const [filesLoading, setFilesLoading] = useState(false); - const [connectingSSH, setConnectingSSH] = useState(false); - const [connectionCache, setConnectionCache] = useState< - Record< - string, - { - sessionId: string; - timestamp: number; - } - > - >({}); - const [fetchingFiles, setFetchingFiles] = useState(false); - - const [contextMenu, setContextMenu] = useState<{ - visible: boolean; - x: number; - y: number; - item: any; - }>({ - visible: false, - x: 0, - y: 0, - item: null, - }); - - const [renamingItem, setRenamingItem] = useState<{ - item: any; - newName: string; - } | null>(null); - - useEffect(() => { - const nextPath = host?.defaultPath || "/"; - setCurrentPath(nextPath); - onPathChange?.(nextPath); - (async () => { - await connectToSSH(host); - })(); - }, [host?.id]); - - async function connectToSSH(server: SSHHost): Promise { - const sessionId = server.id.toString(); - - const cached = connectionCache[sessionId]; - if (cached && Date.now() - cached.timestamp < 30000) { - setSshSessionId(cached.sessionId); - return cached.sessionId; - } - - if (connectingSSH) { - return null; - } - - setConnectingSSH(true); - - try { - if (!server.password && !server.key) { - toast.error(t("common.noAuthCredentials")); - return null; - } - - const connectionConfig = { - hostId: server.id, - ip: server.ip, - port: server.port, - username: server.username, - password: server.password, - sshKey: server.key, - keyPassword: server.keyPassword, - authType: server.authType, - credentialId: server.credentialId, - userId: server.userId, - }; - - await connectSSH(sessionId, connectionConfig); - - setSshSessionId(sessionId); - - setConnectionCache((prev) => ({ - ...prev, - [sessionId]: { sessionId, timestamp: Date.now() }, - })); - - return sessionId; - } catch (err: any) { - toast.error( - err?.response?.data?.error || t("fileManager.failedToConnectSSH"), - ); - setSshSessionId(null); - return null; - } finally { - setConnectingSSH(false); - } - } - - async function fetchFiles() { - if (fetchingFiles) { - return; - } - - setFetchingFiles(true); - setFiles([]); - setFilesLoading(true); - - try { - let pinnedFiles: any[] = []; - try { - if (host) { - pinnedFiles = await getFileManagerPinned(host.id); - } - } catch (err) {} - - if (host && sshSessionId) { - let res: any[] = []; - - try { - const status = await getSSHStatus(sshSessionId); - if (!status.connected) { - const newSessionId = await connectToSSH(host); - if (newSessionId) { - setSshSessionId(newSessionId); - res = await listSSHFiles(newSessionId, currentPath); - } else { - throw new Error(t("fileManager.failedToReconnectSSH")); - } - } else { - res = await listSSHFiles(sshSessionId, currentPath); - } - } catch (sessionErr) { - const newSessionId = await connectToSSH(host); - if (newSessionId) { - setSshSessionId(newSessionId); - res = await listSSHFiles(newSessionId, currentPath); - } else { - throw sessionErr; - } - } - - const processedFiles = (res || []).map((f: any) => { - const filePath = - currentPath + (currentPath.endsWith("/") ? "" : "/") + f.name; - const isPinned = pinnedFiles.some( - (pinned) => pinned.path === filePath, - ); - return { - ...f, - path: filePath, - isPinned, - isSSH: true, - sshSessionId: sshSessionId, - }; - }); - - setFiles(processedFiles); - } - } catch (err: any) { - setFiles([]); - toast.error( - err?.response?.data?.error || - err?.message || - t("fileManager.failedToListFiles"), - ); - } finally { - setFilesLoading(false); - setFetchingFiles(false); - } - } - - useEffect(() => { - if (host && sshSessionId && !connectingSSH && !fetchingFiles) { - const timeoutId = setTimeout(() => { - fetchFiles(); - }, 100); - return () => clearTimeout(timeoutId); - } - }, [currentPath, host, sshSessionId]); - - useImperativeHandle(ref, () => ({ - openFolder: async (_server: SSHHost, path: string) => { - if (connectingSSH || fetchingFiles) { - return; - } - - if (currentPath === path) { - setTimeout(() => fetchFiles(), 100); - return; - } - - setFetchingFiles(false); - setFilesLoading(false); - setFiles([]); - - setCurrentPath(path); - onPathChange?.(path); - if (!sshSessionId) { - const sessionId = await connectToSSH(host); - if (sessionId) setSshSessionId(sessionId); - } - }, - fetchFiles: () => { - if (host && sshSessionId) { - fetchFiles(); - } - }, - getCurrentPath: () => currentPath, - })); - - useEffect(() => { - if (pathInputRef.current) { - pathInputRef.current.scrollLeft = pathInputRef.current.scrollWidth; - } - }, [currentPath]); - - const filteredFiles = files.filter((file) => { - const q = debouncedFileSearch.trim().toLowerCase(); - if (!q) return true; - return file.name.toLowerCase().includes(q); - }); - - const handleContextMenu = (e: React.MouseEvent, item: any) => { - e.preventDefault(); - - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - const menuWidth = 160; - const menuHeight = 80; - - let x = e.clientX; - let y = e.clientY; - - if (x + menuWidth > viewportWidth) { - x = e.clientX - menuWidth; - } - - if (y + menuHeight > viewportHeight) { - y = e.clientY - menuHeight; - } - - if (x < 0) { - x = 0; - } - - if (y < 0) { - y = 0; - } - - setContextMenu({ - visible: true, - x, - y, - item, - }); - }; - - const closeContextMenu = () => { - setContextMenu({ visible: false, x: 0, y: 0, item: null }); - }; - - const handleRename = async (item: any, newName: string) => { - if (!sshSessionId || !newName.trim() || newName === item.name) { - setRenamingItem(null); - return; - } - - try { - await renameSSHItem(sshSessionId, item.path, newName.trim()); - toast.success( - `${item.type === "directory" ? t("common.folder") : item.type === "link" ? t("common.link") : t("common.file")} ${t("common.renamedSuccessfully")}`, - ); - setRenamingItem(null); - if (onOperationComplete) { - onOperationComplete(); - } else { - fetchFiles(); - } - } catch (error: any) { - toast.error( - error?.response?.data?.error || t("fileManager.failedToRenameItem"), - ); - } - }; - - const startRename = (item: any) => { - setRenamingItem({ item, newName: item.name }); - closeContextMenu(); - }; - - const startDelete = (item: any) => { - onDeleteItem?.(item); - closeContextMenu(); - }; - - useEffect(() => { - const handleClickOutside = () => closeContextMenu(); - document.addEventListener("click", handleClickOutside); - return () => document.removeEventListener("click", handleClickOutside); - }, []); - - const handlePathChange = (newPath: string) => { - setCurrentPath(newPath); - onPathChange?.(newPath); - }; - - // Handle symlink resolution - const handleSymlinkClick = async (item: any) => { - if (!host) return; - - try { - // Extract just the symlink path (before the " -> " if present) - const symlinkPath = item.path.includes(" -> ") - ? item.path.split(" -> ")[0] - : item.path; - - let currentSessionId = sshSessionId; - - // Check SSH connection status and reconnect if needed - if (currentSessionId) { - try { - const status = await getSSHStatus(currentSessionId); - if (!status.connected) { - const newSessionId = await connectToSSH(host); - if (newSessionId) { - setSshSessionId(newSessionId); - currentSessionId = newSessionId; - } else { - throw new Error(t("fileManager.failedToReconnectSSH")); - } - } - } catch (sessionErr) { - const newSessionId = await connectToSSH(host); - if (newSessionId) { - setSshSessionId(newSessionId); - currentSessionId = newSessionId; - } else { - throw sessionErr; - } - } - } else { - // No session ID, try to connect - const newSessionId = await connectToSSH(host); - if (newSessionId) { - setSshSessionId(newSessionId); - currentSessionId = newSessionId; - } else { - throw new Error(t("fileManager.failedToConnectSSH")); - } - } - - const symlinkInfo = await identifySSHSymlink( - currentSessionId, - symlinkPath, - ); - - if (symlinkInfo.type === "directory") { - // If symlink points to a directory, navigate to it - handlePathChange(symlinkInfo.target); - } else if (symlinkInfo.type === "file") { - // If symlink points to a file, open it as a file - onOpenFile({ - name: item.name, - path: symlinkInfo.target, // Use the target path, not the symlink path - isSSH: item.isSSH, - sshSessionId: currentSessionId, - }); - } - } catch (error: any) { - toast.error( - error?.response?.data?.error || - error?.message || - t("fileManager.failedToResolveSymlink"), - ); - } - }; - - return ( -
-
-
- {host && ( -
-
- - handlePathChange(e.target.value)} - className="flex-1 bg-dark-bg border-2 border-dark-border-hover text-white truncate rounded-md px-2 py-1 focus:outline-none focus:ring-2 focus:ring-ring hover:border-dark-border-light" - /> -
-
- setFileSearch(e.target.value)} - /> -
-
- -
- {connectingSSH || filesLoading ? ( -
- {t("common.loading")} -
- ) : filteredFiles.length === 0 ? ( -
- {t("fileManager.noFilesOrFoldersFound")} -
- ) : ( -
- {filteredFiles.map((item: any) => { - const isOpen = (tabs || []).some( - (t: any) => t.id === item.path, - ); - const isRenaming = - renamingItem?.item?.path === item.path; - const isDeleting = false; - - return ( -
- !isOpen && handleContextMenu(e, item) - } - > - {isRenaming ? ( -
- {item.type === "directory" ? ( - - ) : item.type === "link" ? ( - - ) : ( - - )} - - setRenamingItem((prev) => - prev - ? { - ...prev, - newName: e.target.value, - } - : null, - ) - } - className="flex-1 h-6 text-sm bg-dark-bg-button border border-dark-border-hover text-white" - autoFocus - onKeyDown={(e) => { - if (e.key === "Enter") { - handleRename( - item, - renamingItem.newName, - ); - } else if (e.key === "Escape") { - setRenamingItem(null); - } - }} - onBlur={() => - handleRename(item, renamingItem.newName) - } - /> -
- ) : ( - <> -
- !isOpen && - (item.type === "directory" - ? handlePathChange(item.path) - : item.type === "link" - ? handleSymlinkClick(item) - : onOpenFile({ - name: item.name, - path: item.path, - isSSH: item.isSSH, - sshSessionId: item.sshSessionId, - })) - } - > - {item.type === "directory" ? ( - - ) : item.type === "link" ? ( - - ) : ( - - )} - - {item.name} - -
-
- {item.type === "file" && ( - - )} - {!isOpen && ( - - )} -
- - )} -
- ); - })} -
- )} -
-
-
-
- )} -
-
- - {contextMenu.visible && contextMenu.item && ( -
- - -
- )} -
- ); -}); - -export { FileManagerLeftSidebar }; diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx deleted file mode 100644 index 36a23a79..00000000 --- a/src/ui/Desktop/Apps/File Manager/FileManagerLeftSidebarFileViewer.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import React from "react"; -import { Button } from "@/components/ui/button.tsx"; -import { Card } from "@/components/ui/card.tsx"; -import { Folder, File, Trash2, Pin, Download } from "lucide-react"; -import { useTranslation } from "react-i18next"; - -interface SSHConnection { - id: string; - name: string; - ip: string; - port: number; - username: string; - isPinned?: boolean; -} - -interface FileItem { - name: string; - type: "file" | "directory" | "link"; - path: string; - isStarred?: boolean; -} - -interface FileManagerLeftSidebarVileViewerProps { - sshConnections: SSHConnection[]; - onAddSSH: () => void; - onConnectSSH: (conn: SSHConnection) => void; - onEditSSH: (conn: SSHConnection) => void; - onDeleteSSH: (conn: SSHConnection) => void; - onPinSSH: (conn: SSHConnection) => void; - currentPath: string; - files: FileItem[]; - onOpenFile: (file: FileItem) => void; - onOpenFolder: (folder: FileItem) => void; - onStarFile: (file: FileItem) => void; - onDownloadFile?: (file: FileItem) => void; - onDeleteFile: (file: FileItem) => void; - isLoading?: boolean; - error?: string; - isSSHMode: boolean; - onSwitchToLocal: () => void; - onSwitchToSSH: (conn: SSHConnection) => void; - currentSSH?: SSHConnection; -} - -export function FileManagerLeftSidebarFileViewer({ - currentPath, - files, - onOpenFile, - onOpenFolder, - onStarFile, - onDownloadFile, - onDeleteFile, - isLoading, - error, - isSSHMode, -}: FileManagerLeftSidebarVileViewerProps) { - const { t } = useTranslation(); - - return ( -
-
-
- - {isSSHMode ? t("common.sshPath") : t("common.localPath")} - - {currentPath} -
- {isLoading ? ( -
- {t("common.loading")} -
- ) : error ? ( -
{error}
- ) : ( -
- {files.map((item) => ( - -
- item.type === "directory" - ? onOpenFolder(item) - : onOpenFile(item) - } - > - {item.type === "directory" ? ( - - ) : ( - - )} - - {item.name} - -
-
- - {item.type === "file" && onDownloadFile && ( - - )} - -
-
- ))} - {files.length === 0 && ( -
- No files or folders found. -
- )} -
- )} -
-
- ); -} diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx deleted file mode 100644 index d6367ef4..00000000 --- a/src/ui/Desktop/Apps/File Manager/FileManagerModern.tsx +++ /dev/null @@ -1,1809 +0,0 @@ -import React, { useState, useEffect, useRef, useCallback } from "react"; -import { FileManagerGrid } from "./FileManagerGrid"; -import { FileManagerSidebar } from "./FileManagerSidebar"; -import { FileManagerContextMenu } from "./FileManagerContextMenu"; -import { useFileSelection } from "./hooks/useFileSelection"; -import { useDragAndDrop } from "./hooks/useDragAndDrop"; -import { WindowManager, useWindowManager } from "./components/WindowManager"; -import { FileWindow } from "./components/FileWindow"; -import { DiffWindow } from "./components/DiffWindow"; -import { useDragToDesktop } from "../../../hooks/useDragToDesktop"; -import { useDragToSystemDesktop } from "../../../hooks/useDragToSystemDesktop"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { toast } from "sonner"; -import { useTranslation } from "react-i18next"; -import { - Upload, - FolderPlus, - FilePlus, - RefreshCw, - Search, - Grid3X3, - List, - Eye, - Settings, -} from "lucide-react"; -import { TerminalWindow } from "./components/TerminalWindow"; -import type { SSHHost, FileItem } from "../../../types/index.js"; -import { - listSSHFiles, - uploadSSHFile, - downloadSSHFile, - createSSHFile, - createSSHFolder, - deleteSSHItem, - copySSHItem, - renameSSHItem, - moveSSHItem, - connectSSH, - getSSHStatus, - keepSSHAlive, - identifySSHSymlink, - addRecentFile, - addPinnedFile, - removePinnedFile, - removeRecentFile, - addFolderShortcut, - getPinnedFiles, -} from "@/ui/main-axios.ts"; -import type { SidebarItem } from "./FileManagerSidebar"; - -interface FileManagerModernProps { - initialHost?: SSHHost | null; - onClose?: () => void; -} - -// Linus-style data structure: creation intent completely separated from actual files -interface CreateIntent { - id: string; - type: 'file' | 'directory'; - defaultName: string; - currentName: string; -} - -// Internal component, uses window manager -function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) { - const { openWindow } = useWindowManager(); - const { t } = useTranslation(); - - // State - const [currentHost, setCurrentHost] = useState( - initialHost || null, - ); - const [currentPath, setCurrentPath] = useState("/"); - const [files, setFiles] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [sshSessionId, setSshSessionId] = useState(null); - const [isReconnecting, setIsReconnecting] = useState(false); - const [searchQuery, setSearchQuery] = useState(""); - const [lastRefreshTime, setLastRefreshTime] = useState(0); - const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); - const [pinnedFiles, setPinnedFiles] = useState>(new Set()); - const [sidebarRefreshTrigger, setSidebarRefreshTrigger] = useState(0); - - // Context menu state - const [contextMenu, setContextMenu] = useState<{ - x: number; - y: number; - isVisible: boolean; - files: FileItem[]; - }>({ - x: 0, - y: 0, - isVisible: false, - files: [], - }); - - // Operation state - const [clipboard, setClipboard] = useState<{ - files: FileItem[]; - operation: "copy" | "cut"; - } | null>(null); - - // Undo history - interface UndoAction { - type: "copy" | "cut" | "delete"; - description: string; - data: { - operation: "copy" | "cut"; - copiedFiles?: { - originalPath: string; - targetPath: string; - targetName: string; - }[]; - deletedFiles?: { path: string; name: string }[]; - targetDirectory?: string; - }; - timestamp: number; - } - - const [undoHistory, setUndoHistory] = useState([]); - - // Linus-style state: creation intent separated from file editing - const [createIntent, setCreateIntent] = useState(null); - const [editingFile, setEditingFile] = useState(null); - - // Hooks - const { selectedFiles, selectFile, selectAll, clearSelection, setSelection } = - useFileSelection(); - - const { isDragging, dragHandlers } = useDragAndDrop({ - onFilesDropped: handleFilesDropped, - onError: (error) => toast.error(error), - maxFileSize: 5120, // 5GB - support large files like SSH tools should - }); - - // Drag to desktop functionality - const dragToDesktop = useDragToDesktop({ - sshSessionId: sshSessionId || "", - sshHost: currentHost!, - }); - - // System-level drag to desktop functionality (new approach) - const systemDrag = useDragToSystemDesktop({ - sshSessionId: sshSessionId || "", - sshHost: currentHost!, - }); - - // SSH keepalive function - const startKeepalive = useCallback(() => { - if (!sshSessionId) return; - - // Clear existing timer - if (keepaliveTimerRef.current) { - clearInterval(keepaliveTimerRef.current); - } - - // Send keepalive every 5 minutes (300000ms) - keepaliveTimerRef.current = setInterval(async () => { - if (sshSessionId) { - try { - await keepSSHAlive(sshSessionId); - console.log("SSH keepalive sent successfully"); - } catch (error) { - console.error("SSH keepalive failed:", error); - // If keepalive fails, session might be dead - could trigger reconnect here - } - } - }, 5 * 60 * 1000); // 5 minutes - }, [sshSessionId]); - - const stopKeepalive = useCallback(() => { - if (keepaliveTimerRef.current) { - clearInterval(keepaliveTimerRef.current); - keepaliveTimerRef.current = null; - } - }, []); - - // Initialize SSH connection - useEffect(() => { - if (currentHost) { - initializeSSHConnection(); - } - }, [currentHost]); - - // Start/stop keepalive based on SSH session - useEffect(() => { - if (sshSessionId) { - startKeepalive(); - } else { - stopKeepalive(); - } - - // Cleanup on unmount - return () => { - stopKeepalive(); - }; - }, [sshSessionId, startKeepalive, stopKeepalive]); - - // Track if initial directory load is done to prevent duplicate loading - const initialLoadDoneRef = useRef(false); - // Track last path change to prevent rapid navigation issues - const lastPathChangeRef = useRef(""); - const pathChangeTimerRef = useRef(null); - // Track current loading request to handle cancellation - const currentLoadingPathRef = useRef(""); - // SSH keepalive timer - const keepaliveTimerRef = useRef(null); - - // Handle file drag to external - const handleFileDragStart = useCallback( - (files: FileItem[]) => { - // Record currently dragged files - systemDrag.startDragToSystem(files, { - enableToast: true, - onSuccess: () => { - clearSelection(); - }, - onError: (error) => { - console.error("Drag failed:", error); - }, - }); - }, - [systemDrag, clearSelection], - ); - - const handleFileDragEnd = useCallback( - (e: DragEvent) => { - // Check if dragged outside window - const margin = 50; - const isOutside = - e.clientX < margin || - e.clientX > window.innerWidth - margin || - e.clientY < margin || - e.clientY > window.innerHeight - margin; - - if (isOutside) { - // Execute immediately to preserve user gesture context - systemDrag.handleDragEnd(e); - } else { - // Cancel drag - systemDrag.cancelDragToSystem(); - } - }, - [systemDrag], - ); - - async function initializeSSHConnection() { - if (!currentHost) return; - - try { - setIsLoading(true); - // Reset initial load flag for new connections - initialLoadDoneRef.current = false; - - const sessionId = currentHost.id.toString(); - - const result = await connectSSH(sessionId, { - hostId: currentHost.id, - ip: currentHost.ip, - port: currentHost.port, - username: currentHost.username, - password: currentHost.password, - sshKey: currentHost.key, - keyPassword: currentHost.keyPassword, - authType: currentHost.authType, - credentialId: currentHost.credentialId, - userId: currentHost.userId, - }); - - setSshSessionId(sessionId); - - // Load initial directory immediately after connection to prevent jarring transition - try { - console.log("Loading initial directory:", currentPath); - const response = await listSSHFiles(sessionId, currentPath); - const files = Array.isArray(response) ? response : response?.files || []; - console.log("Initial directory loaded successfully:", files.length, "items"); - setFiles(files); - clearSelection(); - // Mark initial load as completed - initialLoadDoneRef.current = true; - } catch (dirError: any) { - console.error("Failed to load initial directory:", dirError); - // Don't show error toast here as it will be handled by the useEffect retry - } - } catch (error: any) { - console.error("SSH connection failed:", error); - toast.error( - t("fileManager.failedToConnect") + ": " + (error.message || error), - ); - } finally { - setIsLoading(false); - } - } - - const loadDirectory = useCallback(async (path: string) => { - if (!sshSessionId) { - console.error("Cannot load directory: no SSH session ID"); - return; - } - - // Prevent concurrent loading requests - if (isLoading && currentLoadingPathRef.current !== path) { - console.log("Directory loading already in progress, skipping:", path); - return; - } - - // Set current loading path for tracking - currentLoadingPathRef.current = path; - setIsLoading(true); - - // Clear createIntent when changing directories - setCreateIntent(null); - - try { - console.log("Loading directory:", path); - - const response = await listSSHFiles(sshSessionId, path); - - // Check if this is still the current request (avoid race conditions) - if (currentLoadingPathRef.current !== path) { - console.log("Directory load canceled, newer request in progress:", path); - return; - } - - console.log("Directory response received:", response); - - const files = Array.isArray(response) ? response : response?.files || []; - - console.log("Directory loaded successfully:", files.length, "items"); - - setFiles(files); - clearSelection(); - } catch (error: any) { - // Only show error if this is still the current request - if (currentLoadingPathRef.current === path) { - console.error("Failed to load directory:", error); - toast.error( - t("fileManager.failedToLoadDirectory") + ": " + (error.message || error) - ); - } - } finally { - // Only clear loading if this is still the current request - if (currentLoadingPathRef.current === path) { - setIsLoading(false); - currentLoadingPathRef.current = ""; - } - } - }, [sshSessionId, isLoading, clearSelection, t]); - - // Debounced directory loading for path changes - const debouncedLoadDirectory = useCallback((path: string) => { - // Clear any existing timer - if (pathChangeTimerRef.current) { - clearTimeout(pathChangeTimerRef.current); - } - - // Set new timer for debounced loading - pathChangeTimerRef.current = setTimeout(() => { - if (path !== lastPathChangeRef.current && sshSessionId) { - console.log("Loading directory after path change:", path); - lastPathChangeRef.current = path; - loadDirectory(path); - } - }, 150); // 150ms debounce for path changes - }, [sshSessionId, loadDirectory]); - - // File list update - only reload when path changes, not on initial connection - useEffect(() => { - if (sshSessionId && currentPath) { - // Skip the first load since it's handled in initializeSSHConnection - if (!initialLoadDoneRef.current) { - initialLoadDoneRef.current = true; - lastPathChangeRef.current = currentPath; - return; - } - - // Use debounced loading for path changes to prevent rapid clicking issues - debouncedLoadDirectory(currentPath); - } - - // Cleanup timer on unmount or dependency change - return () => { - if (pathChangeTimerRef.current) { - clearTimeout(pathChangeTimerRef.current); - } - }; - }, [sshSessionId, currentPath, debouncedLoadDirectory]); - - // Debounced refresh function - prevent excessive clicking - const handleRefreshDirectory = useCallback(() => { - const now = Date.now(); - const DEBOUNCE_MS = 500; // 500ms debounce - - if (now - lastRefreshTime < DEBOUNCE_MS) { - console.log("Refresh ignored - too frequent"); - return; - } - - setLastRefreshTime(now); - loadDirectory(currentPath); - }, [currentPath, lastRefreshTime, loadDirectory]); - - // Global keyboard shortcuts - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - // Check if input box or editable element has focus, skip if so - const activeElement = document.activeElement; - if ( - activeElement && - (activeElement.tagName === "INPUT" || - activeElement.tagName === "TEXTAREA" || - activeElement.contentEditable === "true") - ) { - return; - } - - // Handle Ctrl+Shift+T for opening terminal - if (event.key === "T" && event.ctrlKey && event.shiftKey) { - event.preventDefault(); - handleOpenTerminal(currentPath); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [currentPath]); - - function handleFilesDropped(fileList: FileList) { - if (!sshSessionId) { - toast.error(t("fileManager.noSSHConnection")); - return; - } - - Array.from(fileList).forEach((file) => { - handleUploadFile(file); - }); - } - - async function handleUploadFile(file: File) { - if (!sshSessionId) return; - - try { - // Ensure SSH connection is valid - await ensureSSHConnection(); - - // Read file content - const fileContent = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = () => reject(reader.error); - - // Check file type to determine reading method - const isTextFile = - file.type.startsWith("text/") || - file.type === "application/json" || - file.type === "application/javascript" || - file.type === "application/xml" || - file.name.match( - /\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i, - ); - - if (isTextFile) { - reader.onload = () => { - if (reader.result) { - resolve(reader.result as string); - } else { - reject(new Error("Failed to read text file content")); - } - }; - reader.readAsText(file); - } else { - reader.onload = () => { - if (reader.result instanceof ArrayBuffer) { - const bytes = new Uint8Array(reader.result); - let binary = ""; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - const base64 = btoa(binary); - resolve(base64); - } else { - reject(new Error("Failed to read binary file")); - } - }; - reader.readAsArrayBuffer(file); - } - }); - - await uploadSSHFile( - sshSessionId, - currentPath, - file.name, - fileContent, - currentHost?.id, - undefined, // userId - will be handled by backend - ); - toast.success( - t("fileManager.fileUploadedSuccessfully", { name: file.name }), - ); - handleRefreshDirectory(); - } catch (error: any) { - if ( - error.message?.includes("connection") || - error.message?.includes("established") - ) { - toast.error( - `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, - ); - } else { - toast.error(t("fileManager.failedToUploadFile")); - } - console.error("Upload failed:", error); - } - } - - async function handleDownloadFile(file: FileItem) { - if (!sshSessionId) return; - - try { - // Ensure SSH connection is valid - await ensureSSHConnection(); - - const response = await downloadSSHFile(sshSessionId, file.path); - - if (response?.content) { - // Convert to blob and trigger download - const byteCharacters = atob(response.content); - const byteNumbers = new Array(byteCharacters.length); - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); - } - const byteArray = new Uint8Array(byteNumbers); - const blob = new Blob([byteArray], { - type: response.mimeType || "application/octet-stream", - }); - - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = response.fileName || file.name; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - toast.success( - t("fileManager.fileDownloadedSuccessfully", { name: file.name }), - ); - } - } catch (error: any) { - if ( - error.message?.includes("connection") || - error.message?.includes("established") - ) { - toast.error( - `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, - ); - } else { - toast.error(t("fileManager.failedToDownloadFile")); - } - console.error("Download failed:", error); - } - } - - async function handleDeleteFiles(files: FileItem[]) { - if (!sshSessionId || files.length === 0) return; - - try { - // Ensure SSH connection is valid - await ensureSSHConnection(); - - for (const file of files) { - await deleteSSHItem( - sshSessionId, - file.path, - file.type === "directory", // isDirectory - currentHost?.id, - currentHost?.userId?.toString(), - ); - } - - // Record deletion history (although cannot truly undo) - const deletedFiles = files.map((file) => ({ - path: file.path, - name: file.name, - })); - - const undoAction: UndoAction = { - type: "delete", - description: t("fileManager.deletedItems", { count: files.length }), - data: { - operation: "cut", // Placeholder - deletedFiles, - targetDirectory: currentPath, - }, - timestamp: Date.now(), - }; - setUndoHistory((prev) => [...prev.slice(-9), undoAction]); - - toast.success( - t("fileManager.itemsDeletedSuccessfully", { count: files.length }), - ); - handleRefreshDirectory(); - clearSelection(); - } catch (error: any) { - if ( - error.message?.includes("connection") || - error.message?.includes("established") - ) { - toast.error( - `SSH connection failed. Please check your connection to ${currentHost?.name} (${currentHost?.ip}:${currentHost?.port})`, - ); - } else { - toast.error(t("fileManager.failedToDeleteItems")); - } - console.error("Delete failed:", error); - } - } - - // Linus-style creation: pure intent, no side effects - function handleCreateNewFolder() { - const defaultName = generateUniqueName("NewFolder", "directory"); - const newCreateIntent = { - id: Date.now().toString(), - type: 'directory' as const, - defaultName, - currentName: defaultName - }; - - - setCreateIntent(newCreateIntent); - } - - function handleCreateNewFile() { - const defaultName = generateUniqueName("NewFile.txt", "file"); - const newCreateIntent = { - id: Date.now().toString(), - type: 'file' as const, - defaultName, - currentName: defaultName - }; - setCreateIntent(newCreateIntent); - } - - // Handle symlink resolution - const handleSymlinkClick = async (file: FileItem) => { - if (!currentHost || !sshSessionId) { - toast.error(t("fileManager.noSSHConnection")); - return; - } - - try { - // Ensure SSH connection is valid - let currentSessionId = sshSessionId; - try { - const status = await getSSHStatus(currentSessionId); - if (!status.connected) { - const result = await connectSSH(currentSessionId, { - hostId: currentHost.id, - host: currentHost.ip, - port: currentHost.port, - username: currentHost.username, - authType: currentHost.authType, - password: currentHost.password, - key: currentHost.key, - keyPassword: currentHost.keyPassword, - credentialId: currentHost.credentialId, - }); - - if (!result.success) { - throw new Error(t("fileManager.failedToReconnectSSH")); - } - } - } catch (sessionErr) { - throw sessionErr; - } - - const symlinkInfo = await identifySSHSymlink(currentSessionId, file.path); - - if (symlinkInfo.type === "directory") { - // If symlink points to directory, navigate to it - setCurrentPath(symlinkInfo.target); - } else if (symlinkInfo.type === "file") { - // If symlink points to file, open file - // Calculate window position (slightly offset) - const windowCount = Date.now() % 10; - const offsetX = 120 + windowCount * 30; - const offsetY = 120 + windowCount * 30; - - // Create target file object - const targetFile: FileItem = { - ...file, - path: symlinkInfo.target, - }; - - // Create window component factory function - const createWindowComponent = (windowId: string) => ( - - ); - - openWindow({ - title: file.name, - x: offsetX, - y: offsetY, - width: 800, - height: 600, - isMaximized: false, - isMinimized: false, - component: createWindowComponent, - }); - } - } catch (error: any) { - toast.error( - error?.response?.data?.error || - error?.message || - t("fileManager.failedToResolveSymlink"), - ); - } - }; - - async function handleFileOpen(file: FileItem, editMode: boolean = false) { - if (file.type === "directory") { - setCurrentPath(file.path); - } else if (file.type === "link") { - // Handle symlinks - await handleSymlinkClick(file); - } else { - // Open file in new window - if (!sshSessionId) { - toast.error(t("fileManager.noSSHConnection")); - return; - } - - // Record to recent access for regular files - await recordRecentFile(file); - - // Calculate window position (slightly offset) - const windowCount = Date.now() % 10; // Simple offset calculation - const offsetX = 120 + windowCount * 30; - const offsetY = 120 + windowCount * 30; - - const windowTitle = file.name; // Remove mode identifier, controlled internally by FileViewer - - // Create window component factory function - const createWindowComponent = (windowId: string) => ( - - ); - - openWindow({ - title: windowTitle, - x: offsetX, - y: offsetY, - width: 800, - height: 600, - isMaximized: false, - isMinimized: false, - component: createWindowComponent, - }); - } - } - - // Dedicated file editing function - function handleFileEdit(file: FileItem) { - handleFileOpen(file, true); - } - - // Dedicated file viewing function (read-only) - function handleFileView(file: FileItem) { - handleFileOpen(file, false); - } - - function handleContextMenu(event: React.MouseEvent, file?: FileItem) { - event.preventDefault(); - - // If right-clicked file is already in selection list, use all selected files - // If right-clicked file is not in selection list, use only this file - let files: FileItem[]; - if (file) { - const isFileSelected = selectedFiles.some((f) => f.path === file.path); - files = isFileSelected ? selectedFiles : [file]; - } else { - files = selectedFiles; - } - - setContextMenu({ - x: event.clientX, - y: event.clientY, - isVisible: true, - files, - }); - } - - function handleCopyFiles(files: FileItem[]) { - setClipboard({ files, operation: "copy" }); - toast.success( - t("fileManager.filesCopiedToClipboard", { count: files.length }), - ); - } - - function handleCutFiles(files: FileItem[]) { - setClipboard({ files, operation: "cut" }); - toast.success( - t("fileManager.filesCutToClipboard", { count: files.length }), - ); - } - - async function handlePasteFiles() { - if (!clipboard || !sshSessionId) return; - - try { - await ensureSSHConnection(); - - const { files, operation } = clipboard; - - // Handle copy and cut operations - let successCount = 0; - const copiedItems: string[] = []; - - for (const file of files) { - try { - if (operation === "copy") { - // Copy operation: call copy API - const result = await copySSHItem( - sshSessionId, - file.path, - currentPath, - currentHost?.id, - currentHost?.userId?.toString(), - ); - copiedItems.push(result.uniqueName || file.name); - successCount++; - } else { - // Cut operation: move files to target directory - const targetPath = currentPath.endsWith("/") - ? `${currentPath}${file.name}` - : `${currentPath}/${file.name}`; - - // Only move when target path differs from original path - if (file.path !== targetPath) { - // Use dedicated moveSSHItem API for cross-directory movement - await moveSSHItem( - sshSessionId, - file.path, - targetPath, - currentHost?.id, - currentHost?.userId?.toString(), - ); - successCount++; - } - } - } catch (error: any) { - console.error(`Failed to ${operation} file ${file.name}:`, error); - toast.error( - t("fileManager.operationFailed", { operation: operation === "copy" ? t("fileManager.copy") : t("fileManager.move"), name: file.name, error: error.message }), - ); - } - } - - // Record undo history - if (successCount > 0) { - if (operation === "copy") { - const copiedFiles = files - .slice(0, successCount) - .map((file, index) => ({ - originalPath: file.path, - targetPath: `${currentPath}/${copiedItems[index] || file.name}`, - targetName: copiedItems[index] || file.name, - })); - - const undoAction: UndoAction = { - type: "copy", - description: t("fileManager.copiedItems", { count: successCount }), - data: { - operation: "copy", - copiedFiles, - targetDirectory: currentPath, - }, - timestamp: Date.now(), - }; - setUndoHistory((prev) => [...prev.slice(-9), undoAction]); // Keep max 10 undo records - } else if (operation === "cut") { - // Cut operation: record move info, can be moved back to original position on undo - const movedFiles = files.slice(0, successCount).map((file) => { - const targetPath = currentPath.endsWith("/") - ? `${currentPath}${file.name}` - : `${currentPath}/${file.name}`; - return { - originalPath: file.path, - targetPath: targetPath, - targetName: file.name, - }; - }); - - const undoAction: UndoAction = { - type: "cut", - description: t("fileManager.movedItems", { count: successCount }), - data: { - operation: "cut", - copiedFiles: movedFiles, // Reuse copiedFiles field to store move info - targetDirectory: currentPath, - }, - timestamp: Date.now(), - }; - setUndoHistory((prev) => [...prev.slice(-9), undoAction]); - } - } - - // Show success message - if (successCount > 0) { - const operationText = operation === "copy" ? t("fileManager.copy") : t("fileManager.move"); - if (operation === "copy" && copiedItems.length > 0) { - // Show detailed copy info, including renamed files - const hasRenamed = copiedItems.some( - (name) => !files.some((file) => file.name === name), - ); - - if (hasRenamed) { - toast.success( - t("fileManager.operationCompletedSuccessfully", { operation: operationText, count: successCount }), - ); - } else { - toast.success(t("fileManager.operationCompleted", { operation: operationText, count: successCount })); - } - } else { - toast.success(t("fileManager.operationCompleted", { operation: operationText, count: successCount })); - } - } - - // Refresh file list - handleRefreshDirectory(); - clearSelection(); - - // Clear clipboard (after cut operation, copy operation retains clipboard content) - if (operation === "cut") { - setClipboard(null); - } - } catch (error: any) { - toast.error(`${t("fileManager.pasteFailed")}: ${error.message || t("fileManager.unknownError")}`); - } - } - - async function handleUndo() { - if (undoHistory.length === 0) { - toast.info(t("fileManager.noUndoableActions")); - return; - } - - const lastAction = undoHistory[undoHistory.length - 1]; - - try { - await ensureSSHConnection(); - - // Execute undo logic based on different operation types - switch (lastAction.type) { - case "copy": - // Undo copy operation: delete copied target files - if (lastAction.data.copiedFiles) { - let successCount = 0; - for (const copiedFile of lastAction.data.copiedFiles) { - try { - const isDirectory = - files.find((f) => f.path === copiedFile.targetPath)?.type === - "directory"; - await deleteSSHItem( - sshSessionId!, - copiedFile.targetPath, - isDirectory, - currentHost?.id, - currentHost?.userId?.toString(), - ); - successCount++; - } catch (error: any) { - console.error( - `Failed to delete copied file ${copiedFile.targetName}:`, - error, - ); - toast.error( - t("fileManager.deleteCopiedFileFailed", { name: copiedFile.targetName, error: error.message }), - ); - } - } - - if (successCount > 0) { - // Remove last undo record - setUndoHistory((prev) => prev.slice(0, -1)); - toast.success( - t("fileManager.undoCopySuccess", { count: successCount }), - ); - } else { - toast.error(t("fileManager.undoCopyFailedDelete")); - return; - } - } else { - toast.error(t("fileManager.undoCopyFailedNoInfo")); - return; - } - break; - - case "cut": - // Undo cut operation: move files back to original position - if (lastAction.data.copiedFiles) { - let successCount = 0; - for (const movedFile of lastAction.data.copiedFiles) { - try { - // Move file from current position back to original position - await moveSSHItem( - sshSessionId!, - movedFile.targetPath, // Current position (target path) - movedFile.originalPath, // Move back to original position - currentHost?.id, - currentHost?.userId?.toString(), - ); - successCount++; - } catch (error: any) { - console.error( - `Failed to move back file ${movedFile.targetName}:`, - error, - ); - toast.error( - t("fileManager.moveBackFileFailed", { name: movedFile.targetName, error: error.message }), - ); - } - } - - if (successCount > 0) { - // Remove last undo record - setUndoHistory((prev) => prev.slice(0, -1)); - toast.success( - t("fileManager.undoMoveSuccess", { count: successCount }), - ); - } else { - toast.error(t("fileManager.undoMoveFailedMove")); - return; - } - } else { - toast.error(t("fileManager.undoMoveFailedNoInfo")); - return; - } - break; - - case "delete": - // Delete operation cannot be truly undone (file already deleted from server) - toast.info(t("fileManager.undoDeleteNotSupported")); - // Still remove history record as user already knows this limitation - setUndoHistory((prev) => prev.slice(0, -1)); - return; - - default: - toast.error(t("fileManager.undoTypeNotSupported")); - return; - } - - // Refresh file list - handleRefreshDirectory(); - } catch (error: any) { - toast.error(`${t("fileManager.undoOperationFailed")}: ${error.message || t("fileManager.unknownError")}`); - console.error("Undo failed:", error); - } - } - - function handleRenameFile(file: FileItem) { - setEditingFile(file); - } - - // Ensure SSH connection is valid - simplified version, prevent concurrent reconnection - async function ensureSSHConnection() { - if (!sshSessionId || !currentHost || isReconnecting) return; - - try { - const status = await getSSHStatus(sshSessionId); - - if (!status.connected && !isReconnecting) { - setIsReconnecting(true); - console.log("SSH disconnected, reconnecting..."); - - await connectSSH(sshSessionId, { - hostId: currentHost.id, - ip: currentHost.ip, - port: currentHost.port, - username: currentHost.username, - password: currentHost.password, - sshKey: currentHost.key, - keyPassword: currentHost.keyPassword, - authType: currentHost.authType, - credentialId: currentHost.credentialId, - userId: currentHost.userId, - }); - - console.log("SSH reconnection successful"); - } - } catch (error) { - console.log("SSH reconnection failed:", error); - throw error; - } finally { - setIsReconnecting(false); - } - } - - // Linus-style creation confirmation: pure creation, no mixed logic - async function handleConfirmCreate(name: string) { - if (!createIntent || !sshSessionId) return; - - try { - await ensureSSHConnection(); - - console.log(`Creating ${createIntent.type}:`, name); - - if (createIntent.type === "file") { - await createSSHFile( - sshSessionId, - currentPath, - name, - "", - currentHost?.id, - currentHost?.userId?.toString(), - ); - toast.success(t("fileManager.fileCreatedSuccessfully", { name })); - } else { - await createSSHFolder( - sshSessionId, - currentPath, - name, - currentHost?.id, - currentHost?.userId?.toString(), - ); - toast.success(t("fileManager.folderCreatedSuccessfully", { name })); - } - - setCreateIntent(null); // Clear intent - handleRefreshDirectory(); - } catch (error: any) { - console.error("Create failed:", error); - toast.error(t("fileManager.failedToCreateItem")); - } - } - - // Linus-style cancel: zero side effects - function handleCancelCreate() { - setCreateIntent(null); // Just that simple! - console.log("Create cancelled - no side effects"); - } - - // Pure rename confirmation: only handle real files - async function handleRenameConfirm(file: FileItem, newName: string) { - if (!sshSessionId) return; - - try { - await ensureSSHConnection(); - - console.log("Renaming existing item:", { - from: file.path, - to: newName, - }); - - await renameSSHItem( - sshSessionId, - file.path, - newName, - currentHost?.id, - currentHost?.userId?.toString(), - ); - - toast.success(t("fileManager.itemRenamedSuccessfully", { name: newName })); - setEditingFile(null); - handleRefreshDirectory(); - } catch (error: any) { - console.error("Rename failed:", error); - toast.error(t("fileManager.failedToRenameItem")); - } - } - - // Start editing file name - function handleStartEdit(file: FileItem) { - setEditingFile(file); - } - - // Linus-style cancel edit: pure cancel, no side effects - function handleCancelEdit() { - setEditingFile(null); // Simple and elegant - console.log("Edit cancelled - no side effects"); - } - - // Generate unique name (handle name conflicts) - function generateUniqueName( - baseName: string, - type: "file" | "directory", - ): string { - const existingNames = files.map((f) => f.name.toLowerCase()); - let candidateName = baseName; - let counter = 1; - - // If name already exists, try adding number suffix - while (existingNames.includes(candidateName.toLowerCase())) { - if (type === "file" && baseName.includes(".")) { - // For files, add number between filename and extension - const lastDotIndex = baseName.lastIndexOf("."); - const nameWithoutExt = baseName.substring(0, lastDotIndex); - const extension = baseName.substring(lastDotIndex); - candidateName = `${nameWithoutExt}${counter}${extension}`; - } else { - // For folders or files without extension, add number directly - candidateName = `${baseName}${counter}`; - } - counter++; - } - - console.log(`Generated unique name: ${baseName} -> ${candidateName}`); - return candidateName; - } - - // Drag handling: file/folder drag to folder = move operation - async function handleFileDrop( - draggedFiles: FileItem[], - targetFolder: FileItem, - ) { - if (!sshSessionId || targetFolder.type !== "directory") return; - - try { - await ensureSSHConnection(); - - let successCount = 0; - const movedItems: string[] = []; - - for (const file of draggedFiles) { - try { - const targetPath = targetFolder.path.endsWith("/") - ? `${targetFolder.path}${file.name}` - : `${targetFolder.path}/${file.name}`; - - // Only move when target path differs from original path - if (file.path !== targetPath) { - await moveSSHItem( - sshSessionId, - file.path, - targetPath, - currentHost?.id, - currentHost?.userId?.toString(), - ); - movedItems.push(file.name); - successCount++; - } - } catch (error: any) { - console.error(`Failed to move file ${file.name}:`, error); - toast.error(t("fileManager.moveFileFailed", { name: file.name }) + ": " + error.message); - } - } - - if (successCount > 0) { - // Record undo history - const movedFiles = draggedFiles - .slice(0, successCount) - .map((file, index) => { - const targetPath = targetFolder.path.endsWith("/") - ? `${targetFolder.path}${file.name}` - : `${targetFolder.path}/${file.name}`; - return { - originalPath: file.path, - targetPath: targetPath, - targetName: file.name, - }; - }); - - const undoAction: UndoAction = { - type: "cut", - description: t("fileManager.dragMovedItems", { count: successCount, target: targetFolder.name }), - data: { - operation: "cut", - copiedFiles: movedFiles, - targetDirectory: targetFolder.path, - }, - timestamp: Date.now(), - }; - setUndoHistory((prev) => [...prev.slice(-9), undoAction]); - - toast.success( - t("fileManager.successfullyMovedItems", { count: successCount, target: targetFolder.name }), - ); - handleRefreshDirectory(); - clearSelection(); // Clear selection state - } - } catch (error: any) { - console.error("Drag move operation failed:", error); - toast.error(t("fileManager.moveOperationFailed") + ": " + error.message); - } - } - - // Drag handling: file drag to file = diff comparison operation - function handleFileDiff(file1: FileItem, file2: FileItem) { - if (file1.type !== "file" || file2.type !== "file") { - toast.error(t("fileManager.canOnlyCompareFiles")); - return; - } - - if (!sshSessionId) { - toast.error(t("fileManager.noSSHConnection")); - return; - } - - // Use dedicated DiffWindow for file comparison - console.log("Opening diff comparison:", file1.name, "vs", file2.name); - - // Calculate window position - const offsetX = 100; - const offsetY = 80; - - // Create diff window - const windowId = `diff-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - const createWindowComponent = (windowId: string) => ( - - ); - - openWindow({ - id: windowId, - type: "diff", - title: t("fileManager.fileComparison", { file1: file1.name, file2: file2.name }), - isMaximized: false, - component: createWindowComponent, - zIndex: Date.now(), - }); - - toast.success(t("fileManager.comparingFiles", { file1: file1.name, file2: file2.name })); - } - - // Drag to desktop handler function - async function handleDragToDesktop(files: FileItem[]) { - if (!currentHost || !sshSessionId) { - toast.error(t("fileManager.noSSHConnection")); - return; - } - - try { - // Prefer new system-level drag approach - if (systemDrag.isFileSystemAPISupported) { - await systemDrag.handleDragToSystem(files, { - enableToast: true, - onSuccess: () => { - console.log("System-level drag successful"); - }, - onError: (error) => { - console.error("System-level drag failed:", error); - }, - }); - } else { - // Fallback to Electron approach - if (files.length === 1) { - await dragToDesktop.dragFileToDesktop(files[0]); - } else if (files.length > 1) { - await dragToDesktop.dragFilesToDesktop(files); - } - } - } catch (error: any) { - console.error("Drag to desktop failed:", error); - toast.error(t("fileManager.dragFailed") + ": " + (error.message || t("fileManager.unknownError"))); - } - } - - // Open terminal handler function - function handleOpenTerminal(path: string) { - if (!currentHost) { - toast.error(t("fileManager.noHostSelected")); - return; - } - - // Create terminal window - const windowCount = Date.now() % 10; - const offsetX = 200 + windowCount * 40; - const offsetY = 150 + windowCount * 40; - - const createTerminalComponent = (windowId: string) => ( - - ); - - openWindow({ - title: t("fileManager.terminal", { host: currentHost.name, path }), - x: offsetX, - y: offsetY, - width: 800, - height: 500, - isMaximized: false, - isMinimized: false, - component: createTerminalComponent, - }); - - toast.success( - t("terminal.terminalWithPath", { host: currentHost.name, path }), - ); - } - - // Run executable file handler function - function handleRunExecutable(file: FileItem) { - if (!currentHost) { - toast.error(t("fileManager.noHostSelected")); - return; - } - - if (file.type !== "file" || !file.executable) { - toast.error(t("fileManager.onlyRunExecutableFiles")); - return; - } - - // Get file directory - const fileDir = file.path.substring(0, file.path.lastIndexOf("/")); - const fileName = file.name; - const executeCmd = `./${fileName}`; - - // Create terminal window for execution - const windowCount = Date.now() % 10; - const offsetX = 250 + windowCount * 40; - const offsetY = 200 + windowCount * 40; - - const createExecutionTerminal = (windowId: string) => ( - - ); - - openWindow({ - title: t("fileManager.runningFile", { file: file.name }), - x: offsetX, - y: offsetY, - width: 800, - height: 500, - isMaximized: false, - isMinimized: false, - component: createExecutionTerminal, - }); - - toast.success(t("fileManager.runningFile", { file: file.name })); - } - - // Load pinned files list - async function loadPinnedFiles() { - if (!currentHost?.id) return; - - try { - const pinnedData = await getPinnedFiles(currentHost.id); - const pinnedPaths = new Set(pinnedData.map((item: any) => item.path)); - setPinnedFiles(pinnedPaths); - } catch (error) { - console.error("Failed to load pinned files:", error); - } - } - - // PIN file - async function handlePinFile(file: FileItem) { - if (!currentHost?.id) return; - - try { - await addPinnedFile(currentHost.id, file.path, file.name); - setPinnedFiles((prev) => new Set([...prev, file.path])); - setSidebarRefreshTrigger((prev) => prev + 1); // Trigger sidebar refresh - toast.success(t("fileManager.filePinnedSuccessfully", { name: file.name })); - } catch (error) { - console.error("Failed to pin file:", error); - toast.error(t("fileManager.pinFileFailed")); - } - } - - // UNPIN file - async function handleUnpinFile(file: FileItem) { - if (!currentHost?.id) return; - - try { - await removePinnedFile(currentHost.id, file.path); - setPinnedFiles((prev) => { - const newSet = new Set(prev); - newSet.delete(file.path); - return newSet; - }); - setSidebarRefreshTrigger((prev) => prev + 1); // Trigger sidebar refresh - toast.success(t("fileManager.fileUnpinnedSuccessfully", { name: file.name })); - } catch (error) { - console.error("Failed to unpin file:", error); - toast.error(t("fileManager.unpinFileFailed")); - } - } - - // Add folder shortcut - async function handleAddShortcut(path: string) { - if (!currentHost?.id) return; - - try { - const folderName = path.split("/").pop() || path; - await addFolderShortcut(currentHost.id, path, folderName); - setSidebarRefreshTrigger((prev) => prev + 1); // Trigger sidebar refresh - toast.success(t("fileManager.shortcutAddedSuccessfully", { name: folderName })); - } catch (error) { - console.error("Failed to add shortcut:", error); - toast.error(t("fileManager.addShortcutFailed")); - } - } - - // Check if file is pinned - function isPinnedFile(file: FileItem): boolean { - return pinnedFiles.has(file.path); - } - - // Record recently accessed file - async function recordRecentFile(file: FileItem) { - if (!currentHost?.id || file.type === "directory") return; - - try { - await addRecentFile(currentHost.id, file.path, file.name); - setSidebarRefreshTrigger((prev) => prev + 1); // Trigger sidebar refresh - } catch (error) { - console.error("Failed to record recent file:", error); - } - } - - // Handle sidebar file opening - async function handleSidebarFileOpen(sidebarItem: SidebarItem) { - // Convert SidebarItem to FileItem format - const file: FileItem = { - name: sidebarItem.name, - path: sidebarItem.path, - type: "file", // Both recent and pinned are file types - }; - - // Call regular file opening handler - await handleFileOpen(file); - } - - // Handle file not found - cleanup from recent and pinned lists - async function handleFileNotFound(file: FileItem) { - if (!currentHost) return; - - try { - // Remove from recent files - await removeRecentFile(currentHost.id, file.path); - - // Remove from pinned files - await removePinnedFile(currentHost.id, file.path); - - // Trigger sidebar refresh to update the UI - setSidebarRefreshTrigger(prev => prev + 1); - - console.log(`Cleaned up missing file from recent/pinned lists: ${file.path}`); - } catch (error) { - console.error("Failed to cleanup missing file:", error); - } - } - - - // Clear createIntent when path changes - useEffect(() => { - setCreateIntent(null); - }, [currentPath]); - - // Load pinned files list (when host or connection changes) - useEffect(() => { - if (currentHost?.id) { - loadPinnedFiles(); - } - }, [currentHost?.id]); - - // Linus-style data separation: only filter real files - const filteredFiles = files.filter((file) => - file.name.toLowerCase().includes(searchQuery.toLowerCase()), - ); - - - if (!currentHost) { - return ( -
-
-

- {t("fileManager.selectHostToStart")} -

-
-
- ); - } - - return ( -
- {/* Toolbar */} -
-
-
-

{currentHost.name}

- - {currentHost.ip}:{currentHost.port} - -
- -
- {/* Search */} -
- - setSearchQuery(e.target.value)} - className="pl-8 w-48 h-9 bg-dark-bg-button border-dark-border" - /> -
- - {/* View toggle */} -
- - -
- - {/* Action buttons */} - - - - - - - -
-
-
- - {/* Main content area */} -
- {/* Left sidebar */} -
- -
- - {/* Right file grid */} -
- {}} // No longer need this callback, use onSelectionChange - onFileOpen={handleFileOpen} - onSelectionChange={setSelection} - currentPath={currentPath} - isLoading={isLoading} - onPathChange={setCurrentPath} - onRefresh={handleRefreshDirectory} - onUpload={handleFilesDropped} - onDownload={(files) => files.forEach(handleDownloadFile)} - onContextMenu={handleContextMenu} - viewMode={viewMode} - onRename={handleRenameConfirm} - editingFile={editingFile} - onStartEdit={handleStartEdit} - onCancelEdit={handleCancelEdit} - onDelete={handleDeleteFiles} - onCopy={handleCopyFiles} - onCut={handleCutFiles} - onPaste={handlePasteFiles} - onUndo={handleUndo} - hasClipboard={!!clipboard} - onFileDrop={handleFileDrop} - onFileDiff={handleFileDiff} - onSystemDragStart={handleFileDragStart} - onSystemDragEnd={handleFileDragEnd} - createIntent={createIntent} - onConfirmCreate={handleConfirmCreate} - onCancelCreate={handleCancelCreate} - /> - - {/* Right-click menu */} - - setContextMenu((prev) => ({ ...prev, isVisible: false })) - } - onDownload={(files) => files.forEach(handleDownloadFile)} - onRename={handleRenameFile} - onCopy={handleCopyFiles} - onCut={handleCutFiles} - onPaste={handlePasteFiles} - onDelete={handleDeleteFiles} - onUpload={() => { - const input = document.createElement("input"); - input.type = "file"; - input.multiple = true; - input.onchange = (e) => { - const files = (e.target as HTMLInputElement).files; - if (files) handleFilesDropped(files); - }; - input.click(); - }} - onNewFolder={handleCreateNewFolder} - onNewFile={handleCreateNewFile} - onRefresh={handleRefreshDirectory} - hasClipboard={!!clipboard} - onDragToDesktop={() => handleDragToDesktop(contextMenu.files)} - onOpenTerminal={(path) => handleOpenTerminal(path)} - onRunExecutable={(file) => handleRunExecutable(file)} - onPinFile={handlePinFile} - onUnpinFile={handleUnpinFile} - onAddShortcut={handleAddShortcut} - isPinned={isPinnedFile} - currentPath={currentPath} - /> -
-
-
- ); -} - -// Main export component, wrapped with WindowManager -export function FileManagerModern({ - initialHost, - onClose, -}: FileManagerModernProps) { - return ( - - - - ); -} diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx deleted file mode 100644 index 262f3bd1..00000000 --- a/src/ui/Desktop/Apps/File Manager/FileManagerOperations.tsx +++ /dev/null @@ -1,980 +0,0 @@ -import React, { useState, useRef, useEffect } from "react"; -import { Button } from "@/components/ui/button.tsx"; -import { Input } from "@/components/ui/input.tsx"; -import { Card } from "@/components/ui/card.tsx"; -import { Separator } from "@/components/ui/separator.tsx"; -import { - Upload, - Download, - FilePlus, - FolderPlus, - Trash2, - Edit3, - X, - AlertCircle, - FileText, - Folder, -} from "lucide-react"; -import { cn } from "@/lib/utils.ts"; -import { useTranslation } from "react-i18next"; -import type { FileManagerOperationsProps } from "../../../types/index.js"; - -export function FileManagerOperations({ - currentPath, - sshSessionId, - onOperationComplete, - onError, - onSuccess, -}: FileManagerOperationsProps) { - const { t } = useTranslation(); - const [showUpload, setShowUpload] = useState(false); - const [showDownload, setShowDownload] = useState(false); - const [showCreateFile, setShowCreateFile] = useState(false); - const [showCreateFolder, setShowCreateFolder] = useState(false); - const [showDelete, setShowDelete] = useState(false); - const [showRename, setShowRename] = useState(false); - - const [uploadFile, setUploadFile] = useState(null); - const [downloadPath, setDownloadPath] = useState(""); - const [newFileName, setNewFileName] = useState(""); - const [newFolderName, setNewFolderName] = useState(""); - const [deletePath, setDeletePath] = useState(""); - const [deleteIsDirectory, setDeleteIsDirectory] = useState(false); - const [renamePath, setRenamePath] = useState(""); - const [renameIsDirectory, setRenameIsDirectory] = useState(false); - const [newName, setNewName] = useState(""); - - const [isLoading, setIsLoading] = useState(false); - const [showTextLabels, setShowTextLabels] = useState(true); - const fileInputRef = useRef(null); - const containerRef = useRef(null); - - useEffect(() => { - const checkContainerWidth = () => { - if (containerRef.current) { - const width = containerRef.current.offsetWidth; - setShowTextLabels(width > 240); - } - }; - - checkContainerWidth(); - - const resizeObserver = new ResizeObserver(checkContainerWidth); - if (containerRef.current) { - resizeObserver.observe(containerRef.current); - } - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - const handleFileUpload = async () => { - if (!uploadFile || !sshSessionId) return; - - setIsLoading(true); - - const { toast } = await import("sonner"); - const loadingToast = toast.loading( - t("fileManager.uploadingFile", { name: uploadFile.name }), - ); - - try { - // Read file content - support text and binary files - const content = await new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onerror = () => reject(reader.error); - - // Check file type to determine reading method - const isTextFile = - uploadFile.type.startsWith("text/") || - uploadFile.type === "application/json" || - uploadFile.type === "application/javascript" || - uploadFile.type === "application/xml" || - uploadFile.name.match( - /\.(txt|json|js|ts|jsx|tsx|css|html|htm|xml|yaml|yml|md|py|java|c|cpp|h|sh|bat|ps1)$/i, - ); - - if (isTextFile) { - reader.onload = () => { - if (reader.result) { - resolve(reader.result as string); - } else { - reject(new Error("Failed to read text file content")); - } - }; - reader.readAsText(uploadFile); - } else { - reader.onload = () => { - if (reader.result instanceof ArrayBuffer) { - const bytes = new Uint8Array(reader.result); - let binary = ""; - for (let i = 0; i < bytes.byteLength; i++) { - binary += String.fromCharCode(bytes[i]); - } - const base64 = btoa(binary); - resolve(base64); - } else { - reject(new Error("Failed to read binary file")); - } - }; - reader.readAsArrayBuffer(uploadFile); - } - }); - - const { uploadSSHFile } = await import("@/ui/main-axios.ts"); - - const response = await uploadSSHFile( - sshSessionId, - currentPath, - uploadFile.name, - content, - ); - - toast.dismiss(loadingToast); - - if (response?.toast) { - toast[response.toast.type](response.toast.message); - } else { - onSuccess( - t("fileManager.fileUploadedSuccessfully", { name: uploadFile.name }), - ); - } - - setShowUpload(false); - setUploadFile(null); - onOperationComplete(); - } catch (error: any) { - toast.dismiss(loadingToast); - onError( - error?.response?.data?.error || t("fileManager.failedToUploadFile"), - ); - } finally { - setIsLoading(false); - } - }; - - const handleCreateFile = async () => { - if (!newFileName.trim() || !sshSessionId) return; - - setIsLoading(true); - - const { toast } = await import("sonner"); - const loadingToast = toast.loading( - t("fileManager.creatingFile", { name: newFileName.trim() }), - ); - - try { - const { createSSHFile } = await import("@/ui/main-axios.ts"); - - const response = await createSSHFile( - sshSessionId, - currentPath, - newFileName.trim(), - ); - - toast.dismiss(loadingToast); - - if (response?.toast) { - toast[response.toast.type](response.toast.message); - } else { - onSuccess( - t("fileManager.fileCreatedSuccessfully", { - name: newFileName.trim(), - }), - ); - } - - setShowCreateFile(false); - setNewFileName(""); - onOperationComplete(); - } catch (error: any) { - toast.dismiss(loadingToast); - onError( - error?.response?.data?.error || t("fileManager.failedToCreateFile"), - ); - } finally { - setIsLoading(false); - } - }; - - const handleDownload = async () => { - if (!downloadPath.trim() || !sshSessionId) return; - - setIsLoading(true); - - const { toast } = await import("sonner"); - const fileName = downloadPath.split("/").pop() || "download"; - const loadingToast = toast.loading( - t("fileManager.downloadingFile", { name: fileName }), - ); - - try { - const { downloadSSHFile } = await import("@/ui/main-axios.ts"); - - const response = await downloadSSHFile(sshSessionId, downloadPath.trim()); - - toast.dismiss(loadingToast); - - if (response?.content) { - // Convert base64 to blob and trigger download - const byteCharacters = atob(response.content); - const byteNumbers = new Array(byteCharacters.length); - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); - } - const byteArray = new Uint8Array(byteNumbers); - const blob = new Blob([byteArray], { - type: response.mimeType || "application/octet-stream", - }); - - // Create download link - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = response.fileName || fileName; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - URL.revokeObjectURL(url); - - onSuccess( - t("fileManager.fileDownloadedSuccessfully", { - name: response.fileName || fileName, - }), - ); - } else { - onError(t("fileManager.noFileContent")); - } - - setShowDownload(false); - setDownloadPath(""); - } catch (error: any) { - toast.dismiss(loadingToast); - onError( - error?.response?.data?.error || t("fileManager.failedToDownloadFile"), - ); - } finally { - setIsLoading(false); - } - }; - - const handleCreateFolder = async () => { - if (!newFolderName.trim() || !sshSessionId) return; - - setIsLoading(true); - - const { toast } = await import("sonner"); - const loadingToast = toast.loading( - t("fileManager.creatingFolder", { name: newFolderName.trim() }), - ); - - try { - const { createSSHFolder } = await import("@/ui/main-axios.ts"); - - const response = await createSSHFolder( - sshSessionId, - currentPath, - newFolderName.trim(), - ); - - toast.dismiss(loadingToast); - - if (response?.toast) { - toast[response.toast.type](response.toast.message); - } else { - onSuccess( - t("fileManager.folderCreatedSuccessfully", { - name: newFolderName.trim(), - }), - ); - } - - setShowCreateFolder(false); - setNewFolderName(""); - onOperationComplete(); - } catch (error: any) { - toast.dismiss(loadingToast); - onError( - error?.response?.data?.error || t("fileManager.failedToCreateFolder"), - ); - } finally { - setIsLoading(false); - } - }; - - const handleDelete = async () => { - if (!deletePath || !sshSessionId) return; - - setIsLoading(true); - - const { toast } = await import("sonner"); - const loadingToast = toast.loading( - t("fileManager.deletingItem", { - type: deleteIsDirectory - ? t("fileManager.folder") - : t("fileManager.file"), - name: deletePath.split("/").pop(), - }), - ); - - try { - const { deleteSSHItem } = await import("@/ui/main-axios.ts"); - - const response = await deleteSSHItem( - sshSessionId, - deletePath, - deleteIsDirectory, - ); - - toast.dismiss(loadingToast); - - if (response?.toast) { - toast[response.toast.type](response.toast.message); - } else { - onSuccess( - t("fileManager.itemDeletedSuccessfully", { - type: deleteIsDirectory - ? t("fileManager.folder") - : t("fileManager.file"), - }), - ); - } - - setShowDelete(false); - setDeletePath(""); - setDeleteIsDirectory(false); - onOperationComplete(); - } catch (error: any) { - toast.dismiss(loadingToast); - onError( - error?.response?.data?.error || t("fileManager.failedToDeleteItem"), - ); - } finally { - setIsLoading(false); - } - }; - - const handleRename = async () => { - if (!renamePath || !newName.trim() || !sshSessionId) return; - - setIsLoading(true); - - const { toast } = await import("sonner"); - const loadingToast = toast.loading( - t("fileManager.renamingItem", { - type: renameIsDirectory - ? t("fileManager.folder") - : t("fileManager.file"), - oldName: renamePath.split("/").pop(), - newName: newName.trim(), - }), - ); - - try { - const { renameSSHItem } = await import("@/ui/main-axios.ts"); - - const response = await renameSSHItem( - sshSessionId, - renamePath, - newName.trim(), - ); - - toast.dismiss(loadingToast); - - if (response?.toast) { - toast[response.toast.type](response.toast.message); - } else { - onSuccess( - t("fileManager.itemRenamedSuccessfully", { - type: renameIsDirectory - ? t("fileManager.folder") - : t("fileManager.file"), - }), - ); - } - - setShowRename(false); - setRenamePath(""); - setRenameIsDirectory(false); - setNewName(""); - onOperationComplete(); - } catch (error: any) { - toast.dismiss(loadingToast); - onError( - error?.response?.data?.error || t("fileManager.failedToRenameItem"), - ); - } finally { - setIsLoading(false); - } - }; - - const openFileDialog = () => { - fileInputRef.current?.click(); - }; - - const handleFileSelect = (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (file) { - setUploadFile(file); - } - }; - - const resetStates = () => { - setShowUpload(false); - setShowCreateFile(false); - setShowCreateFolder(false); - setShowDelete(false); - setShowRename(false); - setUploadFile(null); - setNewFileName(""); - setNewFolderName(""); - setDeletePath(""); - setDeleteIsDirectory(false); - setRenamePath(""); - setRenameIsDirectory(false); - setNewName(""); - }; - - if (!sshSessionId) { - return ( -
- -

- {t("fileManager.connectToSsh")} -

-
- ); - } - - return ( -
-
- - - - - - -
- -
-
- -
- - {t("fileManager.currentPath")}: - - - {currentPath} - -
-
-
- - - - {showUpload && ( - -
-
-

- - - {t("fileManager.uploadFileTitle")} - -

-

- {t("fileManager.maxFileSize")} -

-
- -
- -
-
- {uploadFile ? ( -
- -

- {uploadFile.name} -

-

- {(uploadFile.size / 1024).toFixed(2)} KB -

- -
- ) : ( -
- -

- {t("fileManager.clickToSelectFile")} -

- -
- )} -
- - - -
- - -
-
-
- )} - - {showDownload && ( - -
-
-

- - - {t("fileManager.downloadFile")} - -

-
- -
- -
-
- - setDownloadPath(e.target.value)} - placeholder={t("placeholders.fullPath")} - className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm" - onKeyDown={(e) => e.key === "Enter" && handleDownload()} - /> -
- -
- - -
-
-
- )} - - {showCreateFile && ( - -
-
-

- - - {t("fileManager.createNewFile")} - -

-
- -
- -
-
- - setNewFileName(e.target.value)} - placeholder={t("placeholders.fileName")} - className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm" - onKeyDown={(e) => e.key === "Enter" && handleCreateFile()} - /> -
- -
- - -
-
-
- )} - - {showCreateFolder && ( - -
-
-

- - - {t("fileManager.createNewFolder")} - -

-
- -
- -
-
- - setNewFolderName(e.target.value)} - placeholder={t("placeholders.folderName")} - className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm" - onKeyDown={(e) => e.key === "Enter" && handleCreateFolder()} - /> -
- -
- - -
-
-
- )} - - {showDelete && ( - -
-
-

- - - {t("fileManager.deleteItem")} - -

-
- -
- -
-
-
- - - {t("fileManager.warningCannotUndo")} - -
-
- -
- - setDeletePath(e.target.value)} - placeholder={t("placeholders.fullPath")} - className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm" - /> -
- -
- setDeleteIsDirectory(e.target.checked)} - className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0" - /> - -
- -
- - -
-
-
- )} - - {showRename && ( - -
-
-

- - - {t("fileManager.renameItem")} - -

-
- -
- -
-
- - setRenamePath(e.target.value)} - placeholder={t("placeholders.currentPath")} - className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm" - /> -
- -
- - setNewName(e.target.value)} - placeholder={t("placeholders.newName")} - className="bg-dark-bg-button border-2 border-dark-border-hover text-white text-sm" - onKeyDown={(e) => e.key === "Enter" && handleRename()} - /> -
- -
- setRenameIsDirectory(e.target.checked)} - className="rounded border-dark-border-hover bg-dark-bg-button mt-0.5 flex-shrink-0" - /> - -
- -
- - -
-
-
- )} -
- ); -} diff --git a/src/ui/Desktop/Apps/File Manager/FileManagerTabList.tsx b/src/ui/Desktop/Apps/File Manager/FileManagerTabList.tsx deleted file mode 100644 index 439988ba..00000000 --- a/src/ui/Desktop/Apps/File Manager/FileManagerTabList.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React from "react"; -import { Button } from "@/components/ui/button.tsx"; -import { X, Home } from "lucide-react"; - -interface FileManagerTab { - id: string | number; - title: string; -} - -interface FileManagerTabList { - tabs: FileManagerTab[]; - activeTab: string | number; - setActiveTab: (tab: string | number) => void; - closeTab: (tab: string | number) => void; - onHomeClick: () => void; -} - -export function FileManagerTabList({ - tabs, - activeTab, - setActiveTab, - closeTab, - onHomeClick, -}: FileManagerTabList) { - return ( -
- - {tabs.map((tab) => { - const isActive = tab.id === activeTab; - return ( -
- - - -
- ); - })} -
- ); -} diff --git a/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx b/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx index 8161aefe..3c632c50 100644 --- a/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx +++ b/src/ui/Desktop/Apps/File Manager/components/FileViewer.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { cn } from "@/lib/utils"; import { useTranslation } from "react-i18next"; import { @@ -15,6 +15,7 @@ import { Save, RotateCcw, Keyboard, + Search, } from "lucide-react"; import { SiJavascript, @@ -49,7 +50,7 @@ import CodeMirror from "@uiw/react-codemirror"; import { oneDark } from "@codemirror/theme-one-dark"; import { loadLanguage } from "@uiw/codemirror-extensions-langs"; import { EditorView, keymap } from "@codemirror/view"; -import { searchKeymap, search } from "@codemirror/search"; +import { searchKeymap, search, openSearchPanel } from "@codemirror/search"; import { defaultKeymap, history, historyKeymap, toggleComment } from "@codemirror/commands"; import { autocompletion, completionKeymap } from "@codemirror/autocomplete"; import { PhotoProvider, PhotoView } from "react-photo-view"; @@ -323,6 +324,7 @@ export function FileViewer({ const [pdfScale, setPdfScale] = useState(1.2); const [pdfError, setPdfError] = useState(false); const [markdownEditMode, setMarkdownEditMode] = useState(false); + const editorRef = useRef(null); const fileTypeInfo = getFileType(file.name); @@ -348,7 +350,8 @@ export function FileViewer({ if (savedContent) { setOriginalContent(savedContent); } - setHasChanges(content !== (savedContent || content)); + // Fix: Compare current content with saved content properly + setHasChanges(content !== savedContent); // If unknown file type and file is large, show warning if (fileTypeInfo.type === "unknown" && isLargeFile && !forceShowAsText) { @@ -361,7 +364,8 @@ export function FileViewer({ // Handle content changes const handleContentChange = (newContent: string) => { setEditedContent(newContent); - setHasChanges(newContent !== originalContent); + // Fix: Compare with savedContent instead of originalContent for consistency + setHasChanges(newContent !== savedContent); onContentChange?.(newContent); }; @@ -373,9 +377,9 @@ export function FileViewer({ // Revert file const handleRevert = () => { - setEditedContent(originalContent); + setEditedContent(savedContent); setHasChanges(false); - onContentChange?.(originalContent); + onContentChange?.(savedContent); }; // Handle save shortcut specifically @@ -453,6 +457,26 @@ export function FileViewer({
+ {/* Search button */} + {isEditable && ( + + )} {/* Keyboard shortcuts help */} {isEditable && (
- - -
-

{t("fileManager.navigation")}

-
- {t("fileManager.goToLine")} - Ctrl+G + {t("fileManager.toggleComment")} + Ctrl+/ +
+
+ {t("fileManager.autoComplete")} + Ctrl+Space
{t("fileManager.moveLineUp")} @@ -576,27 +599,6 @@ export function FileViewer({
-
-

{t("fileManager.code")}

-
-
- {t("fileManager.toggleComment")} - Ctrl+/ -
-
- {t("fileManager.indent")} - Tab -
-
- {t("fileManager.outdent")} - Shift+Tab -
-
- {t("fileManager.autoComplete")} - Ctrl+Space -
-
-
)} @@ -737,6 +739,7 @@ export function FileViewer({ {isEditable ? ( // Unified CodeMirror editor for all text-based files handleContentChange(value)} onFocus={() => setEditorFocused(true)} @@ -906,17 +909,7 @@ export function FileViewer({
- {hasChanges && ( - - )} + {/* Save button removed - using the main header save button instead */}
diff --git a/src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx b/src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx index e038fd0d..f8c0aebe 100644 --- a/src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx +++ b/src/ui/Desktop/Apps/Host Manager/HostManagerViewer.tsx @@ -525,13 +525,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { const sampleData = { hosts: [ { - name: "Web Server - Production", + name: t("interface.webServerProduction"), ip: "192.168.1.100", port: 22, username: "admin", authType: "password", password: "your_secure_password_here", - folder: "Production", + folder: t("interface.productionFolder"), tags: ["web", "production", "nginx"], pin: true, enableTerminal: true, @@ -540,7 +540,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { defaultPath: "/var/www", }, { - name: "Database Server", + name: t("interface.databaseServer"), ip: "192.168.1.101", port: 22, username: "dbadmin", @@ -548,7 +548,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----", keyPassword: "optional_key_passphrase", keyType: "ssh-ed25519", - folder: "Production", + folder: t("interface.productionFolder"), tags: ["database", "production", "postgresql"], pin: false, enableTerminal: true, @@ -558,7 +558,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { { sourcePort: 5432, endpointPort: 5432, - endpointHost: "Web Server - Production", + endpointHost: t("interface.webServerProduction"), maxRetries: 3, retryInterval: 10, autoStart: true, @@ -566,13 +566,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { ], }, { - name: "Development Server", + name: t("interface.developmentServer"), ip: "192.168.1.102", port: 2222, username: "developer", authType: "credential", credentialId: 1, - folder: "Development", + folder: t("interface.developmentFolder"), tags: ["dev", "testing"], pin: false, enableTerminal: true, @@ -686,13 +686,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { const sampleData = { hosts: [ { - name: "Web Server - Production", + name: t("interface.webServerProduction"), ip: "192.168.1.100", port: 22, username: "admin", authType: "password", password: "your_secure_password_here", - folder: "Production", + folder: t("interface.productionFolder"), tags: ["web", "production", "nginx"], pin: true, enableTerminal: true, @@ -701,7 +701,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { defaultPath: "/var/www", }, { - name: "Database Server", + name: t("interface.databaseServer"), ip: "192.168.1.101", port: 22, username: "dbadmin", @@ -709,7 +709,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { key: "-----BEGIN OPENSSH PRIVATE KEY-----\nYour SSH private key content here\n-----END OPENSSH PRIVATE KEY-----", keyPassword: "optional_key_passphrase", keyType: "ssh-ed25519", - folder: "Production", + folder: t("interface.productionFolder"), tags: ["database", "production", "postgresql"], pin: false, enableTerminal: true, @@ -719,7 +719,7 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { { sourcePort: 5432, endpointPort: 5432, - endpointHost: "Web Server - Production", + endpointHost: t("interface.webServerProduction"), maxRetries: 3, retryInterval: 10, autoStart: true, @@ -727,13 +727,13 @@ export function HostManagerViewer({ onEditHost }: SSHManagerHostViewerProps) { ], }, { - name: "Development Server", + name: t("interface.developmentServer"), ip: "192.168.1.102", port: 2222, username: "developer", authType: "credential", credentialId: 1, - folder: "Development", + folder: t("interface.developmentFolder"), tags: ["dev", "testing"], pin: false, enableTerminal: true, diff --git a/src/ui/Desktop/Apps/Server/Server.tsx b/src/ui/Desktop/Apps/Server/Server.tsx index e6fc7265..868d50d3 100644 --- a/src/ui/Desktop/Apps/Server/Server.tsx +++ b/src/ui/Desktop/Apps/Server/Server.tsx @@ -40,6 +40,7 @@ export function Server({ const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig); const [isLoadingMetrics, setIsLoadingMetrics] = React.useState(false); const [isRefreshing, setIsRefreshing] = React.useState(false); + const [showStatsUI, setShowStatsUI] = React.useState(true); React.useEffect(() => { setCurrentHostConfig(hostConfig); @@ -116,10 +117,12 @@ export function Server({ const data = await getServerMetricsById(currentHostConfig.id); if (!cancelled) { setMetrics(data); + setShowStatsUI(true); } } catch (error) { if (!cancelled) { setMetrics(null); + setShowStatsUI(false); toast.error(t("serverStats.failedToFetchMetrics")); } } finally { @@ -208,6 +211,7 @@ export function Server({ currentHostConfig.id, ); setMetrics(data); + setShowStatsUI(true); } catch (error: any) { if (error?.response?.status === 503) { setServerStatus("offline"); @@ -219,6 +223,7 @@ export function Server({ setServerStatus("offline"); } setMetrics(null); + setShowStatsUI(false); } finally { setIsRefreshing(false); } @@ -267,7 +272,8 @@ export function Server({ {/* Stats */} -
+ {showStatsUI && ( +
{isLoadingMetrics && !metrics ? (
@@ -443,7 +449,8 @@ export function Server({
)} -
+
+ )} {/* SSH Tunnels */} {currentHostConfig?.tunnelConnections && diff --git a/src/ui/Desktop/Apps/Terminal/Terminal.tsx b/src/ui/Desktop/Apps/Terminal/Terminal.tsx index 551aece9..6cbadc16 100644 --- a/src/ui/Desktop/Apps/Terminal/Terminal.tsx +++ b/src/ui/Desktop/Apps/Terminal/Terminal.tsx @@ -304,18 +304,18 @@ export const Terminal = forwardRef(function SSHTerminal( } const baseWsUrl = isDev - ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:8082` + ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002` : isElectron() ? (() => { const baseUrl = - (window as any).configuredServerUrl || "http://127.0.0.1:8081"; + (window as any).configuredServerUrl || "http://127.0.0.1:30001"; const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://"; const wsHost = baseUrl.replace(/^https?:\/\//, ""); - return `${wsProtocol}${wsHost.replace(':8081', ':8082')}/`; + return `${wsProtocol}${wsHost.replace(':30001', ':30002')}/`; })() - : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:8082/`; + : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:30002/`; // Clean up existing connection to prevent duplicates - Linus principle: eliminate complexity if (webSocketRef.current && webSocketRef.current.readyState !== WebSocket.CLOSED) { diff --git a/src/ui/Desktop/Electron Only/ServerConfig.tsx b/src/ui/Desktop/Electron Only/ServerConfig.tsx index 4c5b1312..3096ccf1 100644 --- a/src/ui/Desktop/Electron Only/ServerConfig.tsx +++ b/src/ui/Desktop/Electron Only/ServerConfig.tsx @@ -146,7 +146,7 @@ export function ServerConfig({ handleUrlChange(e.target.value)} className="flex-1 h-10" diff --git a/src/ui/Desktop/Homepage/HomepageAlertManager.tsx b/src/ui/Desktop/Homepage/HomepageAlertManager.tsx index bb704c66..cab8700f 100644 --- a/src/ui/Desktop/Homepage/HomepageAlertManager.tsx +++ b/src/ui/Desktop/Homepage/HomepageAlertManager.tsx @@ -27,13 +27,11 @@ export function HomepageAlertManager({ }, [loggedIn, userId]); const fetchUserAlerts = async () => { - if (!userId) return; - setLoading(true); setError(null); try { - const response = await getUserAlerts(userId); + const response = await getUserAlerts(); const userAlerts = response.alerts || []; const sortedAlerts = userAlerts.sort((a: TermixAlert, b: TermixAlert) => { @@ -64,10 +62,8 @@ export function HomepageAlertManager({ }; const handleDismissAlert = async (alertId: string) => { - if (!userId) return; - try { - await dismissAlert(userId, alertId); + await dismissAlert(alertId); setAlerts((prev) => { const newAlerts = prev.filter((alert) => alert.id !== alertId); diff --git a/src/ui/Desktop/Homepage/HomepageAuth.tsx b/src/ui/Desktop/Homepage/HomepageAuth.tsx index 0b580dbd..93628fee 100644 --- a/src/ui/Desktop/Homepage/HomepageAuth.tsx +++ b/src/ui/Desktop/Homepage/HomepageAuth.tsx @@ -4,9 +4,9 @@ import { Button } from "@/components/ui/button.tsx"; import { Input } from "@/components/ui/input.tsx"; import { PasswordInput } from "@/components/ui/password-input.tsx"; import { Label } from "@/components/ui/label.tsx"; -import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert.tsx"; import { useTranslation } from "react-i18next"; import { LanguageSwitcher } from "@/ui/Desktop/User/LanguageSwitcher.tsx"; +import { toast } from "sonner"; import { registerUser, loginUser, @@ -124,20 +124,32 @@ export function HomepageAuth({ }, []); useEffect(() => { + setDbHealthChecking(true); getSetupRequired() .then((res) => { if (res.setup_required) { setFirstUser(true); setTab("signup"); + toast.info(t("auth.firstUserMessage")); } else { setFirstUser(false); } setDbError(null); + setDbConnectionFailed(false); }) .catch(() => { - setDbError(t("errors.databaseConnection")); + setDbConnectionFailed(true); + }) + .finally(() => { + setDbHealthChecking(false); }); - }, [setDbError]); + }, [setDbError, t]); + + useEffect(() => { + if (!registrationAllowed && !internalLoggedIn) { + toast.warning(t("messages.registrationDisabled")); + } + }, [registrationAllowed, internalLoggedIn, t]); async function handleSubmit(e: React.FormEvent) { e.preventDefault(); @@ -145,7 +157,7 @@ export function HomepageAuth({ setLoading(true); if (!localUsername.trim()) { - setError(t("errors.requiredField")); + toast.error(t("errors.requiredField")); setLoading(false); return; } @@ -156,12 +168,12 @@ export function HomepageAuth({ res = await loginUser(localUsername, password); } else { if (password !== signupConfirmPassword) { - setError(t("errors.passwordMismatch")); + toast.error(t("errors.passwordMismatch")); setLoading(false); return; } if (password.length < 6) { - setError(t("errors.minLength", { min: 6 })); + toast.error(t("errors.minLength", { min: 6 })); setLoading(false); return; } @@ -199,22 +211,25 @@ export function HomepageAuth({ setLoggedIn(true); setIsAdmin(!!meRes.is_admin); setUsername(meRes.username || null); - setUserId(meRes.id || null); + setUserId(meRes.userId || null); setDbError(null); onAuthSuccess({ isAdmin: !!meRes.is_admin, username: meRes.username || null, - userId: meRes.id || null, + userId: meRes.userId || null, }); setInternalLoggedIn(true); if (tab === "signup") { setSignupConfirmPassword(""); + toast.success(t("messages.registrationSuccess")); + } else { + toast.success(t("messages.loginSuccess")); } setTotpRequired(false); setTotpCode(""); setTotpTempToken(""); } catch (err: any) { - setError( + toast.error( err?.response?.data?.error || err?.message || t("errors.unknownError"), ); setInternalLoggedIn(false); @@ -224,7 +239,7 @@ export function HomepageAuth({ setUserId(null); setCookie("jwt", "", -1); if (err?.response?.data?.error?.includes("Database")) { - setDbError(t("errors.databaseConnection")); + setDbConnectionFailed(true); } else { setDbError(null); } @@ -239,9 +254,9 @@ export function HomepageAuth({ try { const result = await initiatePasswordReset(localUsername); setResetStep("verify"); - setError(null); + toast.success(t("messages.resetCodeSent")); } catch (err: any) { - setError( + toast.error( err?.response?.data?.error || err?.message || t("errors.failedPasswordReset"), @@ -258,9 +273,9 @@ export function HomepageAuth({ const response = await verifyPasswordResetCode(localUsername, resetCode); setTempToken(response.tempToken); setResetStep("newPassword"); - setError(null); + toast.success(t("messages.codeVerified")); } catch (err: any) { - setError(err?.response?.data?.error || t("errors.failedVerifyCode")); + toast.error(err?.response?.data?.error || t("errors.failedVerifyCode")); } finally { setResetLoading(false); } @@ -271,13 +286,13 @@ export function HomepageAuth({ setResetLoading(true); if (newPassword !== confirmPassword) { - setError(t("errors.passwordMismatch")); + toast.error(t("errors.passwordMismatch")); setResetLoading(false); return; } if (newPassword.length < 6) { - setError(t("errors.minLength", { min: 6 })); + toast.error(t("errors.minLength", { min: 6 })); setResetLoading(false); return; } @@ -293,8 +308,9 @@ export function HomepageAuth({ setError(null); setResetSuccess(true); + toast.success(t("messages.passwordResetSuccess")); } catch (err: any) { - setError(err?.response?.data?.error || t("errors.failedCompleteReset")); + toast.error(err?.response?.data?.error || t("errors.failedCompleteReset")); } finally { setResetLoading(false); } @@ -319,7 +335,7 @@ export function HomepageAuth({ async function handleTOTPVerification() { if (totpCode.length !== 6) { - setError(t("auth.enterCode")); + toast.error(t("auth.enterCode")); return; } @@ -340,19 +356,20 @@ export function HomepageAuth({ setLoggedIn(true); setIsAdmin(!!meRes.is_admin); setUsername(meRes.username || null); - setUserId(meRes.id || null); + setUserId(meRes.userId || null); setDbError(null); onAuthSuccess({ isAdmin: !!meRes.is_admin, username: meRes.username || null, - userId: meRes.id || null, + userId: meRes.userId || null, }); setInternalLoggedIn(true); setTotpRequired(false); setTotpCode(""); setTotpTempToken(""); + toast.success(t("messages.loginSuccess")); } catch (err: any) { - setError( + toast.error( err?.response?.data?.error || err?.message || t("errors.invalidTotpCode"), @@ -375,7 +392,7 @@ export function HomepageAuth({ window.location.replace(authUrl); } catch (err: any) { - setError( + toast.error( err?.response?.data?.error || err?.message || t("errors.failedOidcLogin"), @@ -391,7 +408,7 @@ export function HomepageAuth({ const error = urlParams.get("error"); if (error) { - setError(`${t("errors.oidcAuthFailed")}: ${error}`); + toast.error(`${t("errors.oidcAuthFailed")}: ${error}`); setOidcLoading(false); window.history.replaceState({}, document.title, window.location.pathname); return; @@ -408,12 +425,12 @@ export function HomepageAuth({ setLoggedIn(true); setIsAdmin(!!meRes.is_admin); setUsername(meRes.username || null); - setUserId(meRes.id || null); + setUserId(meRes.userId || null); setDbError(null); onAuthSuccess({ isAdmin: !!meRes.is_admin, username: meRes.username || null, - userId: meRes.id || null, + userId: meRes.userId || null, }); setInternalLoggedIn(true); window.history.replaceState( @@ -423,7 +440,7 @@ export function HomepageAuth({ ); }) .catch((err) => { - setError(t("errors.failedUserInfo")); + toast.error(t("errors.failedUserInfo")); setInternalLoggedIn(false); setLoggedIn(false); setIsAdmin(false); @@ -468,6 +485,34 @@ export function HomepageAuth({ null, ); const [currentServerUrl, setCurrentServerUrl] = useState(""); + const [dbConnectionFailed, setDbConnectionFailed] = useState(false); + const [dbHealthChecking, setDbHealthChecking] = useState(false); + + useEffect(() => { + if (dbConnectionFailed) { + toast.error(t("errors.databaseConnection")); + } + }, [dbConnectionFailed, t]); + + const retryDatabaseConnection = async () => { + setDbHealthChecking(true); + setDbConnectionFailed(false); + try { + const res = await getSetupRequired(); + if (res.setup_required) { + setFirstUser(true); + setTab("signup"); + } else { + setFirstUser(false); + } + setDbError(null); + toast.success(t("messages.databaseConnected")); + } catch (error) { + setDbConnectionFailed(true); + } finally { + setDbHealthChecking(false); + } + }; useEffect(() => { const checkServerConfig = async () => { @@ -519,42 +564,91 @@ export function HomepageAuth({ ); } + if (dbHealthChecking && !dbConnectionFailed) { + return ( +
+
+
+
+

+ {t("common.checkingDatabase")} +

+
+
+
+ ); + } + + if (dbConnectionFailed) { + return ( +
+
+

+ {t("errors.databaseConnection")} +

+

+ {t("messages.databaseConnectionFailed")} +

+
+ +
+ +
+ +
+
+
+ +
+ +
+ {isElectron() && currentServerUrl && ( +
+
+ +
+ {currentServerUrl} +
+
+ +
+ )} +
+
+ ); + } + return (
- {dbError && ( - - Error - {dbError} - - )} - {firstUser && !dbError && !internalLoggedIn && ( - - {t("auth.firstUser")} - - {t("auth.firstUserMessage")}{" "} - - GitHub Issue - - . - - - )} - {!registrationAllowed && !internalLoggedIn && ( - - {t("auth.registerTitle")} - - {t("messages.registrationDisabled")} - - - )} {totpRequired && (
@@ -805,14 +899,11 @@ export function HomepageAuth({ {resetSuccess && ( <> - - - {t("auth.passwordResetSuccess")} - - +
+

{t("auth.passwordResetSuccessDesc")} - - +

+
); } diff --git a/src/ui/Desktop/Navigation/LeftSidebar.tsx b/src/ui/Desktop/Navigation/LeftSidebar.tsx index 81020d79..0e59c30a 100644 --- a/src/ui/Desktop/Navigation/LeftSidebar.tsx +++ b/src/ui/Desktop/Navigation/LeftSidebar.tsx @@ -361,8 +361,8 @@ export function LeftSidebar({
{hostsError && ( -
-
+
+
{t("leftSidebar.failedToLoadHosts")}
diff --git a/src/ui/Mobile/Apps/Terminal/Terminal.tsx b/src/ui/Mobile/Apps/Terminal/Terminal.tsx index 861bfd80..285bd8a7 100644 --- a/src/ui/Mobile/Apps/Terminal/Terminal.tsx +++ b/src/ui/Mobile/Apps/Terminal/Terminal.tsx @@ -311,17 +311,17 @@ export const Terminal = forwardRef(function SSHTerminal( window.location.port === ""); const baseWsUrl = isDev - ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:8082` + ? `${window.location.protocol === "https:" ? "wss" : "ws"}://localhost:30002` : isElectron() ? (() => { const baseUrl = (window as any).configuredServerUrl || - "http://127.0.0.1:8081"; + "http://127.0.0.1:30001"; const wsProtocol = baseUrl.startsWith("https://") ? "wss://" : "ws://"; const wsHost = baseUrl.replace(/^https?:\/\//, ""); - return `${wsProtocol}${wsHost.replace(':8081', ':8082')}/ssh/websocket/`; + return `${wsProtocol}${wsHost.replace(':30001', ':30002')}/ssh/websocket/`; })() : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.host}/ssh/websocket/`; diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts index 2f149a3c..60797e1c 100644 --- a/src/ui/main-axios.ts +++ b/src/ui/main-axios.ts @@ -283,7 +283,7 @@ function createApiInstance( // Handle DEK (Data Encryption Key) invalidation if (status === 423) { const errorData = error.response?.data; - if (errorData?.error === "DATA_LOCKED" || errorData?.message?.includes("DATA_LOCKED")) { + if ((errorData as any)?.error === "DATA_LOCKED" || (errorData as any)?.message?.includes("DATA_LOCKED")) { // DEK session has expired (likely due to server restart or timeout) // Force logout to require re-authentication and DEK unlock if (isElectron()) { @@ -324,11 +324,11 @@ function isDev(): boolean { } let apiHost = import.meta.env.VITE_API_HOST || "localhost"; -let apiPort = 8081; +let apiPort = 30001; let configuredServerUrl: string | null = null; if (isElectron()) { - apiPort = 8081; + apiPort = 30001; } export interface ServerConfig { @@ -416,38 +416,38 @@ function getApiUrl(path: string, defaultPort: number): string { // Initialize API instances function initializeApiInstances() { - // SSH Host Management API (port 8081) - sshHostApi = createApiInstance(getApiUrl("/ssh", 8081), "SSH_HOST"); + // SSH Host Management API (port 30001) + sshHostApi = createApiInstance(getApiUrl("/ssh", 30001), "SSH_HOST"); - // Tunnel Management API (port 8083) - tunnelApi = createApiInstance(getApiUrl("/ssh", 8083), "TUNNEL"); + // Tunnel Management API (port 30003) + tunnelApi = createApiInstance(getApiUrl("/ssh", 30003), "TUNNEL"); - // File Manager Operations API (port 8084) + // File Manager Operations API (port 30004) fileManagerApi = createApiInstance( - getApiUrl("/ssh/file_manager", 8084), + getApiUrl("/ssh/file_manager", 30004), "FILE_MANAGER", ); - // Server Statistics API (port 8085) - statsApi = createApiInstance(getApiUrl("", 8085), "STATS"); + // Server Statistics API (port 30005) + statsApi = createApiInstance(getApiUrl("", 30005), "STATS"); - // Authentication API (port 8081) - authApi = createApiInstance(getApiUrl("", 8081), "AUTH"); + // Authentication API (port 30001) + authApi = createApiInstance(getApiUrl("", 30001), "AUTH"); } -// SSH Host Management API (port 8081) +// SSH Host Management API (port 30001) export let sshHostApi: AxiosInstance; -// Tunnel Management API (port 8083) +// Tunnel Management API (port 30003) export let tunnelApi: AxiosInstance; -// File Manager Operations API (port 8084) +// File Manager Operations API (port 30004) export let fileManagerApi: AxiosInstance; -// Server Statistics API (port 8085) +// Server Statistics API (port 30005) export let statsApi: AxiosInstance; -// Authentication API (port 8081) +// Authentication API (port 30001) export let authApi: AxiosInstance; // Initialize API instances immediately @@ -1763,11 +1763,9 @@ export async function generateBackupCodes( } } -export async function getUserAlerts( - userId: string, -): Promise<{ alerts: any[] }> { +export async function getUserAlerts(): Promise<{ alerts: any[] }> { try { - const response = await authApi.get(`/alerts/user/${userId}`); + const response = await authApi.get(`/alerts`); return response.data; } catch (error) { handleApiError(error, "fetch user alerts"); @@ -1775,11 +1773,10 @@ export async function getUserAlerts( } export async function dismissAlert( - userId: string, alertId: string, ): Promise { try { - const response = await authApi.post("/alerts/dismiss", { userId, alertId }); + const response = await authApi.post("/alerts/dismiss", { alertId }); return response.data; } catch (error) { handleApiError(error, "dismiss alert");