## CHANGES

- SSH directory stats now include remote file and folder counts 
- New incremental remote scanning finds changed files without full walks 🚀
- Baseline remote file listing added for faster subsequent refreshes 🧭
- File tree loading now streams live progress for slow SSH sessions 🛰️
- Fresh loading UI shows spinner, counters, and active scanned folder 
- Sessions track file-tree loading state, progress, and last scan time 🧾
- New instance creation validates remote project path before enabling submit 🛡️
- Edit agent modal now verifies remote directory exists before saving 🔍
- Mind map nodes now label using filename (without .md) for clarity 🏷️
- Highlighted graph connections are now bolder for better visibility 🎯
This commit is contained in:
Pedram Amini
2026-01-06 08:27:40 -06:00
parent 5c81416880
commit 7db629f8aa
9 changed files with 571 additions and 43 deletions

View File

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

View File

@@ -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<RemoteFsResult<IncrementalScanResult>> {
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<RemoteFsResult<string[]>> {
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,
};
}

View File

@@ -1471,10 +1471,6 @@ export function DocumentGraphView({
)}
</div>
)}
<span style={{ opacity: 0.7 }}>
Click to select Double-click or Enter to focus O to open Arrow keys to navigate Esc to close
</span>
</div>
</div>

View File

@@ -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,

View File

@@ -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 (
<div className="flex flex-col items-center justify-center gap-3 py-8">
{/* Animated spinner */}
<Loader2
className="w-6 h-6 animate-spin"
style={{ color: theme.colors.accent }}
/>
{/* Status text */}
<div className="text-center">
<div className="text-xs" style={{ color: theme.colors.textMain }}>
{isRemote ? 'Loading remote files...' : 'Loading files...'}
</div>
{/* Progress counters - shown when we have progress data */}
{progress && (progress.directoriesScanned > 0 || progress.filesFound > 0) && (
<div
className="text-xs mt-2 font-mono"
style={{ color: theme.colors.textDim }}
>
<span style={{ color: theme.colors.accent }}>{progress.filesFound.toLocaleString()}</span>
{' files in '}
<span style={{ color: theme.colors.accent }}>{progress.directoriesScanned.toLocaleString()}</span>
{' folders'}
</div>
)}
{/* Current directory being scanned - truncated */}
{currentFolder && (
<div
className="text-[10px] mt-1.5 max-w-[200px] truncate opacity-60"
style={{ color: theme.colors.textDim }}
title={progress?.currentDirectory}
>
scanning: {currentFolder}/
</div>
)}
</div>
</div>
);
}
// Auto-refresh interval options in seconds
const AUTO_REFRESH_OPTIONS = [
{ label: 'Every 5 seconds', value: 5 },
@@ -878,8 +942,13 @@ function FileExplorerPanelInner(props: FileExplorerPanelProps) {
</div>
) : (
<>
{(!session.fileTree || session.fileTree.length === 0) && (
<div className="text-xs opacity-50 italic">Loading files...</div>
{/* Show loading progress when file tree is loading */}
{(session.fileTreeLoading || (!session.fileTree || session.fileTree.length === 0)) && (
<FileTreeLoadingProgress
theme={theme}
progress={session.fileTreeLoadingProgress}
isRemote={!!(session.sshRemoteId || session.sessionSshRemoteConfig?.enabled)}
/>
)}
{flattenedTree.length > 0 && (
<div

View File

@@ -88,6 +88,13 @@ export function NewInstanceModal({ isOpen, onClose, onCreate, theme, existingSes
// SSH Remote configuration
const [sshRemotes, setSshRemotes] = useState<SshRemoteConfig[]>([]);
const [agentSshRemoteConfigs, setAgentSshRemoteConfigs] = useState<Record<string, AgentSshRemoteConfig>>({});
// 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<HTMLInputElement>(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() && (
<div className="mt-2 text-xs flex items-center gap-1.5">
{remotePathValidation.checking ? (
<>
<div
className="w-3 h-3 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: theme.colors.textDim, borderTopColor: 'transparent' }}
/>
<span style={{ color: theme.colors.textDim }}>Checking remote path...</span>
</>
) : remotePathValidation.valid ? (
<>
<Check className="w-3.5 h-3.5" style={{ color: theme.colors.success }} />
<span style={{ color: theme.colors.success }}>Remote directory found</span>
</>
) : remotePathValidation.error ? (
<>
<X className="w-3.5 h-3.5" style={{ color: theme.colors.error }} />
<span style={{ color: theme.colors.error }}>{remotePathValidation.error}</span>
</>
) : null}
</div>
)}
{/* Directory Warning with Acknowledgment */}
{validation.warning && validation.warningField === 'directory' && (
<div
@@ -887,6 +984,13 @@ export function EditAgentModal({ isOpen, onClose, onSave, theme, session, existi
// SSH Remote configuration
const [sshRemotes, setSshRemotes] = useState<SshRemoteConfig[]>([]);
const [sshRemoteConfig, setSshRemoteConfig] = useState<AgentSshRemoteConfig | undefined>(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<HTMLInputElement>(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
<p className="mt-1 text-xs" style={{ color: theme.colors.textDim }}>
Directory cannot be changed. Create a new agent for a different directory.
</p>
{/* Remote path validation status (only shown when SSH is enabled) */}
{isSshEnabled && (
<div className="mt-2 text-xs flex items-center gap-1.5">
{remotePathValidation.checking ? (
<>
<div
className="w-3 h-3 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: theme.colors.textDim, borderTopColor: 'transparent' }}
/>
<span style={{ color: theme.colors.textDim }}>
Checking path on {sshRemoteHost || 'remote'}...
</span>
</>
) : remotePathValidation.valid ? (
<>
<Check className="w-3.5 h-3.5" style={{ color: theme.colors.success }} />
<span style={{ color: theme.colors.success }}>
Directory found on {sshRemoteHost || 'remote'}
</span>
</>
) : remotePathValidation.error ? (
<>
<X className="w-3.5 h-3.5" style={{ color: theme.colors.error }} />
<span style={{ color: theme.colors.error }}>
{remotePathValidation.error}{sshRemoteHost ? ` (${sshRemoteHost})` : ''}
</span>
</>
) : null}
</div>
)}
</div>
{/* Nudge Message */}

View File

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

View File

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

View File

@@ -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<FileTreeNode[]> {
// 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<FileTreeNode[]> {
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,
});
}
}
}