FIX: Resolve rapid clicking and navigation issues in file manager

Fixed race conditions and loading problems when users click folders
or navigation buttons too quickly.

## Problems Identified:

### 1. Race Conditions in Path Changes
**Issue**: Fast clicking folders/back button caused multiple simultaneous requests
- useEffect triggered on every currentPath change
- No debouncing for path changes (only for manual refresh)
- Multiple loadDirectory() calls executed concurrently
- Later responses could overwrite earlier ones

### 2. Concurrent Request Conflicts
**Issue**: loadDirectory() had basic isLoading check but insufficient protection
- Multiple requests could run if timing was right
- No tracking of which request was current
- Stale responses could update UI incorrectly

### 3. Missing Request Cancellation
**Issue**: No way to cancel outdated requests when user navigates rapidly
- Old requests would complete and show wrong directory
- Confusing UI state when mixed responses arrived

## Technical Solution:

### **Path Change Debouncing**
```typescript
// Added 150ms debounce specifically for path changes
const debouncedLoadDirectory = useCallback((path: string) => {
  if (pathChangeTimerRef.current) {
    clearTimeout(pathChangeTimerRef.current);
  }
  pathChangeTimerRef.current = setTimeout(() => {
    if (path !== lastPathChangeRef.current && sshSessionId) {
      loadDirectory(path);
    }
  }, 150);
}, [sshSessionId, loadDirectory]);
```

### **Request Race Condition Protection**
```typescript
// Track current loading path for proper cancellation
const currentLoadingPathRef = useRef<string>("");

// Enhanced concurrent request prevention
if (isLoading && currentLoadingPathRef.current !== path) {
  console.log("Directory loading already in progress, skipping:", path);
  return;
}
```

### **Stale Response Handling**
```typescript
// Check if response is still relevant before updating UI
if (currentLoadingPathRef.current !== path) {
  console.log("Directory load canceled, newer request in progress:", path);
  return; // Discard stale response
}
```

## Flow Improvements:

**Before (Problematic):**
1. User clicks folder A → currentPath changes → useEffect → loadDirectory(A)
2. User quickly clicks folder B → currentPath changes → useEffect → loadDirectory(B)
3. Both requests run concurrently
4. Response A or B arrives randomly, wrong folder might show

**After (Fixed):**
1. User clicks folder A → currentPath changes → debouncedLoadDirectory(A)
2. User quickly clicks folder B → currentPath changes → cancels A timer → debouncedLoadDirectory(B)
3. Only request B executes after 150ms
4. If A somehow runs, its response is discarded as stale

## User Experience:
✅ Rapid folder navigation works smoothly
✅ Back button rapid clicking handled properly
✅ No more loading wrong directories
✅ Proper loading states maintained
✅ No duplicate API requests
✅ Responsive feel with 150ms debounce (fast enough to feel instant)

The file manager now handles rapid user interactions gracefully without
race conditions or loading the wrong directory content.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
ZacharyZcRandClaude committed 2025-09-24 05:00:42 +08:00
1 parent ff1f3829bc
commit ff2bf474ee
1 file changed
+61 -8
@@ -153,6 +153,28 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
// Track if initial directory load is done to prevent duplicate loading // Track if initial directory load is done to prevent duplicate loading
const initialLoadDoneRef = useRef(false); const initialLoadDoneRef = useRef(false);
// Track last path change to prevent rapid navigation issues
const lastPathChangeRef = useRef<string>("");
const pathChangeTimerRef = useRef<NodeJS.Timeout | null>(null);
// Track current loading request to handle cancellation
const currentLoadingPathRef = useRef<string>("");
// Debounced directory loading for path changes
const debouncedLoadDirectory = useCallback((path: string) => {
// Clear any existing timer
if (pathChangeTimerRef.current) {
clearTimeout(pathChangeTimerRef.current);
}
// Set new timer for debounced loading
pathChangeTimerRef.current = setTimeout(() => {
if (path !== lastPathChangeRef.current && sshSessionId) {
console.log("Loading directory after path change:", path);
lastPathChangeRef.current = path;
loadDirectory(path);
}
}, 150); // 150ms debounce for path changes
}, [sshSessionId, loadDirectory]);
// File list update - only reload when path changes, not on initial connection // File list update - only reload when path changes, not on initial connection
useEffect(() => { useEffect(() => {
@@ -160,11 +182,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
// Skip the first load since it's handled in initializeSSHConnection // Skip the first load since it's handled in initializeSSHConnection
if (!initialLoadDoneRef.current) { if (!initialLoadDoneRef.current) {
initialLoadDoneRef.current = true; initialLoadDoneRef.current = true;
lastPathChangeRef.current = currentPath;
return; return;
} }
handleRefreshDirectory();
// Use debounced loading for path changes to prevent rapid clicking issues
debouncedLoadDirectory(currentPath);
} }
}, [sshSessionId, currentPath]);
// Cleanup timer on unmount or dependency change
return () => {
if (pathChangeTimerRef.current) {
clearTimeout(pathChangeTimerRef.current);
}
};
}, [sshSessionId, currentPath, debouncedLoadDirectory]);
// Handle file drag to external // Handle file drag to external
const handleFileDragStart = useCallback( const handleFileDragStart = useCallback(
@@ -261,6 +293,14 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
return; return;
} }
// Prevent concurrent loading requests
if (isLoading && currentLoadingPathRef.current !== path) {
console.log("Directory loading already in progress, skipping:", path);
return;
}
// Set current loading path for tracking
currentLoadingPathRef.current = path;
setIsLoading(true); setIsLoading(true);
try { try {
@@ -268,6 +308,12 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
const response = await listSSHFiles(sshSessionId, path); const response = await listSSHFiles(sshSessionId, path);
// Check if this is still the current request (avoid race conditions)
if (currentLoadingPathRef.current !== path) {
console.log("Directory load canceled, newer request in progress:", path);
return;
}
console.log("Directory response received:", response); console.log("Directory response received:", response);
const files = Array.isArray(response) ? response : response?.files || []; const files = Array.isArray(response) ? response : response?.files || [];
@@ -277,14 +323,21 @@ function FileManagerContent({ initialHost, onClose }: FileManagerModernProps) {
setFiles(files); setFiles(files);
clearSelection(); clearSelection();
} catch (error: any) { } catch (error: any) {
console.error("Failed to load directory:", error); // Only show error if this is still the current request
toast.error( if (currentLoadingPathRef.current === path) {
t("fileManager.failedToLoadDirectory") + ": " + (error.message || error) console.error("Failed to load directory:", error);
); toast.error(
t("fileManager.failedToLoadDirectory") + ": " + (error.message || error)
);
}
} finally { } finally {
setIsLoading(false); // Only clear loading if this is still the current request
if (currentLoadingPathRef.current === path) {
setIsLoading(false);
currentLoadingPathRef.current = "";
}
} }
}, [sshSessionId, clearSelection, t]); }, [sshSessionId, isLoading, clearSelection, t]);
// Debounced refresh function - prevent excessive clicking // Debounced refresh function - prevent excessive clicking
const handleRefreshDirectory = useCallback(() => { const handleRefreshDirectory = useCallback(() => {