mirror of
https://github.com/aleksey-hoffman/sigma-file-manager.git
synced 2026-10-19 01:31:05 -05:00
Refine: large dir loading performance
This commit is contained in:
1 parent
165b342c67
commit
db15621b06
8 files changed
+542
-207
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+311
-146
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user