Refine: large dir loading performance

This commit is contained in:
aleksey-hoffman committed 2026-07-17 00:49:33 +03:00
1 parent 165b342c67
commit db15621b06
8 files changed
+542 -207

No files matched your search

+7 -2
View File
@@ -14,8 +14,13 @@ use super::types::{
};
#[tauri::command]
pub fn read_dir(path: String, options: Option<ReadDirOptions>) -> Result<DirContents, String> {
read::read_dir(path, options)
pub async fn read_dir(
path: String,
options: Option<ReadDirOptions>,
) -> Result<DirContents, String> {
tauri::async_runtime::spawn_blocking(move || read::read_dir(path, options))
.await
.map_err(|join_error| format!("Failed to read directory: {join_error}"))?
}
#[tauri::command]
+92 -51
View File
@@ -4,8 +4,9 @@
use super::blocking_timeout::{with_blocking_timeout, BlockingTimeoutError};
use crate::utils::{
is_hidden_path, metadata_times_unix_ms, normalize_path, path_extension_lowercase,
is_hidden_from_metadata, metadata_times_unix_ms, normalize_path, path_extension_lowercase,
};
use rayon::prelude::*;
use serde::Deserialize;
use std::collections::HashSet;
use std::fs;
@@ -29,7 +30,7 @@ impl Default for ReadEntryOptions {
Self {
include_shortcut_targets: false,
include_hard_link_counts: false,
include_item_counts: true,
include_item_counts: false,
include_hidden: true,
}
}
@@ -69,7 +70,7 @@ impl From<Option<ReadDirOptions>> for ReadEntryOptions {
Self {
include_shortcut_targets: options.include_shortcut_targets,
include_hard_link_counts: options.include_hard_link_counts,
include_item_counts: options.include_item_counts.unwrap_or(true),
include_item_counts: options.include_item_counts.unwrap_or(false),
include_hidden: options.include_hidden_item_counts.unwrap_or(true),
}
}
@@ -500,6 +501,7 @@ fn read_link_metadata_fields(
symlink_metadata: &fs::Metadata,
metadata: Option<&fs::Metadata>,
options: ReadEntryOptions,
reparse_tag: Option<u32>,
) -> (
Option<DirEntryLinkType>,
Option<String>,
@@ -507,7 +509,6 @@ fn read_link_metadata_fields(
Option<u64>,
) {
let is_symlink = symlink_metadata.is_symlink();
let reparse_tag = windows_reparse_tag(path, symlink_metadata);
let is_file = metadata.map(|metadata| metadata.is_file()).unwrap_or(false);
let (mut link_type, link_target, link_status) = if is_windows_junction_reparse_tag(reparse_tag)
@@ -546,6 +547,22 @@ fn read_link_metadata_fields(
(link_type, link_target, link_status, hard_link_count)
}
fn needs_followed_metadata(symlink_metadata: &fs::Metadata) -> bool {
if symlink_metadata.is_symlink() {
return true;
}
#[cfg(windows)]
{
return is_windows_reparse_point(symlink_metadata);
}
#[cfg(not(windows))]
{
false
}
}
fn read_entry(path: &Path, options: ReadEntryOptions) -> Option<DirEntry> {
if should_skip_path(path) {
return None;
@@ -553,37 +570,29 @@ fn read_entry(path: &Path, options: ReadEntryOptions) -> Option<DirEntry> {
let symlink_metadata = fs::symlink_metadata(path).ok()?;
let is_symlink = symlink_metadata.is_symlink();
let is_windows_junction =
is_windows_junction_reparse_tag(windows_reparse_tag(path, &symlink_metadata));
let metadata = fs::metadata(path).ok();
let reparse_tag = windows_reparse_tag(path, &symlink_metadata);
let is_windows_junction = is_windows_junction_reparse_tag(reparse_tag);
let should_follow_metadata = needs_followed_metadata(&symlink_metadata);
let followed_metadata = if should_follow_metadata {
fs::metadata(path).ok()
} else {
None
};
if metadata.is_none() && !is_symlink && !is_windows_junction {
if followed_metadata.is_none() && should_follow_metadata && !is_symlink && !is_windows_junction
{
return None;
}
let metadata_for_type = followed_metadata.as_ref().unwrap_or(&symlink_metadata);
let path_string = normalize_path(path.to_str()?);
let name = entry_name(path, &path_string)?;
let extension = path_extension_lowercase(path);
let is_dir = metadata
.as_ref()
.map(|metadata| metadata.is_dir())
.unwrap_or(false);
let is_file = metadata
.as_ref()
.map(|metadata| metadata.is_file())
.unwrap_or(false);
let is_dir = metadata_for_type.is_dir();
let is_file = metadata_for_type.is_file();
let (modified_time, accessed_time, created_time) = metadata_times_unix_ms(metadata_for_type);
let metadata_for_times = metadata.as_ref().unwrap_or(&symlink_metadata);
let (modified_time, accessed_time, created_time) = metadata_times_unix_ms(metadata_for_times);
let size = if is_file {
metadata
.as_ref()
.map(|metadata| metadata.len())
.unwrap_or(0)
} else {
0
};
let size = if is_file { metadata_for_type.len() } else { 0 };
let item_count = if is_dir && options.include_item_counts {
count_listable_dir_entries(path, options)
@@ -596,8 +605,13 @@ fn read_entry(path: &Path, options: ReadEntryOptions) -> Option<DirEntry> {
} else {
None
};
let (link_type, link_target, link_status, hard_link_count) =
read_link_metadata_fields(path, &symlink_metadata, metadata.as_ref(), options);
let (link_type, link_target, link_status, hard_link_count) = read_link_metadata_fields(
path,
&symlink_metadata,
Some(metadata_for_type),
options,
reparse_tag,
);
Some(DirEntry {
name,
@@ -612,7 +626,7 @@ fn read_entry(path: &Path, options: ReadEntryOptions) -> Option<DirEntry> {
is_file,
is_dir,
is_symlink,
is_hidden: is_hidden_path(path),
is_hidden: is_hidden_from_metadata(path, metadata_for_type),
link_type,
link_target,
link_status,
@@ -680,10 +694,21 @@ fn read_link_metadata(path: &Path, options: ReadEntryOptions) -> Option<DirEntry
}
let symlink_metadata = fs::symlink_metadata(path).ok()?;
let metadata = fs::metadata(path).ok();
let reparse_tag = windows_reparse_tag(path, &symlink_metadata);
let followed_metadata = if needs_followed_metadata(&symlink_metadata) {
fs::metadata(path).ok()
} else {
None
};
let metadata_for_type = followed_metadata.as_ref().unwrap_or(&symlink_metadata);
let path_string = normalize_path(path.to_str()?);
let (link_type, link_target, link_status, hard_link_count) =
read_link_metadata_fields(path, &symlink_metadata, metadata.as_ref(), options);
let (link_type, link_target, link_status, hard_link_count) = read_link_metadata_fields(
path,
&symlink_metadata,
Some(metadata_for_type),
options,
reparse_tag,
);
Some(DirEntryLinkMetadata {
path: path_string,
@@ -891,27 +916,25 @@ pub fn read_dir(path: String, options: Option<ReadDirOptions>) -> Result<DirCont
let (self_modified, self_accessed, self_created) = metadata_times_unix_ms(&self_metadata);
let read_result = fs::read_dir(directory).map_err(|error| error.to_string())?;
let entry_paths: Vec<PathBuf> = read_result.flatten().map(|entry| entry.path()).collect();
let mut entries: Vec<DirEntry> = Vec::new();
let mut dir_count = 0;
let mut file_count = 0;
const PARALLEL_READ_THRESHOLD: usize = 64;
let mut entries: Vec<DirEntry> = if entry_paths.len() >= PARALLEL_READ_THRESHOLD {
entry_paths
.par_iter()
.filter_map(|entry_path| read_entry(entry_path, read_entry_options))
.collect()
} else {
entry_paths
.iter()
.filter_map(|entry_path| read_entry(entry_path, read_entry_options))
.collect()
};
for entry in read_result.flatten() {
if let Some(dir_entry) = read_entry(&entry.path(), read_entry_options) {
if dir_entry.is_dir {
dir_count += 1;
} else if dir_entry.is_file {
file_count += 1;
}
entries.push(dir_entry);
}
}
entries.sort_by_cached_key(|entry| (!entry.is_dir, entry.name.to_lowercase()));
entries.sort_by(|first, second| match (first.is_dir, second.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => first.name.to_lowercase().cmp(&second.name.to_lowercase()),
});
let dir_count = entries.iter().filter(|entry| entry.is_dir).count();
let file_count = entries.iter().filter(|entry| entry.is_file).count();
Ok(DirContents {
path: normalize_path(&path),
@@ -1037,6 +1060,24 @@ mod tests {
assert_eq!(child_entry.item_count, None);
}
#[test]
fn read_dir_defaults_to_skipping_item_counts() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
let child_dir_path = temp_dir.path().join("child");
fs::create_dir(&child_dir_path).expect("create child dir");
fs::write(child_dir_path.join("nested.txt"), b"contents").expect("write nested file");
let contents =
read_dir(temp_dir.path().to_string_lossy().to_string(), None).expect("read dir");
let child_entry = contents
.entries
.iter()
.find(|entry| entry.name == "child")
.expect("child entry");
assert_eq!(child_entry.item_count, None);
}
#[test]
fn read_dir_item_counts_can_exclude_hidden_entries() {
let temp_dir = tempfile::tempdir().expect("create temp dir");
+23 -7
View File
@@ -52,17 +52,33 @@ pub fn path_extension_lowercase(path: &Path) -> Option<String> {
.map(|extension| extension.to_lowercase())
}
pub fn is_hidden_from_metadata(path: &Path, metadata: &Metadata) -> bool {
#[cfg(windows)]
{
let _ = path;
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
(metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN) != 0
}
#[cfg(not(windows))]
{
let _ = metadata;
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.starts_with('.'))
.unwrap_or(false)
}
}
pub fn is_hidden_path(path: &Path) -> bool {
#[cfg(windows)]
{
use std::os::windows::fs::MetadataExt;
if let Ok(metadata) = std::fs::metadata(path) {
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
return (metadata.file_attributes() & FILE_ATTRIBUTE_HIDDEN) != 0;
match std::fs::metadata(path) {
Ok(metadata) => is_hidden_from_metadata(path, &metadata),
Err(_) => false,
}
false
}
#[cfg(not(windows))]
@@ -156,6 +156,7 @@ describe('createFsAPI', () => {
options: {
includeShortcutTargets: true,
includeHardLinkCounts: true,
includeItemCounts: false,
},
});
});
@@ -32,6 +32,7 @@ async function readExtensionDir(extensionId: string, path: string): Promise<Exte
options: {
includeShortcutTargets: true,
includeHardLinkCounts: true,
includeItemCounts: false,
},
});
@@ -11,7 +11,7 @@ import {
} from 'vue';
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { homeDir, basename } from '@tauri-apps/api/path';
import { homeDir } from '@tauri-apps/api/path';
import { openPathDefault } from '@/utils/open-path-default';
import type { DirEntry, DirContents, ReadDirOptions } from '@/types/dir-entry';
import type { Tab } from '@/types/workspaces';
@@ -64,6 +64,33 @@ function logDirWatcherDiag(message: string, details?: Record<string, unknown>) {
console.debug(`[dir-watcher-diag] ${message}`);
}
function buildDirContentsFromEntries(path: string, entries: DirEntry[]): DirContents {
let dirCount = 0;
let fileCount = 0;
for (const entry of entries) {
if (entry.is_dir) {
dirCount += 1;
}
else if (entry.is_file) {
fileCount += 1;
}
}
return {
path,
entries,
total_count: dirCount + fileCount,
dir_count: dirCount,
file_count: fileCount,
opened_directory_times: {
modified_time: 0,
accessed_time: 0,
created_time: 0,
},
};
}
function replacePathPrefix(path: string, oldPrefix: string, newPrefix: string): string | null {
if (path === oldPrefix) return newPrefix;
if (path.startsWith(oldPrefix + '/')) return newPrefix + path.slice(oldPrefix.length);
@@ -132,6 +159,11 @@ export function useFileBrowserNavigation(
let dirChangeUnlisten: UnlistenFn | null = null;
let watcherRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let watcherValidationTimer: ReturnType<typeof setTimeout> | null = null;
let iconPrefetchTimer: ReturnType<typeof setTimeout> | null = null;
let watcherOperation = Promise.resolve();
let readGeneration = 0;
let silentRefreshGeneration = 0;
let isDisposed = false;
const readDirExistenceOptions: ReadDirOptions = {
includeShortcutTargets: false,
@@ -201,61 +233,90 @@ export function useFileBrowserNavigation(
}, DIRECTORY_DWELL_TIME_MS);
}
async function startWatching(path: string) {
const normalizedPath = normalizePath(path);
if (watchedPath === normalizedPath) {
logDirWatcherDiag('startWatching skipped already watching', { path: normalizedPath });
return;
}
logDirWatcherDiag('startWatching begin', {
path: normalizedPath,
previousPath: watchedPath,
});
await stopWatching();
try {
await invoke('watch_directory', { path: normalizedPath });
watchedPath = normalizedPath;
logDirWatcherDiag('startWatching complete', { path: normalizedPath });
if (!dirChangeUnlisten) {
dirChangeUnlisten = await listen<DirChangePayload>('dir-change', (event) => {
if (event.payload.watchedPath === watchedPath) {
if (watcherRefreshTimer) {
clearTimeout(watcherRefreshTimer);
}
watcherRefreshTimer = setTimeout(() => {
if (currentPath.value && watchedPath === currentPath.value) {
silentRefresh();
}
watcherRefreshTimer = null;
}, WATCHER_DEBOUNCE_MS);
}
else if (currentPath.value) {
if (watcherValidationTimer) {
clearTimeout(watcherValidationTimer);
}
watcherValidationTimer = setTimeout(() => {
validateCurrentPath();
watcherValidationTimer = null;
}, WATCHER_DEBOUNCE_MS);
}
});
}
}
catch (err) {
console.error('[dir-watcher-diag] Failed to start directory watcher:', err);
}
function enqueueWatcherOperation(operation: () => Promise<void>): Promise<void> {
const result = watcherOperation.then(operation, operation);
watcherOperation = result.catch(() => {});
return result;
}
async function stopWatching() {
function startWatching(path: string): Promise<void> {
const normalizedPath = normalizePath(path);
return enqueueWatcherOperation(async () => {
if (isDisposed || normalizePath(currentPath.value) !== normalizedPath) {
return;
}
if (watchedPath === normalizedPath) {
logDirWatcherDiag('startWatching skipped already watching', { path: normalizedPath });
return;
}
logDirWatcherDiag('startWatching begin', {
path: normalizedPath,
previousPath: watchedPath,
});
await stopWatchingImmediately();
if (isDisposed || normalizePath(currentPath.value) !== normalizedPath) {
return;
}
try {
await invoke('watch_directory', { path: normalizedPath });
if (isDisposed || normalizePath(currentPath.value) !== normalizedPath) {
await invoke('unwatch_directory', { path: normalizedPath });
return;
}
watchedPath = normalizedPath;
logDirWatcherDiag('startWatching complete', { path: normalizedPath });
if (!dirChangeUnlisten) {
const unlisten = await listen<DirChangePayload>('dir-change', (event) => {
if (event.payload.watchedPath === watchedPath) {
if (watcherRefreshTimer) {
clearTimeout(watcherRefreshTimer);
}
watcherRefreshTimer = setTimeout(() => {
if (currentPath.value && watchedPath === currentPath.value) {
silentRefresh();
}
watcherRefreshTimer = null;
}, WATCHER_DEBOUNCE_MS);
}
else if (currentPath.value) {
if (watcherValidationTimer) {
clearTimeout(watcherValidationTimer);
}
watcherValidationTimer = setTimeout(() => {
validateCurrentPath();
watcherValidationTimer = null;
}, WATCHER_DEBOUNCE_MS);
}
});
if (isDisposed) {
unlisten();
return;
}
dirChangeUnlisten = unlisten;
}
}
catch (error) {
console.error('[dir-watcher-diag] Failed to start directory watcher:', error);
}
});
}
async function stopWatchingImmediately(): Promise<void> {
if (watcherRefreshTimer) {
clearTimeout(watcherRefreshTimer);
watcherRefreshTimer = null;
@@ -274,14 +335,18 @@ export function useFileBrowserNavigation(
await invoke('unwatch_directory', { path: pathToStop });
logDirWatcherDiag('stopWatching complete', { path: pathToStop });
}
catch (err) {
console.error('[dir-watcher-diag] Failed to stop directory watcher:', err);
catch (error) {
console.error('[dir-watcher-diag] Failed to stop directory watcher:', error);
}
watchedPath = null;
}
}
function stopWatching(): Promise<void> {
return enqueueWatcherOperation(stopWatchingImmediately);
}
watch(sharedDrives, () => {
if (!isVirtualDirectoryPath(currentPath.value) || !dirContents.value) {
return;
@@ -304,9 +369,16 @@ export function useFileBrowserNavigation(
}, { deep: true });
onUnmounted(() => {
isDisposed = true;
readGeneration += 1;
cancelPendingDirectoryRecord();
stopWatching();
if (iconPrefetchTimer) {
clearTimeout(iconPrefetchTimer);
iconPrefetchTimer = null;
}
if (dirChangeUnlisten) {
dirChangeUnlisten();
dirChangeUnlisten = null;
@@ -316,14 +388,98 @@ export function useFileBrowserNavigation(
const canGoBack = computed(() => historyIndex.value > 0);
const canGoForward = computed(() => historyIndex.value < history.value.length - 1);
async function loadDirectoryWithIconPrefetch(path: string, options?: ReadDirOptions): Promise<DirContents> {
const directoryContents = await loadDirectoryContents(path, options ?? createReadDirOptions());
navigatorIconsStore.prefetchForDirectoryEntries(directoryContents.entries);
return directoryContents;
function getCachedDirContentsForPath(path: string): DirContents | null {
const currentTab = tab();
if (!currentTab || currentTab.type !== 'directory') {
return null;
}
if (normalizePath(currentTab.path) !== path) {
return null;
}
if (!Array.isArray(currentTab.dirEntries) || currentTab.dirEntries.length === 0) {
return null;
}
return buildDirContentsFromEntries(path, currentTab.dirEntries);
}
function applyDirectoryContents(
result: DirContents,
readOptions: ReadDirOptions,
options: {
addToHistory: boolean;
invalidateLinkMetadata: boolean;
updateTabName: boolean;
},
): void {
if (options.invalidateLinkMetadata) {
invalidateDirectoryLinkMetadata(result);
invalidateDirectoryItemCounts(result);
}
primeDirectoryMetadataCaches(result, readOptions);
dirContents.value = result;
currentPath.value = result.path;
pathInput.value = result.path;
const currentTab = tab();
if (currentTab) {
currentTab.path = result.path;
currentTab.dirEntries = result.entries;
if (options.updateTabName) {
currentTab.name = isVirtualLocationPath(result.path)
? result.path
: (getPathDisplayName(result.path) || result.path);
}
}
if (options.addToHistory) {
if (historyIndex.value < history.value.length - 1) {
history.value.splice(historyIndex.value + 1);
}
history.value.push(result.path);
historyIndex.value = history.value.length - 1;
}
nextTick(() => {
onNavigationComplete?.(currentDirEntry.value);
});
const dirPaths = result.entries
.filter(entry => entry.is_dir)
.slice(0, DIR_SIZE_CONSTANTS.BATCH_LIMIT)
.map(entry => entry.path);
if (dirPaths.length > 0 && !isCopyOrMoveInProgress() && !isVirtualDirectoryPath(result.path)) {
dirSizesStore.requestSizesBatch(dirPaths);
}
}
function scheduleDirectoryIconPrefetch(entries: DirEntry[]): void {
if (iconPrefetchTimer) {
clearTimeout(iconPrefetchTimer);
}
iconPrefetchTimer = setTimeout(() => {
iconPrefetchTimer = null;
if (!isDisposed) {
navigatorIconsStore.prefetchForDirectoryEntries(entries);
}
}, 0);
}
async function silentRefresh(): Promise<void> {
const requestGeneration = ++silentRefreshGeneration;
const startingReadGeneration = readGeneration;
const refreshPath = currentPath.value;
const refreshTabId = tab()?.id;
if (!refreshPath) {
return;
@@ -336,9 +492,18 @@ export function useFileBrowserNavigation(
const virtualDirectory = buildVirtualDirectoryFromDrives(refreshPath, sharedDrives.value);
const result = virtualDirectory
? virtualDirectory
: await loadDirectoryWithIconPrefetch(refreshPath, readOptions);
: await loadDirectoryContents(refreshPath, readOptions);
if (currentPath.value !== refreshPath) {
const currentTab = tab();
if (
isDisposed
|| requestGeneration !== silentRefreshGeneration
|| startingReadGeneration !== readGeneration
|| currentPath.value !== refreshPath
|| currentTab?.id !== refreshTabId
|| (currentTab && normalizePath(currentTab.path) !== normalizePath(refreshPath))
) {
return;
}
@@ -347,12 +512,12 @@ export function useFileBrowserNavigation(
primeDirectoryMetadataCaches(result, readOptions);
dirContents.value = result;
const currentTab = tab();
if (currentTab) {
currentTab.dirEntries = result.entries;
}
scheduleDirectoryIconPrefetch(result.entries);
const dirPaths = result.entries
.filter(entry => entry.is_dir)
.slice(0, DIR_SIZE_CONSTANTS.BATCH_LIMIT)
@@ -363,14 +528,29 @@ export function useFileBrowserNavigation(
}
}
catch {
if (currentPath.value !== refreshPath) {
const currentTab = tab();
if (
isDisposed
|| requestGeneration !== silentRefreshGeneration
|| startingReadGeneration !== readGeneration
|| currentPath.value !== refreshPath
|| currentTab?.id !== refreshTabId
|| (currentTab && normalizePath(currentTab.path) !== normalizePath(refreshPath))
) {
return;
}
await navigateToNearestExistingAncestor();
}
finally {
isRefreshing.value = false;
if (
!isDisposed
&& requestGeneration === silentRefreshGeneration
&& startingReadGeneration === readGeneration
) {
isRefreshing.value = false;
}
}
}
@@ -433,115 +613,102 @@ export function useFileBrowserNavigation(
forceLoading = false,
invalidateLinkMetadata = false,
) {
const requestGeneration = ++readGeneration;
const normalizedPath = normalizePath(path);
const isNewDirectory = normalizedPath !== currentPath.value;
const cachedContents = !forceLoading
? getCachedDirContentsForPath(normalizedPath)
: null;
if (cachedContents) {
dirContents.value = cachedContents;
currentPath.value = cachedContents.path;
pathInput.value = cachedContents.path;
isLoading.value = false;
isRefreshing.value = true;
}
else {
isLoading.value = forceLoading || isNewDirectory || !dirContents.value;
}
isLoading.value = forceLoading || isNewDirectory || !dirContents.value;
error.value = null;
onSelectionClear?.();
if (watchedPath && watchedPath !== normalizedPath) {
logDirWatcherDiag('readDir stopping watcher before navigation', {
from: watchedPath,
to: normalizedPath,
});
await stopWatching();
}
const stopWatcherPromise = (watchedPath && watchedPath !== normalizedPath)
? (() => {
logDirWatcherDiag('readDir stopping watcher before navigation', {
from: watchedPath,
to: normalizedPath,
});
return stopWatching();
})()
: Promise.resolve();
try {
logDirWatcherDiag('readDir loading directory', { path: normalizedPath });
const readOptions = createReadDirOptions();
const result = await loadDirectoryWithIconPrefetch(path, readOptions);
const [result] = await Promise.all([
loadDirectoryContents(path, readOptions),
stopWatcherPromise,
]);
if (invalidateLinkMetadata) {
invalidateDirectoryLinkMetadata(result);
invalidateDirectoryItemCounts(result);
if (isDisposed || requestGeneration !== readGeneration) {
return;
}
primeDirectoryMetadataCaches(result, readOptions);
dirContents.value = result;
currentPath.value = result.path;
pathInput.value = result.path;
const currentTab = tab();
if (currentTab) {
currentTab.path = result.path;
if (isVirtualLocationPath(result.path)) {
currentTab.name = result.path;
}
else if (isVirtualDirectoryPath(result.path)) {
currentTab.name = getPathDisplayName(result.path);
}
else {
try {
currentTab.name = await basename(result.path);
}
catch {
currentTab.name = result.path;
}
}
currentTab.dirEntries = result.entries;
}
if (addToHistory) {
if (historyIndex.value < history.value.length - 1) {
history.value.splice(historyIndex.value + 1);
}
history.value.push(result.path);
historyIndex.value = history.value.length - 1;
}
nextTick(() => {
onNavigationComplete?.(currentDirEntry.value);
applyDirectoryContents(result, readOptions, {
addToHistory,
invalidateLinkMetadata,
updateTabName: true,
});
const dirPaths = result.entries
.filter(entry => entry.is_dir)
.slice(0, DIR_SIZE_CONSTANTS.BATCH_LIMIT)
.map(entry => entry.path);
if (dirPaths.length > 0 && !isCopyOrMoveInProgress() && !isVirtualDirectoryPath(result.path)) {
dirSizesStore.requestSizesBatch(dirPaths);
}
isLoading.value = false;
isRefreshing.value = false;
scheduleDirectoryIconPrefetch(result.entries);
if (!isVirtualDirectoryPath(result.path)) {
logDirWatcherDiag('readDir starting watcher after load', {
path: result.path,
entryCount: result.entries.length,
});
await startWatching(result.path);
startWatching(result.path);
}
}
catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
catch (caughtError: unknown) {
if (isDisposed || requestGeneration !== readGeneration) {
return;
}
const errorMessage = caughtError instanceof Error ? caughtError.message : String(caughtError);
error.value = errorMessage;
console.error(err);
console.error(caughtError);
}
finally {
isLoading.value = false;
if (!isDisposed && requestGeneration === readGeneration) {
isLoading.value = false;
isRefreshing.value = false;
}
}
}
function schedulePendingDirectoryRecordIfCurrent(path: string): void {
if (!error.value && normalizePath(currentPath.value) === normalizePath(path)) {
schedulePendingDirectoryRecord(path);
}
}
async function navigateToPath(path: string) {
cancelPendingDirectoryRecord();
await readDir(path);
if (!error.value) {
schedulePendingDirectoryRecord(path);
}
schedulePendingDirectoryRecordIfCurrent(path);
}
async function navigateToEntry(entry: DirEntry) {
if (entry.is_dir) {
cancelPendingDirectoryRecord();
await readDir(entry.path);
schedulePendingDirectoryRecord(entry.path);
schedulePendingDirectoryRecordIfCurrent(entry.path);
}
}
@@ -553,17 +720,14 @@ export function useFileBrowserNavigation(
const targetPath = parentPath.value;
cancelPendingDirectoryRecord();
await readDir(targetPath);
if (!error.value) {
schedulePendingDirectoryRecord(targetPath);
}
schedulePendingDirectoryRecordIfCurrent(targetPath);
}
async function navigateToHome() {
cancelPendingDirectoryRecord();
const homePath = normalizePath(await homeDir());
await readDir(homePath);
schedulePendingDirectoryRecord(homePath);
schedulePendingDirectoryRecordIfCurrent(homePath);
}
async function goBack() {
@@ -572,7 +736,7 @@ export function useFileBrowserNavigation(
historyIndex.value--;
const targetPath = history.value[historyIndex.value];
await readDir(targetPath, false);
schedulePendingDirectoryRecord(targetPath);
schedulePendingDirectoryRecordIfCurrent(targetPath);
}
}
@@ -582,7 +746,7 @@ export function useFileBrowserNavigation(
historyIndex.value++;
const targetPath = history.value[historyIndex.value];
await readDir(targetPath, false);
schedulePendingDirectoryRecord(targetPath);
schedulePendingDirectoryRecordIfCurrent(targetPath);
}
}
@@ -597,8 +761,9 @@ export function useFileBrowserNavigation(
async function handlePathSubmit() {
if (pathInput.value && pathInput.value !== currentPath.value) {
cancelPendingDirectoryRecord();
await readDir(pathInput.value);
schedulePendingDirectoryRecord(pathInput.value);
const submittedPath = pathInput.value;
await readDir(submittedPath);
schedulePendingDirectoryRecordIfCurrent(submittedPath);
}
}
@@ -624,7 +789,7 @@ export function useFileBrowserNavigation(
await navigateToHome();
}
else {
schedulePendingDirectoryRecord(currentTab.path);
schedulePendingDirectoryRecordIfCurrent(currentTab.path);
}
}
else {
@@ -6,6 +6,7 @@ import {
beforeEach, describe, expect, it, vi,
} from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import type { DirContents } from '@/types/dir-entry';
import type { Tab, TabGroup, Workspace } from '@/types/workspaces';
const {
@@ -210,9 +211,53 @@ describe('workspaces storage duplicate tabs', () => {
expect(invokeMock).toHaveBeenCalledWith('read_dir_with_timeout', {
path: 'D:/',
timeoutMs: expect.any(Number),
options: {
includeShortcutTargets: false,
includeHardLinkCounts: false,
includeItemCounts: false,
},
});
});
it('does not overwrite a changed tab path with stale preloaded entries', async () => {
const projectsRead = createDeferred<DirContents>();
const documentsRead = createDeferred<DirContents>();
invokeMock.mockImplementation((command: string, payload?: { path?: string }) => {
if (command === 'read_dir_with_timeout') {
return payload?.path === 'C:/Users/aleks/Projects'
? projectsRead.promise
: documentsRead.promise;
}
if (command === 'get_dir_entry_with_timeout') {
return Promise.resolve({ path: payload?.path ?? '' });
}
return Promise.resolve(null);
});
const currentTab = createTab('current-tab', 'C:/Users/aleks/Projects');
const workspacesStore = useWorkspacesStore();
workspacesStore.workspaces = [
createWorkspace([
[currentTab],
]),
];
const projectsOpen = workspacesStore.openTabGroup([currentTab]);
const documentsOpen = workspacesStore.openPathInCurrentTab('D:/Documents');
documentsRead.resolve(createDirContents('D:/Documents', 'D:/Documents/current.txt'));
await documentsOpen;
projectsRead.resolve(createDirContents('C:/Users/aleks/Projects', 'C:/Users/aleks/Projects/stale.txt'));
await projectsOpen;
expect(currentTab.path).toBe('D:/Documents');
expect(currentTab.dirEntries.map(entry => entry.path)).toEqual(['D:/Documents/current.txt']);
});
it('does not request history when reopening the current tab path', async () => {
mockDirectoryReadResponses();
@@ -433,6 +478,11 @@ describe('workspaces storage duplicate tabs', () => {
expect(invokeMock).toHaveBeenCalledWith('read_dir_with_timeout', {
path: 'C:/Users/aleks/Projects',
timeoutMs: 5000,
options: {
includeShortcutTargets: false,
includeHardLinkCounts: false,
includeItemCounts: false,
},
});
});
@@ -483,6 +533,11 @@ describe('workspaces storage duplicate tabs', () => {
expect(invokeMock).toHaveBeenCalledWith('read_dir_with_timeout', {
path: '//wsl.localhost/Ubuntu-24.04',
timeoutMs: 60000,
options: {
includeShortcutTargets: false,
includeHardLinkCounts: false,
includeItemCounts: false,
},
});
});
@@ -597,6 +652,48 @@ function createTab(id: string, path: string): Tab {
};
}
function createDeferred<T>() {
let resolvePromise: (value: T) => void = () => {};
const promise = new Promise<T>((resolve) => {
resolvePromise = resolve;
});
return {
promise,
resolve: resolvePromise,
};
}
function createDirContents(path: string, entryPath: string): DirContents {
return {
path,
entries: [{
name: entryPath.slice(entryPath.lastIndexOf('/') + 1),
ext: 'txt',
path: entryPath,
size: 0,
item_count: null,
modified_time: 0,
accessed_time: 0,
created_time: 0,
mime: 'text/plain',
is_file: true,
is_dir: false,
is_symlink: false,
is_hidden: false,
}],
total_count: 1,
dir_count: 0,
file_count: 1,
opened_directory_times: {
modified_time: 0,
accessed_time: 0,
created_time: 0,
},
};
}
function mockDirectoryReadResponses() {
invokeMock.mockImplementation((command: string, payload?: { path?: string }) => {
if (command === 'read_dir_with_timeout') {
+10 -1
View File
@@ -609,11 +609,15 @@ export const useWorkspacesStore = defineStore('workspaces', () => {
async function loadTabGroupDirEntries(tabGroup: TabGroup, options: { timeoutMs?: number } = {}) {
await Promise.all(tabGroup.map(async (tab: Tab) => {
if (tab.type === 'directory') {
const requestedPath = normalizePath(tab.path);
const dirEntries = await getDirEntries({
path: tab.path,
timeoutMs: options.timeoutMs,
});
tab.dirEntries = dirEntries;
if (tab.type === 'directory' && normalizePath(tab.path) === requestedPath) {
tab.dirEntries = dirEntries;
}
}
}));
}
@@ -674,6 +678,11 @@ export const useWorkspacesStore = defineStore('workspaces', () => {
const dirContents = await invoke<DirContents>('read_dir_with_timeout', {
path: params.path,
timeoutMs: effectiveTimeoutMs,
options: {
includeShortcutTargets: false,
includeHardLinkCounts: false,
includeItemCounts: false,
},
});
return dirContents.entries;
}