diff --git a/src/main/index.ts b/src/main/index.ts index 0f7b41af..587aa4ed 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1325,16 +1325,18 @@ function setupIpcHandlers() { if (!sshConfig) { throw new Error(`SSH remote not found: ${sshRemoteId}`); } - const result = await directorySizeRemote(dirPath, sshConfig); - if (!result.success) { - throw new Error(result.error || 'Failed to get remote directory size'); + // Fetch size and counts in parallel for SSH remotes + const [sizeResult, countResult] = await Promise.all([ + directorySizeRemote(dirPath, sshConfig), + countItemsRemote(dirPath, sshConfig), + ]); + if (!sizeResult.success) { + throw new Error(sizeResult.error || 'Failed to get remote directory size'); } - // Remote directorySizeRemote only returns totalSize (via du -sb) - // File/folder counts are not available without recursive listing return { - totalSize: result.data!, - fileCount: 0, // Not available from remote du command - folderCount: 0, // Not available from remote du command + totalSize: sizeResult.data!, + fileCount: countResult.success ? countResult.data!.fileCount : 0, + folderCount: countResult.success ? countResult.data!.folderCount : 0, }; } diff --git a/src/main/utils/remote-fs.ts b/src/main/utils/remote-fs.ts index d85f57b1..715505df 100644 --- a/src/main/utils/remote-fs.ts +++ b/src/main/utils/remote-fs.ts @@ -696,3 +696,133 @@ export async function countItemsRemote( data: { fileCount, folderCount }, }; } + +/** + * Result of an incremental file scan showing changes since last check. + */ +export interface IncrementalScanResult { + /** Files added or modified since the reference time */ + added: string[]; + /** Files deleted since the reference time (requires full paths from previous scan) */ + deleted: string[]; + /** Whether any changes were detected */ + hasChanges: boolean; + /** Timestamp of this scan (use for next incremental scan) */ + scanTime: number; +} + +/** + * Perform an incremental scan to find files changed since a reference time. + * Uses `find -newer` with a temporary marker file for efficient delta detection. + * + * This is much faster than a full directory walk for large remote filesystems, + * especially over slow SSH connections. On subsequent refreshes, only files + * modified since the last scan are returned. + * + * Note: This cannot detect deletions directly. For deletion detection, the caller + * should compare the returned paths against the previous file list. + * + * @param dirPath Directory to scan + * @param sshRemote SSH remote configuration + * @param sinceTimestamp Unix timestamp (seconds) to find changes after + * @param deps Optional dependencies for testing + * @returns List of changed file paths (relative to dirPath) + */ +export async function incrementalScanRemote( + dirPath: string, + sshRemote: SshRemoteConfig, + sinceTimestamp: number, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(dirPath); + const scanTime = Math.floor(Date.now() / 1000); + + // Use find with -newermt to find files modified after the given timestamp + // -newermt accepts a date string in ISO format + // We exclude common patterns like node_modules and __pycache__ + const isoDate = new Date(sinceTimestamp * 1000).toISOString(); + const remoteCommand = `find ${escapedPath} -newermt "${isoDate}" -type f \\( ! -path "*/node_modules/*" ! -path "*/__pycache__/*" \\) 2>/dev/null || true`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + // find returns exit code 0 even with no matches, errors go to stderr + if (result.exitCode !== 0 && result.stderr) { + return { + success: false, + error: result.stderr, + }; + } + + // Parse the output - each line is a full path + const lines = result.stdout.trim().split('\n').filter(Boolean); + + // Convert to paths relative to dirPath + const added = lines + .map((line) => { + // Remove the dirPath prefix to get relative path + if (line.startsWith(dirPath)) { + return line.substring(dirPath.length).replace(/^\//, ''); + } + return line; + }) + .filter(Boolean); + + return { + success: true, + data: { + added, + deleted: [], // Caller must detect deletions by comparing with previous state + hasChanges: added.length > 0, + scanTime, + }, + }; +} + +/** + * Get all file paths in a directory (for establishing baseline for incremental scans). + * Uses find to list all files, which is faster than recursive readDir for large trees. + * + * @param dirPath Directory to scan + * @param sshRemote SSH remote configuration + * @param maxDepth Maximum depth to scan (default: 10) + * @param deps Optional dependencies for testing + * @returns List of all file paths (relative to dirPath) + */ +export async function listAllFilesRemote( + dirPath: string, + sshRemote: SshRemoteConfig, + maxDepth: number = 10, + deps: RemoteFsDeps = defaultDeps +): Promise> { + const escapedPath = shellEscape(dirPath); + + // Use find with -maxdepth to list all files + // Exclude node_modules and __pycache__ + const remoteCommand = `find ${escapedPath} -maxdepth ${maxDepth} -type f \\( ! -path "*/node_modules/*" ! -path "*/__pycache__/*" \\) 2>/dev/null || true`; + + const result = await execRemoteCommand(sshRemote, remoteCommand, deps); + + if (result.exitCode !== 0 && result.stderr) { + return { + success: false, + error: result.stderr, + }; + } + + const lines = result.stdout.trim().split('\n').filter(Boolean); + + // Convert to paths relative to dirPath + const files = lines + .map((line) => { + if (line.startsWith(dirPath)) { + return line.substring(dirPath.length).replace(/^\//, ''); + } + return line; + }) + .filter(Boolean); + + return { + success: true, + data: files, + }; +} diff --git a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx index 5b4d7e90..e6d76978 100644 --- a/src/renderer/components/DocumentGraph/DocumentGraphView.tsx +++ b/src/renderer/components/DocumentGraph/DocumentGraphView.tsx @@ -1471,10 +1471,6 @@ export function DocumentGraphView({ )} )} - - - Click to select • Double-click or Enter to focus • O to open • Arrow keys to navigate • Esc to close - diff --git a/src/renderer/components/DocumentGraph/MindMap.tsx b/src/renderer/components/DocumentGraph/MindMap.tsx index 51fb15b8..bc67989f 100644 --- a/src/renderer/components/DocumentGraph/MindMap.tsx +++ b/src/renderer/components/DocumentGraph/MindMap.tsx @@ -1124,7 +1124,7 @@ export function MindMap({ ? `${theme.colors.textDim}44` : `${theme.colors.textDim}66`; - const lineWidth = isHighlighted ? 2 : 1.5; + const lineWidth = isHighlighted ? 3 : 1.5; // Calculate connection points based on node positions let sourceX = sourceNode.x; @@ -1576,6 +1576,8 @@ export function convertToMindMapData( const docData = node.data as DocumentNodeData; // Use description (frontmatter) or contentPreview (plaintext) for display const previewText = docData.description || docData.contentPreview; + // Extract filename without extension for the label (node header) + const filename = docData.filePath?.split('/').pop()?.replace(/\.md$/i, '') || docData.title; mindMapNode = { id: node.id, x: 0, @@ -1585,7 +1587,7 @@ export function convertToMindMapData( depth: 0, side: 'center' as const, nodeType: 'document' as const, - label: docData.title, + label: filename, filePath: docData.filePath, description: docData.description, contentPreview: docData.contentPreview, diff --git a/src/renderer/components/FileExplorerPanel.tsx b/src/renderer/components/FileExplorerPanel.tsx index 183bb54a..76ee493d 100644 --- a/src/renderer/components/FileExplorerPanel.tsx +++ b/src/renderer/components/FileExplorerPanel.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState, useCallback, useMemo, memo } from 'react'; import { createPortal } from 'react-dom'; import { useVirtualizer } from '@tanstack/react-virtual'; -import { ChevronRight, ChevronDown, ChevronUp, Folder, RefreshCw, Check, Eye, EyeOff, Target, Copy, ExternalLink, Server, GitBranch, Clock, RotateCw, FileText, Edit2, Trash2, AlertTriangle } from 'lucide-react'; +import { ChevronRight, ChevronDown, ChevronUp, Folder, RefreshCw, Check, Eye, EyeOff, Target, Copy, ExternalLink, Server, GitBranch, Clock, RotateCw, FileText, Edit2, Trash2, AlertTriangle, Loader2 } from 'lucide-react'; import type { Session, Theme, FocusArea } from '../types'; import type { FileNode } from '../types/fileTree'; import type { FileTreeChanges } from '../utils/fileExplorer'; @@ -55,6 +55,70 @@ function RetryCountdown({ ); } +/** + * FileTreeLoadingProgress component - shows streaming progress during file tree load. + * Particularly useful for slow SSH connections where the full tree walk can take time. + */ +function FileTreeLoadingProgress({ + theme, + progress, + isRemote, +}: { + theme: Theme; + progress?: { + directoriesScanned: number; + filesFound: number; + currentDirectory: string; + }; + isRemote: boolean; +}) { + // Extract just the folder name from the full path for display + const currentFolder = progress?.currentDirectory + ? progress.currentDirectory.split('/').pop() || progress.currentDirectory + : ''; + + return ( +
+ {/* Animated spinner */} + + + {/* Status text */} +
+
+ {isRemote ? 'Loading remote files...' : 'Loading files...'} +
+ + {/* Progress counters - shown when we have progress data */} + {progress && (progress.directoriesScanned > 0 || progress.filesFound > 0) && ( +
+ {progress.filesFound.toLocaleString()} + {' files in '} + {progress.directoriesScanned.toLocaleString()} + {' folders'} +
+ )} + + {/* Current directory being scanned - truncated */} + {currentFolder && ( +
+ scanning: {currentFolder}/ +
+ )} +
+
+ ); +} + // Auto-refresh interval options in seconds const AUTO_REFRESH_OPTIONS = [ { label: 'Every 5 seconds', value: 5 }, @@ -878,8 +942,13 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) { ) : ( <> - {(!session.fileTree || session.fileTree.length === 0) && ( -
Loading files...
+ {/* Show loading progress when file tree is loading */} + {(session.fileTreeLoading || (!session.fileTree || session.fileTree.length === 0)) && ( + )} {flattenedTree.length > 0 && (
([]); const [agentSshRemoteConfigs, setAgentSshRemoteConfigs] = useState>({}); + // Remote path validation state (only used when SSH is enabled) + const [remotePathValidation, setRemotePathValidation] = useState<{ + checking: boolean; + valid: boolean; + isDirectory: boolean; + error?: string; + }>({ checking: false, valid: false, isDirectory: false }); const nameInputRef = useRef(null); @@ -119,6 +126,84 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes setDirectoryWarningAcknowledged(false); }, [workingDir]); + // Check if SSH remote is enabled for the selected agent (moved up for use in validation effect) + const isSshEnabled = useMemo(() => { + if (!selectedAgent) return false; + const config = agentSshRemoteConfigs[selectedAgent]; + return config?.enabled && !!config?.remoteId; + }, [selectedAgent, agentSshRemoteConfigs]); + + // Get SSH remote host for display (moved up for use in validation) + const sshRemoteHost = useMemo(() => { + if (!isSshEnabled || !selectedAgent) return undefined; + const config = agentSshRemoteConfigs[selectedAgent]; + if (!config?.remoteId) return undefined; + const remote = sshRemotes.find(r => r.id === config.remoteId); + return remote?.host; + }, [isSshEnabled, selectedAgent, agentSshRemoteConfigs, sshRemotes]); + + // Validate remote path when SSH is enabled (debounced) + useEffect(() => { + // Only validate when SSH is enabled + if (!isSshEnabled) { + setRemotePathValidation({ checking: false, valid: false, isDirectory: false }); + return; + } + + const trimmedPath = workingDir.trim(); + if (!trimmedPath) { + setRemotePathValidation({ checking: false, valid: false, isDirectory: false }); + return; + } + + // Get the SSH remote ID for this agent + const config = agentSshRemoteConfigs[selectedAgent] || agentSshRemoteConfigs['_pending_']; + const sshRemoteId = config?.remoteId; + if (!sshRemoteId) { + setRemotePathValidation({ checking: false, valid: false, isDirectory: false }); + return; + } + + // Debounce the validation + const timeoutId = setTimeout(async () => { + setRemotePathValidation(prev => ({ ...prev, checking: true })); + + try { + const stat = await window.maestro.fs.stat(trimmedPath, sshRemoteId); + if (stat && stat.isDirectory) { + setRemotePathValidation({ + checking: false, + valid: true, + isDirectory: true, + }); + } else if (stat && stat.isFile) { + setRemotePathValidation({ + checking: false, + valid: false, + isDirectory: false, + error: 'Path is a file, not a directory', + }); + } else { + setRemotePathValidation({ + checking: false, + valid: false, + isDirectory: false, + error: 'Path not found or not accessible', + }); + } + } catch { + setRemotePathValidation({ + checking: false, + valid: false, + isDirectory: false, + error: 'Path not found or not accessible', + }); + } + }, 300); + + return () => clearTimeout(timeoutId); + }, [workingDir, isSshEnabled, selectedAgent, agentSshRemoteConfigs]); + // Define handlers first before they're used in effects const loadAgents = async (source?: Session) => { setLoading(true); @@ -335,22 +420,6 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes }); }, [instanceName, selectedAgent, workingDir, nudgeMessage, customAgentPaths, customAgentArgs, customAgentEnvVars, agentConfigs, agentSshRemoteConfigs, onCreate, onClose, expandTilde, existingSessions]); - // Check if SSH remote is enabled for the selected agent - const isSshEnabled = useMemo(() => { - if (!selectedAgent) return false; - const config = agentSshRemoteConfigs[selectedAgent]; - return config?.enabled && !!config?.remoteId; - }, [selectedAgent, agentSshRemoteConfigs]); - - // Get SSH remote host for display - const sshRemoteHost = useMemo(() => { - if (!isSshEnabled || !selectedAgent) return undefined; - const config = agentSshRemoteConfigs[selectedAgent]; - if (!config?.remoteId) return undefined; - const remote = sshRemotes.find(r => r.id === config.remoteId); - return remote?.host; - }, [isSshEnabled, selectedAgent, agentSshRemoteConfigs, sshRemotes]); - // Check if form is valid for submission const isFormValid = useMemo(() => { const hasWarningThatNeedsAck = validation.warning && !directoryWarningAcknowledged; @@ -360,13 +429,16 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes // 2. User specified a custom path for it const hasCustomPath = customAgentPaths[selectedAgent]?.trim(); const isAgentUsable = agent?.available || !!hasCustomPath; + // For SSH sessions, require remote path validation to succeed + const remotePathOk = !isSshEnabled || remotePathValidation.valid; return selectedAgent && isAgentUsable && workingDir.trim() && instanceName.trim() && validation.valid && - !hasWarningThatNeedsAck; - }, [selectedAgent, agents, workingDir, instanceName, validation.valid, validation.warning, directoryWarningAcknowledged, customAgentPaths]); + !hasWarningThatNeedsAck && + remotePathOk; + }, [selectedAgent, agents, workingDir, instanceName, validation.valid, validation.warning, directoryWarningAcknowledged, customAgentPaths, isSshEnabled, remotePathValidation.valid]); // Handle keyboard shortcuts const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -776,6 +848,31 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes } /> + {/* Remote path validation status (only shown when SSH is enabled) */} + {isSshEnabled && workingDir.trim() && ( +
+ {remotePathValidation.checking ? ( + <> +
+ Checking remote path... + + ) : remotePathValidation.valid ? ( + <> + + Remote directory found + + ) : remotePathValidation.error ? ( + <> + + {remotePathValidation.error} + + ) : null} +
+ )} + {/* Directory Warning with Acknowledgment */} {validation.warning && validation.warningField === 'directory' && (
([]); const [sshRemoteConfig, setSshRemoteConfig] = useState(undefined); + // Remote path validation state (validates projectRoot exists on remote when SSH enabled) + const [remotePathValidation, setRemotePathValidation] = useState<{ + checking: boolean; + valid: boolean; + isDirectory: boolean; + error?: string; + }>({ checking: false, valid: false, isDirectory: false }); const nameInputRef = useRef(null); @@ -974,6 +1078,80 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi return validateEditSession(name, session.id, existingSessions); }, [instanceName, session, existingSessions]); + // Check if SSH remote is enabled + const isSshEnabled = useMemo(() => { + return sshRemoteConfig?.enabled && !!sshRemoteConfig?.remoteId; + }, [sshRemoteConfig]); + + // Get SSH remote host for display + const sshRemoteHost = useMemo(() => { + if (!isSshEnabled) return undefined; + const remoteId = sshRemoteConfig?.remoteId; + if (!remoteId) return undefined; + const remote = sshRemotes.find(r => r.id === remoteId); + return remote?.host; + }, [isSshEnabled, sshRemoteConfig?.remoteId, sshRemotes]); + + // Validate remote path when SSH is enabled (debounced) + useEffect(() => { + // Only validate when SSH is enabled and we have a session + if (!isSshEnabled || !session) { + setRemotePathValidation({ checking: false, valid: false, isDirectory: false }); + return; + } + + const projectRoot = session.projectRoot; + if (!projectRoot) { + setRemotePathValidation({ checking: false, valid: false, isDirectory: false }); + return; + } + + const sshRemoteId = sshRemoteConfig?.remoteId; + if (!sshRemoteId) { + setRemotePathValidation({ checking: false, valid: false, isDirectory: false }); + return; + } + + // Debounce the validation (useful when user is switching remotes) + const timeoutId = setTimeout(async () => { + setRemotePathValidation(prev => ({ ...prev, checking: true })); + + try { + const stat = await window.maestro.fs.stat(projectRoot, sshRemoteId); + if (stat && stat.isDirectory) { + setRemotePathValidation({ + checking: false, + valid: true, + isDirectory: true, + }); + } else if (stat && stat.isFile) { + setRemotePathValidation({ + checking: false, + valid: false, + isDirectory: false, + error: 'Path is a file, not a directory', + }); + } else { + setRemotePathValidation({ + checking: false, + valid: false, + isDirectory: false, + error: 'Path not found on remote', + }); + } + } catch { + setRemotePathValidation({ + checking: false, + valid: false, + isDirectory: false, + error: 'Path not found on remote', + }); + } + }, 300); + + return () => clearTimeout(timeoutId); + }, [isSshEnabled, session, sshRemoteConfig?.remoteId]); + const handleSave = useCallback(() => { if (!session) return; const name = instanceName.trim(); @@ -1045,8 +1223,10 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi // Check if form is valid for submission const isFormValid = useMemo(() => { - return instanceName.trim() && validation.valid; - }, [instanceName, validation.valid]); + // For SSH sessions, require remote path validation to succeed + const remotePathOk = !isSshEnabled || remotePathValidation.valid; + return instanceName.trim() && validation.valid && remotePathOk; + }, [instanceName, validation.valid, isSshEnabled, remotePathValidation.valid]); // Handle keyboard shortcuts const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -1182,6 +1362,36 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi

Directory cannot be changed. Create a new agent for a different directory.

+ {/* Remote path validation status (only shown when SSH is enabled) */} + {isSshEnabled && ( +
+ {remotePathValidation.checking ? ( + <> +
+ + Checking path on {sshRemoteHost || 'remote'}... + + + ) : remotePathValidation.valid ? ( + <> + + + Directory found on {sshRemoteHost || 'remote'} + + + ) : remotePathValidation.error ? ( + <> + + + {remotePathValidation.error}{sshRemoteHost ? ` (${sshRemoteHost})` : ''} + + + ) : null} +
+ )}
{/* Nudge Message */} diff --git a/src/renderer/hooks/git/useFileTreeManagement.ts b/src/renderer/hooks/git/useFileTreeManagement.ts index 656f0f0e..2dc22856 100644 --- a/src/renderer/hooks/git/useFileTreeManagement.ts +++ b/src/renderer/hooks/git/useFileTreeManagement.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import type { RightPanelHandle } from '../../components/RightPanel'; import type { Session } from '../../types'; import type { FileNode } from '../../types/fileTree'; -import { loadFileTree, compareFileTrees, type FileTreeChanges, type SshContext } from '../../utils/fileExplorer'; +import { loadFileTree, compareFileTrees, type FileTreeChanges, type SshContext, type FileTreeProgress } from '../../utils/fileExplorer'; import { fuzzyMatch } from '../../utils/search'; import { gitService } from '../../services/git'; @@ -227,13 +227,14 @@ export function useFileTreeManagement( * Load file tree when active session changes. * Only loads if file tree is empty AND not in error backoff period. * Passes SSH context for remote sessions to enable remote operations (Phase 2+). + * Shows streaming progress updates during loading (useful for slow SSH connections). */ useEffect(() => { const session = sessions.find(s => s.id === activeSessionId); if (!session) return; - // Only load if file tree is empty - if (!session.fileTree || session.fileTree.length === 0) { + // Only load if file tree is empty and not already loading + if ((!session.fileTree || session.fileTree.length === 0) && !session.fileTreeLoading) { // Check if we're in a retry backoff period if (session.fileTreeRetryAt && Date.now() < session.fileTreeRetryAt) { // Schedule retry when backoff expires (if not already scheduled) @@ -257,8 +258,36 @@ export function useFileTreeManagement( // Use projectRoot for file tree (consistent with Files tab header) const treeRoot = session.projectRoot || session.cwd; + // Mark as loading before starting + setSessions(prev => prev.map(s => + s.id === activeSessionId ? { + ...s, + fileTreeLoading: true, + fileTreeLoadingProgress: undefined + } : s + )); + + // Progress callback for streaming updates during SSH load + const onProgress = (progress: FileTreeProgress) => { + setSessions(prev => prev.map(s => + s.id === activeSessionId ? { + ...s, + fileTreeLoadingProgress: { + directoriesScanned: progress.directoriesScanned, + filesFound: progress.filesFound, + currentDirectory: progress.currentDirectory, + } + } : s + )); + }; + + // Load tree with progress callback for SSH sessions + const treePromise = sshContext + ? loadFileTree(treeRoot, 10, 0, sshContext, onProgress) + : loadFileTree(treeRoot, 10, 0, sshContext); + Promise.all([ - loadFileTree(treeRoot, 10, 0, sshContext), + treePromise, window.maestro.fs.directorySize(treeRoot, sshContext?.sshRemoteId) ]).then(([tree, stats]) => { setSessions(prev => prev.map(s => @@ -267,6 +296,8 @@ export function useFileTreeManagement( fileTree: tree, fileTreeError: undefined, fileTreeRetryAt: undefined, + fileTreeLoading: false, + fileTreeLoadingProgress: undefined, fileTreeStats: { fileCount: stats.fileCount, folderCount: stats.folderCount, @@ -283,6 +314,8 @@ export function useFileTreeManagement( fileTree: [], fileTreeError: `Cannot access directory: ${treeRoot}\n${errorMsg}`, fileTreeRetryAt: Date.now() + FILE_TREE_RETRY_DELAY_MS, + fileTreeLoading: false, + fileTreeLoadingProgress: undefined, fileTreeStats: undefined } : s )); diff --git a/src/renderer/types/index.ts b/src/renderer/types/index.ts index fc072f79..7c2328d3 100644 --- a/src/renderer/types/index.ts +++ b/src/renderer/types/index.ts @@ -489,6 +489,16 @@ export interface Session { folderCount: number; totalSize: number; }; + /** Loading progress for file tree (shown during slow SSH connections) */ + fileTreeLoadingProgress?: { + directoriesScanned: number; + filesFound: number; + currentDirectory: string; + }; + /** Whether file tree is currently loading (true = initial load, false = loaded or error) */ + fileTreeLoading?: boolean; + /** Unix timestamp (seconds) of last successful file tree scan - used for incremental refresh */ + fileTreeLastScanTime?: number; // Shell state tracking shellCwd?: string; // Command history (separate for each mode) diff --git a/src/renderer/utils/fileExplorer.ts b/src/renderer/utils/fileExplorer.ts index 4224acd8..70161ced 100644 --- a/src/renderer/utils/fileExplorer.ts +++ b/src/renderer/utils/fileExplorer.ts @@ -60,18 +60,66 @@ export interface SshContext { remoteCwd?: string; } +/** + * Progress callback for streaming file tree loading updates. + * Provides real-time feedback during slow SSH directory walks. + */ +export interface FileTreeProgress { + /** Total directories scanned so far */ + directoriesScanned: number; + /** Total files found so far */ + filesFound: number; + /** Current directory being scanned */ + currentDirectory: string; + /** Partial tree built so far (can be used for progressive display) */ + partialTree?: FileTreeNode[]; +} + +export type FileTreeProgressCallback = (progress: FileTreeProgress) => void; + +/** + * Internal state for tracking progress during recursive file tree loading. + */ +interface LoadingState { + directoriesScanned: number; + filesFound: number; + onProgress?: FileTreeProgressCallback; +} + /** * Load file tree from directory recursively * @param dirPath - The directory path to load * @param maxDepth - Maximum recursion depth (default: 10) * @param currentDepth - Current recursion depth (internal use) * @param sshContext - Optional SSH context for remote file operations + * @param onProgress - Optional callback for progress updates (useful for SSH) */ export async function loadFileTree( dirPath: string, maxDepth = 10, currentDepth = 0, - sshContext?: SshContext + sshContext?: SshContext, + onProgress?: FileTreeProgressCallback +): Promise { + // Initialize loading state at the top level + const state: LoadingState = { + directoriesScanned: 0, + filesFound: 0, + onProgress, + }; + + return loadFileTreeRecursive(dirPath, maxDepth, currentDepth, sshContext, state); +} + +/** + * Internal recursive implementation with shared state for progress tracking. + */ +async function loadFileTreeRecursive( + dirPath: string, + maxDepth: number, + currentDepth: number, + sshContext: SshContext | undefined, + state: LoadingState ): Promise { if (currentDepth >= maxDepth) return []; @@ -79,6 +127,18 @@ export async function loadFileTree( const entries = await window.maestro.fs.readDir(dirPath, sshContext?.sshRemoteId); const tree: FileTreeNode[] = []; + // Update progress: we've scanned a directory + state.directoriesScanned++; + + // Report progress with current directory being scanned + if (state.onProgress) { + state.onProgress({ + directoriesScanned: state.directoriesScanned, + filesFound: state.filesFound, + currentDirectory: dirPath, + }); + } + for (const entry of entries) { // Skip common ignore patterns (but allow hidden files/directories starting with .) if (entry.name === 'node_modules' || entry.name === '__pycache__') { @@ -86,17 +146,33 @@ export async function loadFileTree( } if (entry.isDirectory) { - const children = await loadFileTree(`${dirPath}/${entry.name}`, maxDepth, currentDepth + 1, sshContext); + const children = await loadFileTreeRecursive( + `${dirPath}/${entry.name}`, + maxDepth, + currentDepth + 1, + sshContext, + state + ); tree.push({ name: entry.name, type: 'folder', children }); } else if (entry.isFile) { + state.filesFound++; tree.push({ name: entry.name, type: 'file' }); + + // Report progress periodically for files (every 10 files to avoid too many updates) + if (state.onProgress && state.filesFound % 10 === 0) { + state.onProgress({ + directoriesScanned: state.directoriesScanned, + filesFound: state.filesFound, + currentDirectory: dirPath, + }); + } } }