mirror of
https://github.com/aleksey-hoffman/sigma-file-manager.git
synced 2026-10-19 01:31:05 -05:00
Fix: WSL host UNC path handling on Windows
- Normalize extended UNC paths for modern context menu - Treat //wsl.localhost as a virtual directory listing WSL distros
This commit is contained in:
1 parent
041a55b678
commit
dbee91384d
11 files changed
+375
-93
No files matched your search
@@ -78,6 +78,16 @@ pub fn common_parent_directory_for_selections(
|
||||
Ok(expected_parent.clone())
|
||||
}
|
||||
|
||||
pub fn strip_extended_path_prefix(path: &str) -> String {
|
||||
if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
|
||||
format!(r"\\{rest}")
|
||||
} else if let Some(rest) = path.strip_prefix(r"\\?\") {
|
||||
rest.to_string()
|
||||
} else {
|
||||
path.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare_shell_path(file_path: &str) -> String {
|
||||
#[cfg(windows)]
|
||||
let native_path = path_for_selection(file_path);
|
||||
@@ -92,7 +102,8 @@ pub fn prepare_shell_path(file_path: &str) -> String {
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
resolved.to_string_lossy().replace('/', "\\")
|
||||
let resolved_path = strip_extended_path_prefix(&resolved.to_string_lossy());
|
||||
resolved_path.replace('/', "\\")
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
@@ -143,10 +154,39 @@ pub fn load_png_as_base64(path: &std::path::Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
#[cfg(test)]
|
||||
mod shell_path_tests {
|
||||
use super::{path_for_selection, prepare_shell_path};
|
||||
use super::{path_for_selection, prepare_shell_path, strip_extended_path_prefix};
|
||||
|
||||
#[test]
|
||||
fn strip_extended_path_prefix_strips_extended_unc_prefix() {
|
||||
assert_eq!(
|
||||
strip_extended_path_prefix(r"\\?\UNC\wsl.localhost\Ubuntu-24.04\"),
|
||||
r"\\wsl.localhost\Ubuntu-24.04\",
|
||||
);
|
||||
assert_eq!(
|
||||
strip_extended_path_prefix(r"\\?\UNC\wsl.localhost\docker-desktop\"),
|
||||
r"\\wsl.localhost\docker-desktop\",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_extended_path_prefix_strips_extended_local_prefix() {
|
||||
assert_eq!(
|
||||
strip_extended_path_prefix(r"\\?\C:\Users\aleks\file.txt"),
|
||||
r"C:\Users\aleks\file.txt",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_extended_path_prefix_leaves_regular_paths_unchanged() {
|
||||
assert_eq!(
|
||||
strip_extended_path_prefix(r"\\wsl.localhost\Ubuntu-24.04\"),
|
||||
r"\\wsl.localhost\Ubuntu-24.04\",
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn path_for_selection_converts_unc_paths_to_backslashes() {
|
||||
assert_eq!(
|
||||
@@ -155,6 +195,7 @@ mod shell_path_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn prepare_shell_path_converts_unc_paths_to_backslashes() {
|
||||
assert_eq!(
|
||||
@@ -163,6 +204,7 @@ mod shell_path_tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn prepare_shell_path_converts_local_windows_paths_to_backslashes() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
// pipeline.
|
||||
|
||||
use crate::open_with::types::{GetShellContextMenuResult, OpenWithResult, ShellContextMenuItem};
|
||||
use crate::open_with::utils::canonicalize_path;
|
||||
use crate::open_with::utils::shell_path_candidates;
|
||||
use crate::open_with::windows::icon_utils::icon_location_to_data_uri;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
@@ -358,23 +358,37 @@ unsafe fn read_owned_pwstr(value: windows::core::Result<PWSTR>) -> Option<String
|
||||
Some(text)
|
||||
}
|
||||
|
||||
unsafe fn parse_shell_item_id_list(path: &str) -> Result<*mut ITEMIDLIST, String> {
|
||||
let mut last_error = String::from("No paths provided");
|
||||
|
||||
for candidate_path in shell_path_candidates(path) {
|
||||
let wide_path = HSTRING::from(&candidate_path);
|
||||
let mut id_list: *mut ITEMIDLIST = std::ptr::null_mut();
|
||||
match SHParseDisplayName(&wide_path, None, &mut id_list, 0, None) {
|
||||
Ok(_) => return Ok(id_list),
|
||||
Err(parse_error) => {
|
||||
last_error = format!(
|
||||
"Failed to parse path '{}': {}",
|
||||
candidate_path, parse_error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(last_error)
|
||||
}
|
||||
|
||||
unsafe fn create_shell_item_array(file_paths: &[String]) -> Result<IShellItemArray, String> {
|
||||
let mut id_lists: Vec<*mut ITEMIDLIST> = Vec::with_capacity(file_paths.len());
|
||||
|
||||
for path in file_paths {
|
||||
let normalized_path = canonicalize_path(Path::new(path));
|
||||
let wide_path = HSTRING::from(&normalized_path);
|
||||
let mut id_list: *mut ITEMIDLIST = std::ptr::null_mut();
|
||||
match SHParseDisplayName(&wide_path, None, &mut id_list, 0, None) {
|
||||
Ok(_) => id_lists.push(id_list),
|
||||
match parse_shell_item_id_list(path) {
|
||||
Ok(id_list) => id_lists.push(id_list),
|
||||
Err(parse_error) => {
|
||||
for allocated in &id_lists {
|
||||
CoTaskMemFree(Some(*allocated as *const _));
|
||||
}
|
||||
return Err(format!(
|
||||
"Failed to parse path '{}': {}",
|
||||
normalized_path, parse_error
|
||||
));
|
||||
return Err(parse_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
markRaw,
|
||||
} from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
@@ -46,8 +45,11 @@ import { useShortcutsStore } from '@/stores/runtime/shortcuts';
|
||||
import { usePlatformStore } from '@/stores/runtime/platform';
|
||||
import normalizePath, { getPathDisplayName, getPathSegments, isUncPath } from '@/utils/normalize-path';
|
||||
import {
|
||||
buildLocationsDirectoryFromDrives,
|
||||
isVirtualDirectoryPath,
|
||||
isVirtualLocationPath,
|
||||
LOCATIONS_VIRTUAL_PATH,
|
||||
resolveDirectoryContents,
|
||||
shouldPrependLocationsCrumb,
|
||||
} from '@/utils/virtual-locations';
|
||||
import { useDrives } from '@/modules/home/composables/use-drives';
|
||||
@@ -67,7 +69,9 @@ const { t } = useI18n();
|
||||
const shortcutsStore = useShortcutsStore();
|
||||
const platformStore = usePlatformStore();
|
||||
const { drives } = useDrives();
|
||||
const locationsDrivePaths = computed(() => drives.value.map(drive => normalizePath(drive.path)));
|
||||
const locationsDirectoryPaths = computed(() => {
|
||||
return buildLocationsDirectoryFromDrives(drives.value).entries.map(entry => entry.path);
|
||||
});
|
||||
const { openCopiedPath } = useOpenCopiedPath({
|
||||
openDirectory: path => emit('navigate', path),
|
||||
openFile: path => emit('openFile', path),
|
||||
@@ -83,7 +87,9 @@ interface AddressBarPart {
|
||||
path: string;
|
||||
name: string;
|
||||
isLast: boolean;
|
||||
virtual?: boolean;
|
||||
disableDropTarget?: boolean;
|
||||
displayRootIcon?: boolean;
|
||||
useLocationsDropdown?: boolean;
|
||||
}
|
||||
|
||||
const addressParts = computed(() => {
|
||||
@@ -94,7 +100,9 @@ const addressParts = computed(() => {
|
||||
path: LOCATIONS_VIRTUAL_PATH,
|
||||
name: t('locations'),
|
||||
isLast: true,
|
||||
virtual: true,
|
||||
disableDropTarget: true,
|
||||
displayRootIcon: true,
|
||||
useLocationsDropdown: true,
|
||||
}];
|
||||
}
|
||||
|
||||
@@ -128,6 +136,7 @@ const addressParts = computed(() => {
|
||||
path: fullPath,
|
||||
name: part,
|
||||
isLast: index === parts.length - 1,
|
||||
disableDropTarget: isVirtualDirectoryPath(fullPath),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,7 +149,9 @@ const addressParts = computed(() => {
|
||||
path: LOCATIONS_VIRTUAL_PATH,
|
||||
name: t('locations'),
|
||||
isLast: false,
|
||||
virtual: true,
|
||||
disableDropTarget: true,
|
||||
displayRootIcon: true,
|
||||
useLocationsDropdown: true,
|
||||
},
|
||||
...formattedParts,
|
||||
];
|
||||
@@ -162,12 +173,12 @@ async function loadSeparatorDirectories(index: number) {
|
||||
const part = addressParts.value[index];
|
||||
if (!part) return;
|
||||
|
||||
if (part.virtual) {
|
||||
if (part.useLocationsDropdown) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invoke<DirContents>('read_dir', { path: part.path });
|
||||
const result = await resolveDirectoryContents(part.path);
|
||||
const directories = result.entries
|
||||
.filter(entry => entry.is_dir)
|
||||
.map(entry => ({
|
||||
@@ -301,18 +312,18 @@ onUnmounted(() => {
|
||||
<DirEntryInteractive
|
||||
:path="part.path"
|
||||
:is-file="false"
|
||||
:disable-drop-target="part.virtual"
|
||||
:disable-drop-target="part.disableDropTarget"
|
||||
:is-current-directory-context="part.isLast"
|
||||
>
|
||||
<button
|
||||
class="address-bar__part"
|
||||
:class="{ 'address-bar__part--last': part.isLast }"
|
||||
:title="part.virtual ? t('locations') : part.path"
|
||||
:aria-label="part.virtual ? t('locations') : part.name"
|
||||
:title="part.displayRootIcon ? t('locations') : part.path"
|
||||
:aria-label="part.displayRootIcon ? t('locations') : part.name"
|
||||
@click.stop="!part.isLast && navigateToPart(part.path)"
|
||||
>
|
||||
<FolderRootIcon
|
||||
v-if="part.virtual"
|
||||
v-if="part.displayRootIcon"
|
||||
:size="14"
|
||||
class="address-bar__part-icon"
|
||||
/>
|
||||
@@ -354,7 +365,7 @@ onUnmounted(() => {
|
||||
class="address-bar__separator-menu-scroll"
|
||||
>
|
||||
<DropdownMenuItem
|
||||
v-for="dirPath in part.virtual ? locationsDrivePaths : separatorDropdowns[index]"
|
||||
v-for="dirPath in part.useLocationsDropdown ? locationsDirectoryPaths : separatorDropdowns[index]"
|
||||
:key="dirPath"
|
||||
@select="handleSeparatorNavigate(dirPath)"
|
||||
>
|
||||
|
||||
+22
-20
@@ -26,15 +26,14 @@ import { usePlatformStore } from '@/stores/runtime/platform';
|
||||
import { DIR_SIZE_CONSTANTS } from '@/constants';
|
||||
import normalizePath, {
|
||||
getPathDisplayName,
|
||||
isWslHostRootUncPath,
|
||||
} from '@/utils/normalize-path';
|
||||
import { resolveNavigableItemTarget } from '@/utils/resolve-navigable-item-target';
|
||||
import {
|
||||
buildVirtualDirectoryFromDrives,
|
||||
getNavigableParentPath,
|
||||
isVirtualDirectoryPath,
|
||||
isVirtualLocationPath,
|
||||
resolveDirectoryContents,
|
||||
buildLocationsDirectoryFromDrives,
|
||||
getLocationsDriveListDisplaySignature,
|
||||
getLocationsEntriesDisplaySignature,
|
||||
} from '@/utils/virtual-locations';
|
||||
import { sharedDrives } from '@/modules/home/composables/use-drives';
|
||||
@@ -95,7 +94,7 @@ export function useFileBrowserNavigation(
|
||||
}
|
||||
|
||||
async function directoryPathExists(path: string): Promise<boolean> {
|
||||
if (isVirtualLocationPath(path)) {
|
||||
if (isVirtualDirectoryPath(path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -258,12 +257,18 @@ export function useFileBrowserNavigation(
|
||||
}
|
||||
|
||||
watch(sharedDrives, () => {
|
||||
if (!isVirtualLocationPath(currentPath.value) || !dirContents.value) {
|
||||
if (!isVirtualDirectoryPath(currentPath.value) || !dirContents.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const displayedSignature = getLocationsEntriesDisplaySignature(dirContents.value.entries);
|
||||
const liveSignature = getLocationsDriveListDisplaySignature(sharedDrives.value);
|
||||
const liveDirectory = buildVirtualDirectoryFromDrives(currentPath.value, sharedDrives.value);
|
||||
|
||||
if (!liveDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
const liveSignature = getLocationsEntriesDisplaySignature(liveDirectory.entries);
|
||||
|
||||
if (displayedSignature === liveSignature) {
|
||||
return;
|
||||
@@ -295,8 +300,9 @@ export function useFileBrowserNavigation(
|
||||
isRefreshing.value = true;
|
||||
|
||||
try {
|
||||
const result = isVirtualLocationPath(refreshPath)
|
||||
? buildLocationsDirectoryFromDrives(sharedDrives.value)
|
||||
const virtualDirectory = buildVirtualDirectoryFromDrives(refreshPath, sharedDrives.value);
|
||||
const result = virtualDirectory
|
||||
? virtualDirectory
|
||||
: await loadDirectoryContents(refreshPath, createReadDirOptions());
|
||||
|
||||
if (currentPath.value !== refreshPath) {
|
||||
@@ -318,7 +324,7 @@ export function useFileBrowserNavigation(
|
||||
.slice(0, DIR_SIZE_CONSTANTS.BATCH_LIMIT)
|
||||
.map(entry => entry.path);
|
||||
|
||||
if (dirPaths.length > 0 && !isCopyOrMoveInProgress() && !isVirtualLocationPath(result.path)) {
|
||||
if (dirPaths.length > 0 && !isCopyOrMoveInProgress() && !isVirtualDirectoryPath(result.path)) {
|
||||
dirSizesStore.requestSizesBatch(dirPaths);
|
||||
}
|
||||
}
|
||||
@@ -431,6 +437,9 @@ export function useFileBrowserNavigation(
|
||||
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);
|
||||
@@ -461,11 +470,11 @@ export function useFileBrowserNavigation(
|
||||
.slice(0, DIR_SIZE_CONSTANTS.BATCH_LIMIT)
|
||||
.map(entry => entry.path);
|
||||
|
||||
if (dirPaths.length > 0 && !isCopyOrMoveInProgress() && !isVirtualLocationPath(result.path)) {
|
||||
if (dirPaths.length > 0 && !isCopyOrMoveInProgress() && !isVirtualDirectoryPath(result.path)) {
|
||||
dirSizesStore.requestSizesBatch(dirPaths);
|
||||
}
|
||||
|
||||
if (!isVirtualLocationPath(result.path)) {
|
||||
if (!isVirtualDirectoryPath(result.path)) {
|
||||
logDirWatcherDiag('readDir starting watcher after load', {
|
||||
path: result.path,
|
||||
entryCount: result.entries.length,
|
||||
@@ -485,13 +494,9 @@ export function useFileBrowserNavigation(
|
||||
|
||||
async function navigateToPath(path: string) {
|
||||
cancelPendingDirectoryRecord();
|
||||
const normalizedTarget = normalizePath(path);
|
||||
await readDir(path);
|
||||
|
||||
if (error.value && isWslHostRootUncPath(normalizedTarget)) {
|
||||
await navigateToHome();
|
||||
}
|
||||
else if (!error.value) {
|
||||
if (!error.value) {
|
||||
schedulePendingDirectoryRecord(path);
|
||||
}
|
||||
}
|
||||
@@ -513,10 +518,7 @@ export function useFileBrowserNavigation(
|
||||
cancelPendingDirectoryRecord();
|
||||
await readDir(targetPath);
|
||||
|
||||
if (error.value && isWslHostRootUncPath(targetPath)) {
|
||||
await navigateToHome();
|
||||
}
|
||||
else if (!error.value) {
|
||||
if (!error.value) {
|
||||
schedulePendingDirectoryRecord(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ describe('entry-action-policy', () => {
|
||||
describe('getEntryActionPolicy', () => {
|
||||
it('classifies virtual, mount-root, and normal paths', () => {
|
||||
expect(getEntryActionPolicy(LOCATIONS_VIRTUAL_PATH, 'windows')).toBe('virtual-location');
|
||||
expect(getEntryActionPolicy('//wsl.localhost', 'windows')).toBe('virtual-location');
|
||||
expect(getEntryActionPolicy('C:/', 'windows')).toBe('system-mount-root');
|
||||
expect(getEntryActionPolicy('C:/Users', 'windows')).toBe('normal');
|
||||
expect(getEntryActionPolicy('//wsl.localhost/Ubuntu-24.04', 'windows')).toBe('system-mount-root');
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
isBrowsingVirtualLocations,
|
||||
resolveActionDirectoryPath,
|
||||
} from '@/utils/virtual-location-action-target';
|
||||
import { LOCATIONS_VIRTUAL_PATH } from '@/utils/virtual-path-constants';
|
||||
import { LOCATIONS_VIRTUAL_PATH, WSL_HOST_VIRTUAL_PATH } from '@/utils/virtual-path-constants';
|
||||
|
||||
function createEntry(overrides: Partial<DirEntry> & Pick<DirEntry, 'path'>): DirEntry {
|
||||
return {
|
||||
@@ -35,6 +35,7 @@ describe('virtual-location-action-target', () => {
|
||||
describe('isBrowsingVirtualLocations', () => {
|
||||
it('detects the locations virtual path', () => {
|
||||
expect(isBrowsingVirtualLocations(LOCATIONS_VIRTUAL_PATH)).toBe(true);
|
||||
expect(isBrowsingVirtualLocations(WSL_HOST_VIRTUAL_PATH)).toBe(true);
|
||||
expect(isBrowsingVirtualLocations('C:/Users')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -97,6 +98,13 @@ describe('virtual-location-action-target', () => {
|
||||
expect(resolveActionDirectoryPath(undefined, 'C:/Users')).toBe('C:/Users');
|
||||
});
|
||||
|
||||
it('uses the selected directory when browsing the wsl host virtual path', () => {
|
||||
const selectedEntries = [createEntry({ path: '//wsl.localhost/docker-desktop' })];
|
||||
|
||||
expect(resolveActionDirectoryPath(selectedEntries, WSL_HOST_VIRTUAL_PATH))
|
||||
.toBe('//wsl.localhost/docker-desktop');
|
||||
});
|
||||
|
||||
it('uses the selected directory when browsing virtual locations', () => {
|
||||
const selectedEntries = [createEntry({ path: '//wsl.localhost/docker-desktop' })];
|
||||
|
||||
|
||||
@@ -5,14 +5,19 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
LOCATIONS_VIRTUAL_PATH,
|
||||
WSL_HOST_VIRTUAL_PATH,
|
||||
buildLocationsDirectoryFromDrives,
|
||||
buildVirtualDirectoryFromDrives,
|
||||
buildWslHostDirectoryFromDrives,
|
||||
createLocationsDirEntry,
|
||||
driveInfoToDirEntry,
|
||||
getLocationsDriveListDisplaySignature,
|
||||
getLocationsEntriesDisplaySignature,
|
||||
getNavigableParentPath,
|
||||
getVirtualLocationDisplayName,
|
||||
isVirtualDirectoryPath,
|
||||
isVirtualLocationPath,
|
||||
isWslHostVirtualPath,
|
||||
shouldPrependLocationsCrumb,
|
||||
virtualLocationPathExists,
|
||||
} from '@/utils/virtual-locations';
|
||||
@@ -25,8 +30,18 @@ describe('virtual-locations', () => {
|
||||
expect(isVirtualLocationPath('C:/Users')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns the localized display name for virtual locations', () => {
|
||||
it('detects the wsl host virtual path', () => {
|
||||
expect(isWslHostVirtualPath('//wsl.localhost')).toBe(true);
|
||||
expect(isWslHostVirtualPath('//wsl.localhost/')).toBe(true);
|
||||
expect(isWslHostVirtualPath('//wsl$')).toBe(true);
|
||||
expect(isVirtualDirectoryPath('//wsl.localhost')).toBe(true);
|
||||
expect(isWslHostVirtualPath('//wsl.localhost/Ubuntu')).toBe(false);
|
||||
expect(virtualLocationPathExists('//wsl.localhost/')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns display names for virtual directories', () => {
|
||||
expect(getVirtualLocationDisplayName('sfm://locations', key => `t:${key}`)).toBe('t:locations');
|
||||
expect(getVirtualLocationDisplayName('//wsl.localhost', key => `t:${key}`)).toBe('wsl.localhost');
|
||||
expect(getVirtualLocationDisplayName('C:/', key => `t:${key}`)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -87,6 +102,111 @@ describe('virtual-locations', () => {
|
||||
expect(contents.entries[0]?.path).toBe('C:/');
|
||||
});
|
||||
|
||||
it('keeps wsl distribution drives flat in locations', () => {
|
||||
const localDrive: DriveInfo = {
|
||||
name: 'Local Disk (C:)',
|
||||
path: 'C:/',
|
||||
mount_point: 'C:\\',
|
||||
file_system: 'NTFS',
|
||||
drive_type: 'SSD',
|
||||
total_space: 1000,
|
||||
available_space: 500,
|
||||
used_space: 500,
|
||||
percent_used: 50,
|
||||
is_removable: false,
|
||||
is_read_only: false,
|
||||
is_mounted: true,
|
||||
device_path: '\\\\.\\C:',
|
||||
};
|
||||
const wslDrive: DriveInfo = {
|
||||
name: 'Ubuntu-24.04',
|
||||
path: '//wsl.localhost/Ubuntu-24.04/',
|
||||
mount_point: '//wsl.localhost/Ubuntu-24.04/',
|
||||
file_system: 'WSL',
|
||||
drive_type: 'WSL',
|
||||
total_space: 0,
|
||||
available_space: 0,
|
||||
used_space: 0,
|
||||
percent_used: 0,
|
||||
is_removable: false,
|
||||
is_read_only: false,
|
||||
is_mounted: true,
|
||||
device_path: '//wsl.localhost/Ubuntu-24.04/',
|
||||
};
|
||||
|
||||
const contents = buildLocationsDirectoryFromDrives([localDrive, wslDrive]);
|
||||
|
||||
expect(contents.entries.map(entry => entry.path)).toEqual([
|
||||
'C:/',
|
||||
'//wsl.localhost/Ubuntu-24.04/',
|
||||
]);
|
||||
expect(contents.entries[1]?.item_count).toBeNull();
|
||||
});
|
||||
|
||||
it('builds wsl host directory contents from wsl distribution drives only', () => {
|
||||
const localDrive: DriveInfo = {
|
||||
name: 'Local Disk (C:)',
|
||||
path: 'C:/',
|
||||
mount_point: 'C:\\',
|
||||
file_system: 'NTFS',
|
||||
drive_type: 'SSD',
|
||||
total_space: 1000,
|
||||
available_space: 500,
|
||||
used_space: 500,
|
||||
percent_used: 50,
|
||||
is_removable: false,
|
||||
is_read_only: false,
|
||||
is_mounted: true,
|
||||
device_path: '\\\\.\\C:',
|
||||
};
|
||||
const wslDrive: DriveInfo = {
|
||||
name: 'Ubuntu-24.04',
|
||||
path: '//wsl.localhost/Ubuntu-24.04/',
|
||||
mount_point: '//wsl.localhost/Ubuntu-24.04/',
|
||||
file_system: 'WSL',
|
||||
drive_type: 'WSL',
|
||||
total_space: 0,
|
||||
available_space: 0,
|
||||
used_space: 0,
|
||||
percent_used: 0,
|
||||
is_removable: false,
|
||||
is_read_only: false,
|
||||
is_mounted: true,
|
||||
device_path: '//wsl.localhost/Ubuntu-24.04/',
|
||||
};
|
||||
const contents = buildWslHostDirectoryFromDrives([localDrive, wslDrive]);
|
||||
|
||||
expect(contents.path).toBe(WSL_HOST_VIRTUAL_PATH);
|
||||
expect(contents.entries).toHaveLength(1);
|
||||
expect(contents.entries[0]?.path).toBe('//wsl.localhost/Ubuntu-24.04/');
|
||||
});
|
||||
|
||||
it('builds virtual directories from drive snapshots by path', () => {
|
||||
const wslDrive: DriveInfo = {
|
||||
name: 'Ubuntu-24.04',
|
||||
path: '//wsl.localhost/Ubuntu-24.04/',
|
||||
mount_point: '//wsl.localhost/Ubuntu-24.04/',
|
||||
file_system: 'WSL',
|
||||
drive_type: 'WSL',
|
||||
total_space: 0,
|
||||
available_space: 0,
|
||||
used_space: 0,
|
||||
percent_used: 0,
|
||||
is_removable: false,
|
||||
is_read_only: false,
|
||||
is_mounted: true,
|
||||
device_path: '//wsl.localhost/Ubuntu-24.04/',
|
||||
};
|
||||
|
||||
const locationsContents = buildVirtualDirectoryFromDrives(LOCATIONS_VIRTUAL_PATH, [wslDrive]);
|
||||
|
||||
expect(locationsContents?.path).toBe(LOCATIONS_VIRTUAL_PATH);
|
||||
expect(locationsContents?.entries[0]?.path).toBe('//wsl.localhost/Ubuntu-24.04/');
|
||||
expect(buildVirtualDirectoryFromDrives(WSL_HOST_VIRTUAL_PATH, [wslDrive])?.path)
|
||||
.toBe(WSL_HOST_VIRTUAL_PATH);
|
||||
expect(buildVirtualDirectoryFromDrives('C:/', [wslDrive])).toBeNull();
|
||||
});
|
||||
|
||||
it('detects drive metadata changes in locations display signatures', () => {
|
||||
const baseDrive: DriveInfo = {
|
||||
name: 'Local Disk (C:)',
|
||||
@@ -129,7 +249,8 @@ describe('virtual-locations', () => {
|
||||
expect(getNavigableParentPath('C:/', 'windows')).toBe(LOCATIONS_VIRTUAL_PATH);
|
||||
expect(getNavigableParentPath('C:/Users', 'windows')).toBe('C:/');
|
||||
expect(getNavigableParentPath('//wsl.localhost', 'windows')).toBe(LOCATIONS_VIRTUAL_PATH);
|
||||
expect(getNavigableParentPath('//wsl.localhost/Ubuntu', 'windows')).toBe(LOCATIONS_VIRTUAL_PATH);
|
||||
expect(getNavigableParentPath('//wsl$', 'windows')).toBe(LOCATIONS_VIRTUAL_PATH);
|
||||
expect(getNavigableParentPath('//wsl.localhost/Ubuntu', 'windows')).toBe('//wsl.localhost');
|
||||
expect(getNavigableParentPath('//wsl.localhost/Ubuntu/home', 'windows')).toBe('//wsl.localhost/Ubuntu');
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
import type { ContextMenuAction } from '@/modules/navigator/components/file-browser/types';
|
||||
import { isProtectedSystemPath } from '@/utils/is-protected-system-path';
|
||||
import { isVirtualLocationPath } from '@/utils/virtual-path-constants';
|
||||
import { isVirtualDirectoryPath } from '@/utils/virtual-path-constants';
|
||||
|
||||
export type EntryActionPolicy = 'virtual-location' | 'system-mount-root' | 'normal';
|
||||
|
||||
@@ -33,7 +33,7 @@ const SYSTEM_MOUNT_ROOT_BLOCKED_ACTIONS = new Set<ContextMenuAction>([
|
||||
]);
|
||||
|
||||
export function getEntryActionPolicy(path: string, platform: string | null): EntryActionPolicy {
|
||||
if (isVirtualLocationPath(path)) {
|
||||
if (isVirtualDirectoryPath(path)) {
|
||||
return 'virtual-location';
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export function getEntryActionPolicy(path: string, platform: string | null): Ent
|
||||
}
|
||||
|
||||
export function isSelectionVirtualLocation(entries: DirEntry[]): boolean {
|
||||
return entries.some(entry => isVirtualLocationPath(entry.path));
|
||||
return entries.some(entry => isVirtualDirectoryPath(entry.path));
|
||||
}
|
||||
|
||||
export function isActionBlockedByEntryPolicy(
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
import { isVirtualLocationPath } from '@/utils/virtual-path-constants';
|
||||
import { isVirtualDirectoryPath } from '@/utils/virtual-path-constants';
|
||||
|
||||
export type VirtualLocationActionContext = {
|
||||
isBrowsingVirtualLocations: boolean;
|
||||
@@ -15,7 +15,7 @@ export type VirtualLocationActionContext = {
|
||||
export function isBrowsingVirtualLocations(
|
||||
currentDirectoryPath: string | null | undefined,
|
||||
): boolean {
|
||||
return isVirtualLocationPath(currentDirectoryPath ?? '');
|
||||
return isVirtualDirectoryPath(currentDirectoryPath ?? '');
|
||||
}
|
||||
|
||||
export function getDirectoryPathFromEntry(entry: DirEntry): string | null {
|
||||
@@ -45,7 +45,7 @@ export function getActionTargetEntries(
|
||||
return entries;
|
||||
}
|
||||
|
||||
return entries.filter(entry => !isVirtualLocationPath(entry.path));
|
||||
return entries.filter(entry => !isVirtualDirectoryPath(entry.path));
|
||||
}
|
||||
|
||||
export function resolveActionDirectoryPath(
|
||||
@@ -63,7 +63,7 @@ export function resolveActionDirectoryPath(
|
||||
|
||||
const selectionPath = getDirectoryPathFromEntry(firstTargetEntry);
|
||||
|
||||
if (!selectionPath || isVirtualLocationPath(selectionPath)) {
|
||||
if (!selectionPath || isVirtualDirectoryPath(selectionPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export function resolveActionDirectoryPath(
|
||||
if (entries.length > 0) {
|
||||
const selectionPath = getDirectoryPathFromEntry(entries[0]);
|
||||
|
||||
if (selectionPath && !isVirtualLocationPath(selectionPath)) {
|
||||
if (selectionPath && !isVirtualDirectoryPath(selectionPath)) {
|
||||
return selectionPath;
|
||||
}
|
||||
}
|
||||
|
||||
+109
-37
@@ -4,10 +4,12 @@
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { DirContents, DirEntry, ReadDirOptions } from '@/types/dir-entry';
|
||||
import type { DriveEntryMetadata, DriveInfo } from '@/types/drive-info';
|
||||
import type { DriveInfo } from '@/types/drive-info';
|
||||
import normalizePath, {
|
||||
getParentPath,
|
||||
getPathDisplayName,
|
||||
isUncShareRootPath,
|
||||
isWslPath,
|
||||
} from '@/utils/normalize-path';
|
||||
import { isProtectedSystemPath } from '@/utils/is-protected-system-path';
|
||||
import {
|
||||
@@ -15,23 +17,36 @@ import {
|
||||
isWindowsLocationsScopePath,
|
||||
} from '@/utils/system-mount-roots';
|
||||
import {
|
||||
isVirtualDirectoryPath,
|
||||
isVirtualLocationPath,
|
||||
isWslHostVirtualPath,
|
||||
LOCATIONS_VIRTUAL_PATH,
|
||||
WSL_HOST_VIRTUAL_PATH,
|
||||
} from '@/utils/virtual-path-constants';
|
||||
import { createDriveEntryMetadata } from '@/utils/drive-icon';
|
||||
|
||||
export { isVirtualLocationPath, LOCATIONS_VIRTUAL_PATH } from '@/utils/virtual-path-constants';
|
||||
export {
|
||||
isVirtualDirectoryPath,
|
||||
isVirtualLocationPath,
|
||||
isWslHostVirtualPath,
|
||||
LOCATIONS_VIRTUAL_PATH,
|
||||
WSL_HOST_VIRTUAL_PATH,
|
||||
} from '@/utils/virtual-path-constants';
|
||||
|
||||
export function virtualLocationPathExists(path: string): boolean {
|
||||
return isVirtualLocationPath(path);
|
||||
return isVirtualDirectoryPath(path);
|
||||
}
|
||||
|
||||
export function getVirtualLocationDisplayName(path: string, translate: (key: string) => string): string | null {
|
||||
if (!isVirtualLocationPath(path)) {
|
||||
return null;
|
||||
if (isVirtualLocationPath(path)) {
|
||||
return translate('locations');
|
||||
}
|
||||
|
||||
return translate('locations');
|
||||
if (isWslHostVirtualPath(path)) {
|
||||
return getPathDisplayName(WSL_HOST_VIRTUAL_PATH);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function driveInfoToDirEntry(drive: DriveInfo): DirEntry {
|
||||
@@ -56,16 +71,16 @@ export function driveInfoToDirEntry(drive: DriveInfo): DirEntry {
|
||||
};
|
||||
}
|
||||
|
||||
export function createLocationsDirEntry(): DirEntry {
|
||||
function createVirtualDirectoryEntry(path: string, name: string, itemCount: number | null = null): DirEntry {
|
||||
return {
|
||||
name: '',
|
||||
path: LOCATIONS_VIRTUAL_PATH,
|
||||
name,
|
||||
path,
|
||||
is_file: false,
|
||||
is_dir: true,
|
||||
is_hidden: false,
|
||||
is_symlink: false,
|
||||
size: 0,
|
||||
item_count: null,
|
||||
item_count: itemCount,
|
||||
created_time: 0,
|
||||
modified_time: 0,
|
||||
accessed_time: 0,
|
||||
@@ -74,6 +89,27 @@ export function createLocationsDirEntry(): DirEntry {
|
||||
};
|
||||
}
|
||||
|
||||
export function createLocationsDirEntry(): DirEntry {
|
||||
return createVirtualDirectoryEntry(LOCATIONS_VIRTUAL_PATH, '');
|
||||
}
|
||||
|
||||
export function createWslHostDirEntry(distributionCount: number | null = null): DirEntry {
|
||||
return createVirtualDirectoryEntry(
|
||||
WSL_HOST_VIRTUAL_PATH,
|
||||
getPathDisplayName(WSL_HOST_VIRTUAL_PATH),
|
||||
distributionCount,
|
||||
);
|
||||
}
|
||||
|
||||
export function isWslDistributionDrive(drive: DriveInfo): boolean {
|
||||
const normalizedPath = normalizePath(drive.path);
|
||||
return isWslPath(normalizedPath) && isUncShareRootPath(normalizedPath);
|
||||
}
|
||||
|
||||
export function getWslDistributionDrives(drives: DriveInfo[]): DriveInfo[] {
|
||||
return drives.filter(drive => isWslDistributionDrive(drive));
|
||||
}
|
||||
|
||||
export function buildLocationsDirectoryFromDrives(drives: DriveInfo[]): DirContents {
|
||||
const entries = drives.map(drive => driveInfoToDirEntry(drive));
|
||||
const directoryCount = entries.length;
|
||||
@@ -92,44 +128,27 @@ export function buildLocationsDirectoryFromDrives(drives: DriveInfo[]): DirConte
|
||||
};
|
||||
}
|
||||
|
||||
function formatLocationsDriveDisplaySignaturePart(
|
||||
path: string,
|
||||
name: string,
|
||||
size: number,
|
||||
driveMetadata: DriveEntryMetadata | null | undefined,
|
||||
): string {
|
||||
function formatLocationsEntryDisplaySignature(entry: DirEntry): string {
|
||||
return [
|
||||
normalizePath(path),
|
||||
name,
|
||||
String(size),
|
||||
driveMetadata?.drive_type ?? '',
|
||||
driveMetadata?.is_removable ? '1' : '0',
|
||||
normalizePath(entry.path),
|
||||
entry.name,
|
||||
String(entry.size),
|
||||
entry.drive_metadata?.drive_type ?? '',
|
||||
entry.drive_metadata?.is_removable ? '1' : '0',
|
||||
entry.item_count === null ? '' : String(entry.item_count),
|
||||
].join('\x1f');
|
||||
}
|
||||
|
||||
export function getLocationsDriveListDisplaySignature(drives: DriveInfo[]): string {
|
||||
return drives
|
||||
.map((drive) => {
|
||||
const entry = driveInfoToDirEntry(drive);
|
||||
return formatLocationsDriveDisplaySignaturePart(
|
||||
entry.path,
|
||||
entry.name,
|
||||
entry.size,
|
||||
entry.drive_metadata,
|
||||
);
|
||||
})
|
||||
return buildLocationsDirectoryFromDrives(drives).entries
|
||||
.map(entry => formatLocationsEntryDisplaySignature(entry))
|
||||
.sort()
|
||||
.join('\0');
|
||||
}
|
||||
|
||||
export function getLocationsEntriesDisplaySignature(entries: DirEntry[]): string {
|
||||
return entries
|
||||
.map(entry => formatLocationsDriveDisplaySignaturePart(
|
||||
entry.path,
|
||||
entry.name,
|
||||
entry.size,
|
||||
entry.drive_metadata,
|
||||
))
|
||||
.map(entry => formatLocationsEntryDisplaySignature(entry))
|
||||
.sort()
|
||||
.join('\0');
|
||||
}
|
||||
@@ -139,6 +158,41 @@ export async function readLocationsDirectory(): Promise<DirContents> {
|
||||
return buildLocationsDirectoryFromDrives(drives);
|
||||
}
|
||||
|
||||
export function buildWslHostDirectoryFromDrives(drives: DriveInfo[]): DirContents {
|
||||
const entries = getWslDistributionDrives(drives).map(drive => driveInfoToDirEntry(drive));
|
||||
const directoryCount = entries.length;
|
||||
|
||||
return {
|
||||
path: WSL_HOST_VIRTUAL_PATH,
|
||||
entries,
|
||||
total_count: directoryCount,
|
||||
dir_count: directoryCount,
|
||||
file_count: 0,
|
||||
opened_directory_times: {
|
||||
modified_time: 0,
|
||||
accessed_time: 0,
|
||||
created_time: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readWslHostDirectory(): Promise<DirContents> {
|
||||
const drives = await invoke<DriveInfo[]>('get_system_drives');
|
||||
return buildWslHostDirectoryFromDrives(drives);
|
||||
}
|
||||
|
||||
export function buildVirtualDirectoryFromDrives(path: string, drives: DriveInfo[]): DirContents | null {
|
||||
if (isVirtualLocationPath(path)) {
|
||||
return buildLocationsDirectoryFromDrives(drives);
|
||||
}
|
||||
|
||||
if (isWslHostVirtualPath(path)) {
|
||||
return buildWslHostDirectoryFromDrives(drives);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function resolveDirectoryContents(
|
||||
path: string,
|
||||
options?: ReadDirOptions,
|
||||
@@ -147,6 +201,10 @@ export async function resolveDirectoryContents(
|
||||
return readLocationsDirectory();
|
||||
}
|
||||
|
||||
if (isWslHostVirtualPath(path)) {
|
||||
return readWslHostDirectory();
|
||||
}
|
||||
|
||||
return invoke<DirContents>('read_dir', {
|
||||
path,
|
||||
options,
|
||||
@@ -161,6 +219,10 @@ export async function resolveDirEntry(
|
||||
return createLocationsDirEntry();
|
||||
}
|
||||
|
||||
if (isWslHostVirtualPath(path)) {
|
||||
return createWslHostDirEntry();
|
||||
}
|
||||
|
||||
try {
|
||||
return await invoke<DirEntry>('get_dir_entry_with_timeout', {
|
||||
path,
|
||||
@@ -184,8 +246,18 @@ export function getNavigableParentPath(path: string, platform: string | null): s
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isWslHostVirtualPath(normalizedPath)) {
|
||||
return LOCATIONS_VIRTUAL_PATH;
|
||||
}
|
||||
|
||||
const pathWithoutTrailingSlash = normalizedPath.replace(/\/+$/, '');
|
||||
|
||||
if (platform === 'windows'
|
||||
&& isWslPath(pathWithoutTrailingSlash)
|
||||
&& isUncShareRootPath(pathWithoutTrailingSlash)) {
|
||||
return WSL_HOST_VIRTUAL_PATH;
|
||||
}
|
||||
|
||||
if (isProtectedSystemPath(pathWithoutTrailingSlash, platform)) {
|
||||
return LOCATIONS_VIRTUAL_PATH;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,18 @@
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
export const LOCATIONS_VIRTUAL_PATH = 'sfm://locations';
|
||||
export const WSL_HOST_VIRTUAL_PATH = '//wsl.localhost';
|
||||
|
||||
export function isVirtualLocationPath(path: string): boolean {
|
||||
return path.replace(/\\/g, '/') === LOCATIONS_VIRTUAL_PATH;
|
||||
}
|
||||
|
||||
export function isWslHostVirtualPath(path: string): boolean {
|
||||
const normalizedPath = path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
||||
|
||||
return normalizedPath === WSL_HOST_VIRTUAL_PATH || normalizedPath === '//wsl$';
|
||||
}
|
||||
|
||||
export function isVirtualDirectoryPath(path: string): boolean {
|
||||
return isVirtualLocationPath(path) || isWslHostVirtualPath(path);
|
||||
}
|
||||
Reference in new issue
Block a user