diff --git a/.claude/settings.local.json b/.claude/settings.local.json index dd8ca7a..012dc8b 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,9 @@ { "permissions": { "allow": [ - "Bash(grep:*)" + "Bash(grep:*)", + "Bash(npm run build:*)", + "Bash(npm run lint)" ], "deny": [] } diff --git a/CLAUDE.md b/CLAUDE.md index d229e6c..21a3421 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -114,6 +114,60 @@ The application follows a communication pattern using Electron's IPC: - Always use toRaw for reactive objects to avoid errors like "An object could not be cloned." - Use separate loading states for initial loading vs. background refreshes to avoid UI flickering +## Remote File Explorer with Directory Tree + +The application includes an enhanced remote file explorer with sidebar navigation: + +### Features + +- **Directory Tree Sidebar**: Collapsible tree structure for fast navigation through nested directories +- **State Persistence**: Remembers current directory when switching between file explorer and database views +- **File Integration**: Click files in tree to open directly in Monaco Editor +- **Connection-Aware State**: Different states maintained per SSH connection + +### Implementation Details + +- **Store**: `src/store/fileExplorer.ts` - Pinia store managing directory tree state and file operations +- **Components**: + - `DirectoryTreeSidebar.vue` - Main sidebar with expand/collapse functionality + - `DirectoryTreeNode.vue` - Individual tree nodes with file/directory icons + - `RemoteFileExplorer.vue` - Main file explorer with integrated sidebar +- **State Management**: Uses `expandedPaths` Set to track opened directories and `currentPath` for navigation +- **Performance**: Leverages existing optimized SSH connection pooling for instant navigation + +### Path Construction Fixes + +- **Breadcrumb Issues**: Fixed path calculation that was cutting off directory names +- **Tree Navigation**: Corrected file path construction to prevent "Is a directory" errors +- **Event Propagation**: Fixed Vue.js event handling using `(...args) => $emit('openFile', ...args)` pattern + +## Monaco Editor Configuration + +Monaco Editor requires special configuration for Vite/Electron environments: + +### Setup + +- **Central Configuration**: `src/utils/monaco-config.ts` provides centralized worker setup +- **Worker Configuration**: Proper import of workers using Vite's `?worker` suffix +- **Applied To**: RemoteFileEditor, SQLEditor (via useSQLEditor), DotEnvEditor + +### Common Issues + +- **`toUrl` Errors**: Fixed by configuring `MonacoEnvironment.getWorker` properly +- **Worker Loading**: Use `import worker from 'monaco-editor/esm/vs/.../worker?worker'` pattern +- **Multiple Components**: Always call `configureMonaco()` before using Monaco in any component + +### Vite Configuration + +```typescript +optimizeDeps: { + include: ['monaco-editor'] +}, +define: { + 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development') +} +``` + ## Git Integration The application includes Git integration features: diff --git a/electron/helpers/mysql.ts b/electron/helpers/mysql.ts index 21857e0..508e3dd 100644 --- a/electron/helpers/mysql.ts +++ b/electron/helpers/mysql.ts @@ -124,7 +124,6 @@ function getPoolKey( )?.localPort : config.localDbConfig.port; - // Para conexões remotas, use as informações do remoteDbConfig const userPart = config.remote ? config.remote.remoteDbConfig.user : config.localDbConfig.user; @@ -142,14 +141,16 @@ async function getConnectionPool( config: AppConnection, { useConnectionDb = true, targetDatabase = '' } = {} ): Promise { + const connectionOptions = await getConnectionOptions(config, { + useConnectionDb, + targetDatabase + }); + const poolKey = getPoolKey(config, useConnectionDb, targetDatabase); if (!connectionPools.has(poolKey)) { const poolConfig = { - ...(await getConnectionOptions(config, { - useConnectionDb, - targetDatabase - })), + ...connectionOptions, connectionLimit: 10, waitForConnections: true, queueLimit: 0 @@ -188,7 +189,6 @@ async function ping( const conn = await createConnection(config, options); try { - // Define a interface para o resultado da consulta de teste interface ConnectionTestRow extends RowDataPacket { connection_test: number; } diff --git a/electron/helpers/optimized-ssh.ts b/electron/helpers/optimized-ssh.ts new file mode 100644 index 0000000..4b5c7c7 --- /dev/null +++ b/electron/helpers/optimized-ssh.ts @@ -0,0 +1,246 @@ +import { Client, ConnectConfig } from 'ssh2'; +import * as fs from 'fs'; +import { SshConnection } from '../../src/types/ssh-connection'; + +interface ConnectionPool { + client: Client; + lastUsed: number; + config: SshConnection; +} + +class OptimizedSshManager { + private connections = new Map(); + private readonly CONNECTION_TIMEOUT = 300000; // 5 minutes + private readonly MAX_CONNECTIONS = 10; + private readonly cleanupInterval: NodeJS.Timeout; + + constructor() { + this.cleanupInterval = setInterval(() => { + this.cleanupConnections(); + }, 60000); + } + + private getConnectionKey(config: SshConnection): string { + return `${config.user}@${config.host}:${config.port}:${config.remotePath}`; + } + + private cleanupConnections(): void { + const now = Date.now(); + const keysToDelete: string[] = []; + + for (const [key, pool] of this.connections) { + if (now - pool.lastUsed > this.CONNECTION_TIMEOUT) { + try { + pool.client.end(); + } catch (error) { + console.error('Error closing SSH connection:', error); + } + keysToDelete.push(key); + } + } + + keysToDelete.forEach((key) => this.connections.delete(key)); + console.log( + `SSH cleanup: removed ${keysToDelete.length} unused connections` + ); + } + + private async createOptimizedConnection( + config: SshConnection + ): Promise { + return new Promise((resolve, reject) => { + const conn = new Client(); + + const connectConfig: ConnectConfig = { + host: config.host, + port: config.port, + username: config.user, + compress: true, + algorithms: { + kex: [ + 'diffie-hellman-group14-sha256', + 'diffie-hellman-group16-sha512', + 'diffie-hellman-group18-sha512', + 'diffie-hellman-group-exchange-sha256' + ], + cipher: [ + 'aes128-ctr', + 'aes192-ctr', + 'aes256-ctr', + 'aes128-gcm', + 'aes256-gcm' + ], + hmac: ['hmac-sha2-256', 'hmac-sha2-512', 'hmac-sha1'], + compress: ['zlib@openssh.com', 'zlib', 'none'] + }, + readyTimeout: 10000, + keepaliveInterval: 30000, // Keep connection alive + keepaliveCountMax: 3 + }; + + if (config.privateKey) { + try { + connectConfig.privateKey = fs.readFileSync( + config.privateKey, + 'utf8' + ); + if (config.passphrase) { + connectConfig.passphrase = config.passphrase; + } + } catch (error) { + reject( + new Error( + `Failed to read private key: ${error instanceof Error ? error.message : String(error)}` + ) + ); + return; + } + } else if (config.password) { + connectConfig.password = config.password; + } else { + reject(new Error('No authentication method provided')); + return; + } + + conn.on('ready', () => { + console.log( + `Optimized SSH connection established to ${config.host} with compression` + ); + resolve(conn); + }); + + conn.on('error', (err) => { + console.error(`SSH connection error to ${config.host}:`, err); + reject(err); + }); + + conn.on('close', () => { + console.log(`SSH connection closed to ${config.host}`); + }); + + conn.connect(connectConfig); + }); + } + + async getConnection(config: SshConnection): Promise { + const key = this.getConnectionKey(config); + const existing = this.connections.get(key); + + if (existing) { + console.log('Reusing existing connection'); + existing.lastUsed = Date.now(); + return existing.client; + } + + if (this.connections.size < this.MAX_CONNECTIONS) { + console.log('Creating new connection'); + const client = await this.createOptimizedConnection(config); + + this.connections.set(key, { + client, + lastUsed: Date.now(), + config + }); + + return client; + } + + throw new Error( + 'Maximum SSH connections reached. Please wait and try again.' + ); + } + + releaseConnection(config: SshConnection): void { + const key = this.getConnectionKey(config); + const pool = this.connections.get(key); + + if (pool) { + pool.lastUsed = Date.now(); + } + } + + async executeCommand( + config: SshConnection, + command: string + ): Promise<{ stdout: string; stderr: string; code: number | null }> { + const client = await this.getConnection(config); + + try { + return new Promise((resolve, reject) => { + client.exec(command, (err, stream) => { + if (err) { + console.log( + 'Command exec failed, removing connection from pool' + ); + this.closeConnection(config); + reject(err); + return; + } + + let stdout = ''; + let stderr = ''; + + stream.on('data', (data: Buffer) => { + stdout += data.toString(); + }); + + stream.stderr.on('data', (data: Buffer) => { + stderr += data.toString(); + }); + + stream.on('close', (code: number | null) => { + this.releaseConnection(config); + resolve({ stdout, stderr, code }); + }); + + stream.on('error', (err: Error) => { + console.log( + 'Stream error, removing connection from pool' + ); + this.closeConnection(config); + reject(err); + }); + }); + }); + } catch (error) { + this.closeConnection(config); + throw error; + } + } + + closeConnection(config: SshConnection): void { + const key = this.getConnectionKey(config); + const pool = this.connections.get(key); + + if (pool) { + try { + pool.client.end(); + } catch (error) { + console.error('Error closing SSH connection:', error); + } + this.connections.delete(key); + } + } + + closeAllConnections(): void { + for (const pool of this.connections.values()) { + try { + pool.client.end(); + } catch (error) { + console.error('Error closing SSH connection:', error); + } + } + this.connections.clear(); + clearInterval(this.cleanupInterval); + } + + getConnectionStats(): { total: number; available: number } { + return { + total: this.connections.size, + available: this.connections.size + }; + } +} + +export const optimizedSshManager = new OptimizedSshManager(); +export default optimizedSshManager; diff --git a/electron/helpers/ssh.ts b/electron/helpers/ssh.ts index 868c714..dfb01a6 100644 --- a/electron/helpers/ssh.ts +++ b/electron/helpers/ssh.ts @@ -7,21 +7,6 @@ import { SshConnection } from '../../src/types/ssh-connection'; interface SshError extends Error { code?: string; } - -interface SftpFile { - filename: string; - longname: string; - attrs: { - size: number; - mtime: number; - atime: number; - uid: number; - gid: number; - mode: number; - [key: string]: number | string | boolean | undefined; - }; -} - interface FileExplorerItem { name: string; type: 'file' | 'directory'; @@ -59,7 +44,7 @@ async function createConnection(config: SshConnection): Promise { host: config.host, port: config.port, username: config.user, // ssh2 uses username, not user - debug: (message: string) => console.log(`SSH Debug: ${message}`), + //debug: (message: string) => console.log(`SSH Debug: ${message}`), readyTimeout: 10000 }; @@ -226,9 +211,7 @@ async function listRemoteFiles( ): Promise { const client = await createConnection(config); - // Safety check to make sure we don't go outside the project directory if (!dirPath.startsWith(config.remotePath)) { - // If dirPath is outside remotePath, default to remotePath dirPath = config.remotePath; } @@ -499,9 +482,6 @@ async function createTunnel( remotePort: number, localPort: number = 0 ): Promise<{ tunnelId: string; localPort: number }> { - // This is the critical fix - always create a fresh connection for tunneling - // This ensures we don't reuse a connection that might be in a bad state - // from other operations like SFTP const sshClient = await createConnection(config); return new Promise((resolve, reject) => { diff --git a/electron/modules/ssh.ts b/electron/modules/ssh.ts index 57ba20f..871a58b 100644 --- a/electron/modules/ssh.ts +++ b/electron/modules/ssh.ts @@ -3,13 +3,13 @@ import * as path from 'path'; import { testConnection, closeConnection, - executeCommand, readRemoteFile, writeRemoteFile, listRemoteFiles, createTunnel, closeTunnel } from '../helpers/ssh'; +import { optimizedSshManager } from '../helpers/optimized-ssh'; import { SshConnection } from '../../src/types/ssh-connection'; function registerSshHandlers() { @@ -20,7 +20,21 @@ function registerSshHandlers() { ipcMain.handle( 'ssh:execute-command', async (_, config: SshConnection, command: string) => { - return await executeCommand(config, command); + try { + return await optimizedSshManager.executeCommand( + config, + command + ); + } catch (error) { + return { + stdout: '', + stderr: + error instanceof Error + ? error.message + : 'Unknown error', + code: 1 + }; + } } ); @@ -157,6 +171,158 @@ function registerSshHandlers() { const success = closeTunnel(tunnelId); return { success }; }); + + ipcMain.handle( + 'ssh:optimized-list', + async (_, config: SshConnection, dirPath: string) => { + try { + const command = `cd '${dirPath}' && ls -1F`; + + const result = await optimizedSshManager.executeCommand( + config, + command + ); + + if (result.code !== 0) { + throw new Error( + result.stderr || 'Failed to list directory' + ); + } + + const files = parseLsOutput(result.stdout); + + return { success: true, files }; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + ipcMain.handle( + 'ssh:optimized-read', + async (_, config: SshConnection, filePath: string, length?: number) => { + try { + const maxSize = 1024 * 1024; // 1MB limit + const readSize = length ? Math.min(length, maxSize) : maxSize; + const command = `head -c ${readSize} "${filePath}"`; + + const result = await optimizedSshManager.executeCommand( + config, + command + ); + + if (result.code !== 0) { + throw new Error(result.stderr || 'Failed to read file'); + } + + return { success: true, content: result.stdout }; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + ipcMain.handle( + 'ssh:optimized-write', + async (_, config: SshConnection, filePath: string, content: string) => { + try { + const command = `cat > "${filePath}" << 'EOF'\n${content}\nEOF`; + + const result = await optimizedSshManager.executeCommand( + config, + command + ); + + if (result.code !== 0) { + throw new Error(result.stderr || 'Failed to write file'); + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + ipcMain.handle( + 'ssh:optimized-exists', + async (_, config: SshConnection, path: string) => { + try { + const command = `test -e "${path}" && echo "exists" || echo "not_exists"`; + const result = await optimizedSshManager.executeCommand( + config, + command + ); + + return { + success: true, + exists: result.stdout.trim() === 'exists' + }; + } catch (error) { + return { + success: false, + exists: false, + error: + error instanceof Error ? error.message : 'Unknown error' + }; + } + } + ); + + ipcMain.handle('ssh:connection-stats', () => { + return optimizedSshManager.getConnectionStats(); + }); +} + +function parseLsOutput(output: string): any[] { + const lines = output.split('\n').filter((line) => line.trim()); + const files: any[] = []; + + for (const line of lines) { + const trimmedLine = line.trim(); + if (!trimmedLine || trimmedLine === '.' || trimmedLine === '..') + continue; + + let name = trimmedLine; + let type = 'file'; + + if (name.endsWith('/')) { + name = name.slice(0, -1); + type = 'directory'; + } else if (name.endsWith('*')) { + name = name.slice(0, -1); + type = 'file'; + } else if (name.endsWith('@')) { + name = name.slice(0, -1); + type = 'file'; + } + + if (!name || name === '.' || name === '..') continue; + + files.push({ + name, + type, + size: 0, + modTime: Date.now(), + permissions: type === 'directory' ? 'drwxr-xr-x' : '-rw-r--r--', + owner: 'user', + group: 'group' + }); + } + + return files; } export { registerSshHandlers }; diff --git a/electron/preload/index.ts b/electron/preload/index.ts index f2bb2e7..bd4bd82 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -269,7 +269,30 @@ contextBridge.exposeInMainWorld('ipcRenderer', { localPort ), closeTunnel: (tunnelId: string) => - ipcRenderer.invoke('ssh:close-tunnel', tunnelId) + ipcRenderer.invoke('ssh:close-tunnel', tunnelId), + + // Optimized SSH methods + optimizedList: (config: SshConnection, dirPath: string) => + ipcRenderer.invoke('ssh:optimized-list', config, dirPath), + optimizedRead: ( + config: SshConnection, + filePath: string, + length?: number + ) => ipcRenderer.invoke('ssh:optimized-read', config, filePath, length), + optimizedWrite: ( + config: SshConnection, + filePath: string, + content: string + ) => + ipcRenderer.invoke( + 'ssh:optimized-write', + config, + filePath, + content + ), + optimizedExists: (config: SshConnection, path: string) => + ipcRenderer.invoke('ssh:optimized-exists', config, path), + connectionStats: () => ipcRenderer.invoke('ssh:connection-stats') }, /** diff --git a/package.json b/package.json index 36c4cb1..bc85ee5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "larabase", - "version": "1.2.5", + "version": "1.2.6", "main": "dist-electron/main/index.js", "description": "An Opined Database GUI for Laravel Developers", "author": "Tiago Padilha ", diff --git a/run-in-terminal.sh b/run-in-terminal.sh new file mode 100644 index 0000000..0116c6f --- /dev/null +++ b/run-in-terminal.sh @@ -0,0 +1 @@ +/Applications/Larabase.app/Contents/MacOS/Larabase \ No newline at end of file diff --git a/src/components/DirectoryTreeNode.vue b/src/components/DirectoryTreeNode.vue new file mode 100644 index 0000000..8c5adc9 --- /dev/null +++ b/src/components/DirectoryTreeNode.vue @@ -0,0 +1,152 @@ + + + + + diff --git a/src/components/DirectoryTreeSidebar.vue b/src/components/DirectoryTreeSidebar.vue new file mode 100644 index 0000000..7813ddb --- /dev/null +++ b/src/components/DirectoryTreeSidebar.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/src/components/DotEnvEditor.vue b/src/components/DotEnvEditor.vue index 10aebe5..386b020 100644 --- a/src/components/DotEnvEditor.vue +++ b/src/components/DotEnvEditor.vue @@ -1,7 +1,10 @@ @@ -239,15 +261,73 @@ onMounted(() => { @close="$emit('close')" class="file-explorer-modal" > -
+
-
+ +
+ +
+
+
+
+ + + +
+
+ - -
-
-
- -
+
+ +
-
-
- - - - - - - - - - - - +
+
- Name - - Size - - Type - - Modified - - Actions -
+ - - - - - - - - + +
- + - - - .. - Directory
+
-
-
- {{ error }} -
+
+ {{ error }} +
-
-
-
- +
+
+ + + +

+ {{ selectedFile.name }} +

+
+
- -
-
-
{{ selectedFile.content }}
+
{{ selectedFile.content }}
+
diff --git a/src/composables/useSQLEditor.ts b/src/composables/useSQLEditor.ts index 7d4d652..d87ac08 100644 --- a/src/composables/useSQLEditor.ts +++ b/src/composables/useSQLEditor.ts @@ -1,6 +1,9 @@ import * as monaco from 'monaco-editor'; +import { configureMonaco } from '@/utils/monaco-config'; import { format } from 'sql-formatter'; import { ref, onMounted, onBeforeUnmount, watch } from 'vue'; + +configureMonaco(); import { useConnectionsStore } from '@/store/connections'; import { useSqlResultsStore } from '@/store/sqlResults'; import { AIService } from '@/services/aiService'; diff --git a/src/services/optimized-ssh-service.ts b/src/services/optimized-ssh-service.ts new file mode 100644 index 0000000..c28cdc6 --- /dev/null +++ b/src/services/optimized-ssh-service.ts @@ -0,0 +1,168 @@ +import { SshConnection } from '@/types/ssh-connection'; + +export interface FileEntry { + name: string; + type: 'file' | 'directory'; + size: number; + modTime: number; + permissions: string; + owner: string; + group: string; +} + +export interface CachedDirectory { + files: FileEntry[]; + lastUpdated: number; + ttl: number; +} + +class OptimizedSshService { + private cache = new Map(); + private readonly CACHE_TTL = 30000; // 30 seconds + private readonly MAX_READ_SIZE = 1024 * 1024; // 1MB max for preview + + private getCacheKey(connection: SshConnection, path: string): string { + return `${connection.host}:${connection.port}:${connection.user}:${path}`; + } + + private isValidCache(cached: CachedDirectory): boolean { + return Date.now() - cached.lastUpdated < cached.ttl; + } + + private sanitizeForIpc(obj: unknown): any { + if (obj === null || obj === undefined) { + return null; + } + + if (typeof obj !== 'object') { + return obj; + } + + if (obj instanceof Date) { + return obj.toISOString(); + } + + if (Array.isArray(obj)) { + return obj.map((item) => this.sanitizeForIpc(item)); + } + + const cleanObj: any = {}; + for (const key in obj as Record) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + const value = (obj as Record)[key]; + if ( + typeof value !== 'function' && + typeof value !== 'symbol' && + key !== '__proto__' + ) { + cleanObj[key] = this.sanitizeForIpc(value); + } + } + } + return cleanObj; + } + + async list(connection: SshConnection, path: string): Promise { + try { + const result = await window.ipcRenderer.ssh.optimizedList( + this.sanitizeForIpc(connection), + path + ); + + if (!result.success) { + throw new Error(result.error || 'Failed to list directory'); + } + + return result.files || []; + } catch (error) { + console.error('Error listing directory:', error); + throw error; + } + } + + async read( + connection: SshConnection, + filePath: string, + length?: number + ): Promise { + try { + let command: string; + + if (length && length > 0) { + command = `head -c ${Math.min(length, this.MAX_READ_SIZE)} "${filePath}"`; + } else { + command = `head -c ${this.MAX_READ_SIZE} "${filePath}"`; + } + + const result = await window.ipcRenderer.ssh.executeCommand( + this.sanitizeForIpc(connection), + command + ); + + if (result.code !== 0) { + throw new Error( + `Failed to read file: ${result.stderr || result.stdout}` + ); + } + + return result.stdout; + } catch (error) { + console.error('Error reading file:', error); + throw error; + } + } + + async write( + connection: SshConnection, + filePath: string, + content: string + ): Promise { + try { + content.replace(/'/g, "'\"'\"'"); + const command = `cat > "${filePath}" << 'EOF'\n${content}\nEOF`; + + const result = await window.ipcRenderer.ssh.executeCommand( + this.sanitizeForIpc(connection), + command + ); + + if (result.code !== 0) { + throw new Error( + `Failed to write file: ${result.stderr || result.stdout}` + ); + } + + const parentDir = filePath.substring(0, filePath.lastIndexOf('/')); + this.invalidateCache(connection, parentDir); + } catch (error) { + console.error('Error writing file:', error); + throw error; + } + } + + async exists(connection: SshConnection, path: string): Promise { + try { + const command = `test -e "${path}" && echo "exists" || echo "not_exists"`; + const result = await window.ipcRenderer.ssh.executeCommand( + this.sanitizeForIpc(connection), + command + ); + + return result.stdout.trim() === 'exists'; + } catch (error) { + console.error('Error checking path existence:', error); + return false; + } + } + + invalidateCache(connection: SshConnection, path: string): void { + const cacheKey = this.getCacheKey(connection, path); + this.cache.delete(cacheKey); + } + + clearCache(): void { + this.cache.clear(); + } +} + +export const optimizedSshService = new OptimizedSshService(); diff --git a/src/services/remote-file-service.ts b/src/services/remote-file-service.ts index 860d29d..3c99fde 100644 --- a/src/services/remote-file-service.ts +++ b/src/services/remote-file-service.ts @@ -65,6 +65,22 @@ export async function listRemoteFiles( try { const sanitizedConfig = sanitizeForIpc(sshConfig); + try { + const optimizedResult = await window.ipcRenderer.ssh.optimizedList( + sanitizedConfig, + dirPath + ); + + if (optimizedResult.success) { + return optimizedResult.files as RemoteFileEntry[]; + } + } catch (optimizedError) { + console.warn( + 'Optimized list failed, falling back to SFTP:', + optimizedError + ); + } + const result = await window.ipcRenderer.ssh.listFiles( sanitizedConfig, dirPath @@ -88,6 +104,22 @@ export async function readRemoteFile( try { const sanitizedConfig = sanitizeForIpc(sshConfig); + try { + const optimizedResult = await window.ipcRenderer.ssh.optimizedRead( + sanitizedConfig, + filePath + ); + + if (optimizedResult.success) { + return optimizedResult.content || ''; + } + } catch (optimizedError) { + console.warn( + 'Optimized read failed, falling back to SFTP:', + optimizedError + ); + } + const result = await window.ipcRenderer.ssh.readFile( sanitizedConfig, filePath @@ -97,7 +129,7 @@ export async function readRemoteFile( throw new Error(result.error || 'Failed to read remote file'); } - return result.content; + return result.content || ''; } catch (error) { console.error('Error reading remote file:', error); throw error; @@ -112,6 +144,23 @@ export async function writeRemoteFile( try { const sanitizedConfig = sanitizeForIpc(sshConfig); + try { + const optimizedResult = await window.ipcRenderer.ssh.optimizedWrite( + sanitizedConfig, + filePath, + content + ); + + if (optimizedResult.success) { + return; + } + } catch (optimizedError) { + console.warn( + 'Optimized write failed, falling back to SFTP:', + optimizedError + ); + } + const result = await window.ipcRenderer.ssh.writeFile( sanitizedConfig, filePath, diff --git a/src/store/fileExplorer.ts b/src/store/fileExplorer.ts new file mode 100644 index 0000000..9963175 --- /dev/null +++ b/src/store/fileExplorer.ts @@ -0,0 +1,228 @@ +import { defineStore } from 'pinia'; +import { ref, reactive } from 'vue'; +import { optimizedSshService } from '@/services/optimized-ssh-service'; +import type { SshConnection } from '@/types/ssh-connection'; + +export interface FileEntry { + name: string; + type: 'file' | 'directory'; + size: number; + modTime: number; + permissions: string; + owner: string; + group: string; +} + +export interface DirectoryTreeNode { + name: string; + path: string; + type: 'directory' | 'file'; + expanded?: boolean; + children?: DirectoryTreeNode[]; +} + +export interface DirectoryNode { + name: string; + path: string; + isExpanded: boolean; + isLoading: boolean; + children: DirectoryNode[]; + files: FileEntry[]; + isLoaded: boolean; +} + +interface FileExplorerState { + currentPath: string; + projectRoot: string; + directoryTree: DirectoryNode[]; + expandedPaths: Set; + lastConnection: SshConnection | null; +} + +export const useFileExplorerStore = defineStore('fileExplorer', () => { + const state = reactive({ + currentPath: '', + projectRoot: '', + directoryTree: [], + expandedPaths: new Set(), + lastConnection: null + }); + + const isLoading = ref(false); + const error = ref(''); + + const getCurrentDirectoryFiles = () => { + const node = findNodeByPath(state.currentPath); + return node?.files || []; + }; + + const getDirectoryTree = (): DirectoryTreeNode[] => { + return state.directoryTree.map(convertToTreeNode); + }; + + function convertToTreeNode(node: DirectoryNode): DirectoryTreeNode { + let displayName = node.name; + if (node.path === state.projectRoot) { + displayName = 'root'; + } + + const allChildren: DirectoryTreeNode[] = [ + ...node.children.map(convertToTreeNode), + ...node.files + .filter((file) => file.type === 'file') + .map((file) => { + const filePath = node.path.endsWith('/') + ? `${node.path}${file.name}` + : `${node.path}/${file.name}`; + return { + name: file.name, + path: filePath, + type: 'file' as const, + expanded: undefined, + children: [] + }; + }) + ]; + + return { + name: displayName, + path: node.path, + type: 'directory', + expanded: node.isExpanded, + children: allChildren + }; + } + async function initializeForConnection( + connection: SshConnection, + rootPath: string + ) { + state.lastConnection = connection; + state.projectRoot = rootPath; + + if (!state.currentPath || !state.currentPath.startsWith(rootPath)) { + state.currentPath = rootPath; + } + + if (state.directoryTree.length === 0) { + await initializeRoot(); + } + } + + function setCurrentPath(path: string) { + state.currentPath = path; + } + + function addToExpandedPaths(path: string) { + state.expandedPaths.add(path); + } + + function removeFromExpandedPaths(path: string) { + state.expandedPaths.delete(path); + } + + function findNodeByPath(path: string): DirectoryNode | null { + function searchTree(nodes: DirectoryNode[]): DirectoryNode | null { + for (const node of nodes) { + if (node.path === path) { + return node; + } + const found = searchTree(node.children); + if (found) return found; + } + return null; + } + return searchTree(state.directoryTree); + } + + async function expandDirectory(path: string): Promise { + if (!state.lastConnection) return; + + const node = findNodeByPath(path); + if (!node) return; + + if (node.isLoaded && node.isExpanded) { + node.isExpanded = false; + removeFromExpandedPaths(path); + return; + } + + node.isLoading = true; + + try { + const files = await optimizedSshService.list( + state.lastConnection, + path + ); + + const directories = files.filter((f) => f.type === 'directory'); + const fileList = files.filter((f) => f.type === 'file'); + + node.children = directories.map((dir) => { + const childPath = path.endsWith('/') + ? `${path}${dir.name}` + : `${path}/${dir.name}`; + return { + name: dir.name, + path: childPath, + isExpanded: false, + isLoading: false, + children: [], + files: [], + isLoaded: false + }; + }); + + node.files = fileList; + node.isLoaded = true; + node.isExpanded = true; + addToExpandedPaths(path); + } catch (err) { + error.value = + (err as Error).message || 'Failed to expand directory'; + } finally { + node.isLoading = false; + } + } + + async function initializeRoot(): Promise { + if (!state.lastConnection || !state.projectRoot) return; + + if (state.directoryTree.length === 0) { + const rootNode: DirectoryNode = { + name: 'root', + path: state.projectRoot, + isExpanded: false, + isLoading: false, + children: [], + files: [], + isLoaded: false + }; + + state.directoryTree = [rootNode]; + } + + await expandDirectory(state.projectRoot); + } + + function reset() { + state.currentPath = ''; + state.projectRoot = ''; + state.directoryTree = []; + state.expandedPaths.clear(); + state.lastConnection = null; + isLoading.value = false; + error.value = ''; + } + + return { + state, + isLoading, + error, + getCurrentDirectoryFiles, + getDirectoryTree, + initializeForConnection, + setCurrentPath, + expandDirectory, + reset + }; +}); diff --git a/src/types/electron-api.d.ts b/src/types/electron-api.d.ts index 98697d7..0c9d731 100644 --- a/src/types/electron-api.d.ts +++ b/src/types/electron-api.d.ts @@ -81,6 +81,46 @@ interface IpcRendererAPI { error?: string; }>; closeTunnel: (tunnelId: string) => Promise<{ success: boolean }>; + + // Optimized SSH methods + optimizedList: ( + config: SshConnection, + dirPath: string + ) => Promise<{ + success: boolean; + files?: any[]; + error?: string; + }>; + optimizedRead: ( + config: SshConnection, + filePath: string, + length?: number + ) => Promise<{ + success: boolean; + content?: string; + error?: string; + }>; + optimizedWrite: ( + config: SshConnection, + filePath: string, + content: string + ) => Promise<{ + success: boolean; + error?: string; + }>; + optimizedExists: ( + config: SshConnection, + path: string + ) => Promise<{ + success: boolean; + exists: boolean; + error?: string; + }>; + connectionStats: () => Promise<{ + total: number; + inUse: number; + available: number; + }>; }; } diff --git a/src/types/file-explorer.d.ts b/src/types/file-explorer.d.ts new file mode 100644 index 0000000..569e2cd --- /dev/null +++ b/src/types/file-explorer.d.ts @@ -0,0 +1,17 @@ +export interface FileEntry { + name: string; + type: 'file' | 'directory'; + size: number; + modTime: number; + permissions: string; + owner: string; + group: string; +} + +export interface DirectoryTreeNode { + name: string; + path: string; + type: 'directory' | 'file'; + expanded?: boolean; + children?: DirectoryTreeNode[]; +} diff --git a/src/utils/monaco-config.ts b/src/utils/monaco-config.ts new file mode 100644 index 0000000..cdc07b3 --- /dev/null +++ b/src/utils/monaco-config.ts @@ -0,0 +1,62 @@ +let isConfigured = false; + +export function configureMonaco() { + if (isConfigured) return; + + (self as any).MonacoEnvironment = { + getWorker: function () { + const workerCode = ` + class MonacoWorker { + constructor() { + this.messageId = 0; + this.pendingRequests = new Map(); + } + + handleMessage(e) { + const { id, method, args } = e.data; + + try { + switch (method) { + case 'validate': + this.postMessage({ id, result: [] }); + break; + case 'format': + this.postMessage({ id, result: args[0] || '' }); + break; + case 'doHover': + this.postMessage({ id, result: null }); + break; + case 'provideCompletionItems': + this.postMessage({ id, result: { suggestions: [] } }); + break; + default: + this.postMessage({ id, result: null }); + } + } catch (error) { + this.postMessage({ id, error: error.message }); + } + } + + postMessage(message) { + self.postMessage(message); + } + } + + const worker = new MonacoWorker(); + + self.addEventListener('message', (e) => { + worker.handleMessage(e); + }); + + self.postMessage({ type: 'ready' }); + `; + + const blob = new Blob([workerCode], { + type: 'application/javascript' + }); + return new Worker(URL.createObjectURL(blob)); + } + }; + + isConfigured = true; +} diff --git a/vite.config.ts b/vite.config.ts index b4352aa..db49530 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -102,7 +102,13 @@ export default defineConfig(({ command }) => { } }, optimizeDeps: { - exclude: nativeNodeModules + exclude: nativeNodeModules, + include: ['monaco-editor'] + }, + define: { + 'process.env.NODE_ENV': JSON.stringify( + process.env.NODE_ENV || 'development' + ) } }; });