mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
## 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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user