mirror of
https://github.com/aleksey-hoffman/sigma-file-manager.git
synced 2026-10-19 01:31:05 -05:00
UX: Add "disconnect" option to context menu
This commit is contained in:
1 parent
2013cdb98d
commit
0d77129ff7
35 files changed
+426
-18
No files matched your search
@@ -82,6 +82,15 @@ pub fn unmount_drive(device_path: String, mount_point: String) -> Result<(), Str
|
||||
mount::unmount_drive(device_path, mount_point)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn disconnect_drive(
|
||||
device_path: String,
|
||||
mount_point: String,
|
||||
drive_type: String,
|
||||
) -> Result<(), String> {
|
||||
mount::disconnect_drive(device_path, mount_point, drive_type)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn mount_network_share(params: NetworkShareParams) -> Result<String, String> {
|
||||
network_shares::mount_network_share(params)
|
||||
|
||||
@@ -63,6 +63,79 @@ pub fn mount_drive(device_path: String) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disconnect_drive(
|
||||
device_path: String,
|
||||
mount_point: String,
|
||||
drive_type: String,
|
||||
) -> Result<(), String> {
|
||||
if drive_type == "Network" {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return disconnect_windows_network_drive(&mount_point);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
return unmount_drive(device_path, mount_point);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let _ = (device_path, mount_point);
|
||||
return Err("Disconnect is not supported for this drive on Windows".to_string());
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
unmount_drive(device_path, mount_point)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn disconnect_windows_network_drive(mount_point: &str) -> Result<(), String> {
|
||||
let drive_letter = parse_windows_drive_letter(mount_point)
|
||||
.ok_or_else(|| format!("Invalid network drive mount point: {}", mount_point.trim()))?;
|
||||
|
||||
let output = std::process::Command::new("net")
|
||||
.args(["use", &drive_letter, "/delete", "/y"])
|
||||
.output()
|
||||
.map_err(|run_error| format!("Failed to run 'net use': {}", run_error))?;
|
||||
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let stderr = super::drives_platform::decode_windows_command_output(&output.stderr);
|
||||
let stdout = super::drives_platform::decode_windows_command_output(&output.stdout);
|
||||
let message = if !stderr.trim().is_empty() {
|
||||
stderr
|
||||
} else {
|
||||
stdout
|
||||
};
|
||||
|
||||
Err(format!("net use failed: {}", message.trim()))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn parse_windows_drive_letter(mount_point: &str) -> Option<String> {
|
||||
let trimmed = mount_point.trim().trim_end_matches(['\\', '/']);
|
||||
|
||||
if trimmed.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut characters = trimmed.chars();
|
||||
let drive_letter = characters.next()?;
|
||||
let colon = characters.next()?;
|
||||
|
||||
if colon != ':' || !drive_letter.is_ascii_alphabetic() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(format!("{}:", drive_letter.to_ascii_uppercase()))
|
||||
}
|
||||
|
||||
pub fn unmount_drive(device_path: String, mount_point: String) -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
@@ -136,3 +209,15 @@ fn linux_unmount(device_path: &str, mount_point: &str) -> Result<(), String> {
|
||||
mount_point
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod tests {
|
||||
use super::parse_windows_drive_letter;
|
||||
|
||||
#[test]
|
||||
fn parse_windows_drive_letter_accepts_common_mount_points() {
|
||||
assert_eq!(parse_windows_drive_letter("Z:\\"), Some("Z:".to_string()));
|
||||
assert_eq!(parse_windows_drive_letter("z:/"), Some("Z:".to_string()));
|
||||
assert_eq!(parse_windows_drive_letter("Apps"), None);
|
||||
}
|
||||
}
|
||||
@@ -308,6 +308,7 @@ pub fn run() {
|
||||
dir_reader::get_mountable_devices,
|
||||
dir_reader::mount_drive,
|
||||
dir_reader::unmount_drive,
|
||||
dir_reader::disconnect_drive,
|
||||
dir_reader::mount_network_share,
|
||||
dir_size::get_dir_size,
|
||||
dir_size::get_dir_sizes_batch,
|
||||
|
||||
@@ -17,6 +17,7 @@ import PermanentDeleteConfirmDialog from '@/modules/navigator/components/file-br
|
||||
import { useDirEntryActions } from '@/composables/use-dir-entry-actions';
|
||||
import { CONTEXT_MENU_OPEN_COUNT_KEY } from './index';
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
import type { DriveEntryMetadata } from '@/types/drive-info';
|
||||
import { getPathDisplayName } from '@/utils/normalize-path';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
@@ -25,11 +26,13 @@ const props = withDefaults(defineProps<{
|
||||
disableDestructiveActions?: boolean;
|
||||
disableDropTarget?: boolean;
|
||||
isCurrentDirectoryContext?: boolean;
|
||||
driveMetadata?: DriveEntryMetadata | null;
|
||||
}>(), {
|
||||
isFile: false,
|
||||
disableDestructiveActions: false,
|
||||
disableDropTarget: false,
|
||||
isCurrentDirectoryContext: false,
|
||||
driveMetadata: null,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -103,6 +106,7 @@ const dirEntry = computed<DirEntry>(() => {
|
||||
is_dir: !props.isFile,
|
||||
is_symlink: false,
|
||||
is_hidden: name.startsWith('.'),
|
||||
drive_metadata: props.driveMetadata,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ import { openNavigatorNavigablePath } from '@/utils/open-navigator-directory';
|
||||
import { usePermanentDeleteConfirm } from '@/composables/use-permanent-delete-confirm';
|
||||
import { usePlatformStore } from '@/stores/runtime/platform';
|
||||
import { openNativeProperties } from '@/utils/open-native-properties';
|
||||
import { disconnectDriveForEntry } from '@/utils/disconnect-drive';
|
||||
import { refreshDrives } from '@/modules/home/composables/use-drives';
|
||||
|
||||
export function useDirEntryActions() {
|
||||
const { t } = useI18n();
|
||||
@@ -445,6 +447,21 @@ export function useDirEntryActions() {
|
||||
}
|
||||
}
|
||||
|
||||
async function disconnectDriveEntry(entry: DirEntry) {
|
||||
const result = await disconnectDriveForEntry(entry);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Failed to disconnect drive:', result.error);
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshDrives();
|
||||
}
|
||||
catch (refreshError) {
|
||||
console.error('Failed to refresh drives after disconnect:', refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
function handleContextMenuAction(action: ContextMenuAction, entries: DirEntry[]) {
|
||||
if (entries.length === 0) return;
|
||||
|
||||
@@ -495,6 +512,11 @@ export function useDirEntryActions() {
|
||||
case 'properties':
|
||||
void openProperties(entries);
|
||||
break;
|
||||
case 'disconnect':
|
||||
if (entries.length === 1) {
|
||||
void disconnectDriveEntry(entries[0]);
|
||||
}
|
||||
break;
|
||||
case 'rename':
|
||||
case 'open-with':
|
||||
case 'edit-tags':
|
||||
|
||||
@@ -749,6 +749,7 @@
|
||||
"driveNotMounted": "未挂载",
|
||||
"mounting": "挂载中",
|
||||
"unmount": "卸载",
|
||||
"disconnect": "断开连接",
|
||||
"mount": "挂载",
|
||||
"mountableDevices": "挂载设备",
|
||||
"mountableDevicesDescription": "发现的可挂载设备",
|
||||
|
||||
@@ -749,6 +749,7 @@
|
||||
"driveNotMounted": "Nicht eingehängt",
|
||||
"mounting": "Einhängen",
|
||||
"unmount": "Aushängen",
|
||||
"disconnect": "Trennen",
|
||||
"mount": "Einhängen",
|
||||
"mountableDevices": "Gerät einhängen",
|
||||
"mountableDevicesDescription": "Erkannte Geräte, die eingehängt werden können",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"driveNotMounted": "Not mounted",
|
||||
"mounting": "Mounting",
|
||||
"unmount": "Unmount",
|
||||
"disconnect": "Disconnect",
|
||||
"mount": "Mount",
|
||||
"mountableDevices": "Mount a device",
|
||||
"mountableDevicesDescription": "Discovered devices that can be mounted",
|
||||
|
||||
@@ -749,6 +749,7 @@
|
||||
"driveNotMounted": "No montado",
|
||||
"mounting": "Montando",
|
||||
"unmount": "Desmontar",
|
||||
"disconnect": "Desconectar",
|
||||
"mount": "Montar",
|
||||
"mountableDevices": "Montar un dispositivo",
|
||||
"mountableDevicesDescription": "Dispositivos descubiertos que se pueden montar",
|
||||
|
||||
@@ -745,6 +745,7 @@
|
||||
"driveNotMounted": "نصب نشده",
|
||||
"mounting": "در حال نصب",
|
||||
"unmount": "جدا کردن",
|
||||
"disconnect": "قطع اتصال",
|
||||
"mount": "نصب",
|
||||
"mountableDevices": "نصب دستگاه",
|
||||
"mountableDevicesDescription": "دستگاههای کشفشده قابل نصب",
|
||||
|
||||
@@ -749,6 +749,7 @@
|
||||
"driveNotMounted": "Non monté",
|
||||
"mounting": "Montage",
|
||||
"unmount": "Démonter",
|
||||
"disconnect": "Déconnecter",
|
||||
"mount": "Monter",
|
||||
"mountableDevices": "Monter un périphérique",
|
||||
"mountableDevicesDescription": "Périphériques détectés pouvant être montés",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"driveNotMounted": "לא מעוגן",
|
||||
"mounting": "מעגן",
|
||||
"unmount": "בטל עיגון",
|
||||
"disconnect": "נתק",
|
||||
"mount": "עגן",
|
||||
"mountableDevices": "עגן התקן",
|
||||
"mountableDevicesDescription": "התקנים שזוהו וניתנים לעיגון",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"driveNotMounted": "नहीं लगाया हुआ है",
|
||||
"mounting": "बढ़ते",
|
||||
"unmount": "अनमाउंट",
|
||||
"disconnect": "डिस्कनेक्ट करें",
|
||||
"mount": "पर्वत",
|
||||
"mountableDevices": "एक उपकरण माउंट करें",
|
||||
"mountableDevicesDescription": "ऐसे उपकरण खोजे गए जिन्हें माउंट किया जा सकता है",
|
||||
|
||||
@@ -749,6 +749,7 @@
|
||||
"driveNotMounted": "Non montato",
|
||||
"mounting": "Montaggio",
|
||||
"unmount": "Smonta",
|
||||
"disconnect": "Disconnetti",
|
||||
"mount": "Monta",
|
||||
"mountableDevices": "Monta un dispositivo",
|
||||
"mountableDevicesDescription": "Dispositivi rilevati che possono essere montati",
|
||||
|
||||
@@ -749,6 +749,7 @@
|
||||
"driveNotMounted": "未マウント",
|
||||
"mounting": "マウント中",
|
||||
"unmount": "アンマウント",
|
||||
"disconnect": "切断",
|
||||
"mount": "マウント",
|
||||
"mountableDevices": "デバイスをマウント",
|
||||
"mountableDevicesDescription": "マウント可能なデバイス",
|
||||
|
||||
@@ -748,6 +748,7 @@
|
||||
"driveNotMounted": "Não montado",
|
||||
"mounting": "Montando",
|
||||
"unmount": "Desmontar",
|
||||
"disconnect": "Desconectar",
|
||||
"mount": "Montar",
|
||||
"mountableDevices": "Montar um dispositivo",
|
||||
"mountableDevicesDescription": "Dispositivos descobertos que podem ser montados",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"driveNotMounted": "Не подключено",
|
||||
"mounting": "Подключение",
|
||||
"unmount": "Отключить",
|
||||
"disconnect": "Отключить",
|
||||
"mount": "Подключить",
|
||||
"mountableDevices": "Подключить устройство",
|
||||
"mountableDevicesDescription": "Обнаруженные устройства, которые можно подключить",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"driveNotMounted": "Ni priklopljeno",
|
||||
"mounting": "Priklapljanje",
|
||||
"unmount": "Odklopi",
|
||||
"disconnect": "Prekini povezavo",
|
||||
"mount": "Priklopi",
|
||||
"mountableDevices": "Priklopi napravo",
|
||||
"mountableDevicesDescription": "Odkrite naprave, ki jih je mogoče priklopiti",
|
||||
|
||||
@@ -749,6 +749,7 @@
|
||||
"driveNotMounted": "Bağlı değil",
|
||||
"mounting": "Bağlanıyor",
|
||||
"unmount": "Çıkar",
|
||||
"disconnect": "Bağlantıyı kes",
|
||||
"mount": "Bağla",
|
||||
"mountableDevices": "Bir aygıt bağla",
|
||||
"mountableDevicesDescription": "Bağlanabilir bulunan aygıtlar",
|
||||
|
||||
@@ -779,6 +779,7 @@
|
||||
"driveNotMounted": "منسلک نہیں",
|
||||
"mounting": "منسلک ہو رہا ہے",
|
||||
"unmount": "الگ کریں",
|
||||
"disconnect": "منقطع کریں",
|
||||
"mount": "منسلک کریں",
|
||||
"mountableDevices": "منسلک ہونے والی ڈیوائسز",
|
||||
"mountableDevicesDescription": "ملنے والی ڈیوائسز جن کو منسلک کیا جا سکے",
|
||||
|
||||
@@ -749,6 +749,7 @@
|
||||
"driveNotMounted": "Chưa gắn kết",
|
||||
"mounting": "Đang gắn kết",
|
||||
"unmount": "Gỡ bỏ",
|
||||
"disconnect": "Ngắt kết nối",
|
||||
"mount": "Gắn kết",
|
||||
"mountableDevices": "Gắn kết thiết bị",
|
||||
"mountableDevicesDescription": "Các thiết bị được phát hiện có thể gắn kết",
|
||||
|
||||
@@ -15,6 +15,7 @@ import DriveCard from './drive-card.vue';
|
||||
import MountDialog from './mount-dialog.vue';
|
||||
import { getStaggerSlideUpBinding } from '@/utils/stagger-animation';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { createDriveEntryMetadata } from '@/utils/drive-icon';
|
||||
|
||||
const { t } = useI18n();
|
||||
const { drives, isLoading, error, refresh } = useDrives();
|
||||
@@ -87,6 +88,7 @@ const sectionTitle = computed(() => {
|
||||
v-for="(drive, itemIndex) in drives"
|
||||
:key="drive.path"
|
||||
:path="drive.path"
|
||||
:drive-metadata="createDriveEntryMetadata(drive)"
|
||||
>
|
||||
<DriveCard
|
||||
v-bind="getStaggerSlideUpBinding(itemIndex, {initialDelayMs: 300})"
|
||||
|
||||
@@ -199,4 +199,4 @@ export function useDrives() {
|
||||
};
|
||||
}
|
||||
|
||||
export { drives as sharedDrives, getDriveByPath };
|
||||
export { drives as sharedDrives, getDriveByPath, refresh as refreshDrives };
|
||||
+24
@@ -35,6 +35,8 @@ import { isContextMenuActionVisible } from '@/modules/navigator/components/file-
|
||||
import { resolveNavigableItemTarget } from '@/utils/resolve-navigable-item-target';
|
||||
import type { CreateLinksResult, LinkCreationKind } from '@/utils/link-operations';
|
||||
import { getFileBrowserVisualEntryOrder } from '../file-browser-entry-groups';
|
||||
import { disconnectDriveForEntry } from '@/utils/disconnect-drive';
|
||||
import { refreshDrives } from '@/modules/home/composables/use-drives';
|
||||
|
||||
export const FILE_BROWSER_REVEAL_STALE_FOCUS_GUARD_MS = 500;
|
||||
|
||||
@@ -342,6 +344,22 @@ export function useFileBrowserSelection(
|
||||
});
|
||||
}
|
||||
|
||||
async function disconnectDriveEntry(entry: DirEntry) {
|
||||
const result = await disconnectDriveForEntry(entry);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Failed to disconnect drive:', result.error);
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshDrives();
|
||||
onRefresh();
|
||||
}
|
||||
catch (refreshError) {
|
||||
console.error('Failed to refresh after drive disconnect:', refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
function copyItems(entries: DirEntry[]) {
|
||||
if (!canPerformAction('copy', entries)) {
|
||||
return;
|
||||
@@ -987,6 +1005,12 @@ export function useFileBrowserSelection(
|
||||
startShare(entries);
|
||||
}
|
||||
|
||||
break;
|
||||
case 'disconnect':
|
||||
if (entries.length === 1 && canPerformAction('disconnect', entries)) {
|
||||
void disconnectDriveEntry(entries[0]);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
@@ -400,6 +400,14 @@ function handleCreateLink(linkKind: LinkCreationKind) {
|
||||
{{ shortcutsStore.getShortcutLabel('openNewTab') }}
|
||||
</ContextMenuShortcut>
|
||||
</component>
|
||||
<component
|
||||
:is="menuItemComponent"
|
||||
v-if="isActionVisible('disconnect')"
|
||||
@select="emitAction('disconnect')"
|
||||
>
|
||||
<UnplugIcon :size="16" />
|
||||
<span>{{ t('disconnect') }}</span>
|
||||
</component>
|
||||
<component
|
||||
:is="menuItemComponent"
|
||||
v-if="isActionVisible('copy-path')"
|
||||
|
||||
@@ -38,7 +38,7 @@ export interface GroupedEntries {
|
||||
others: DirEntry[];
|
||||
}
|
||||
|
||||
export type ContextMenuAction = 'rename' | 'copy' | 'cut' | 'link' | 'paste' | 'delete' | 'delete-permanently' | 'open-with' | 'properties' | 'quick-view' | 'print' | 'share' | 'open-in-new-tab' | 'toggle-favorite' | 'edit-tags' | 'copy-path' | 'create-file' | 'create-directory';
|
||||
export type ContextMenuAction = 'rename' | 'copy' | 'cut' | 'link' | 'paste' | 'delete' | 'delete-permanently' | 'open-with' | 'properties' | 'quick-view' | 'print' | 'share' | 'open-in-new-tab' | 'toggle-favorite' | 'edit-tags' | 'copy-path' | 'create-file' | 'create-directory' | 'disconnect';
|
||||
|
||||
export type SelectionType = 'single' | 'multiple';
|
||||
export type EntryType = 'file' | 'directory';
|
||||
|
||||
+22
@@ -79,4 +79,26 @@ describe('isContextMenuActionVisible', () => {
|
||||
expect(isContextMenuActionVisible('delete', entries, { platform: 'windows' })).toBe(true);
|
||||
expect(isContextMenuActionVisible('rename', entries, { platform: 'windows' })).toBe(true);
|
||||
});
|
||||
|
||||
it('shows disconnect for network drive entries', () => {
|
||||
const entries: DirEntry[] = [{
|
||||
...createDirectoryEntry('Z:/'),
|
||||
name: 'test (Z:)',
|
||||
drive_metadata: {
|
||||
drive_type: 'Network',
|
||||
is_removable: false,
|
||||
is_mounted: true,
|
||||
mount_point: 'Z:\\',
|
||||
device_path: 'Z:\\',
|
||||
},
|
||||
}];
|
||||
|
||||
expect(isContextMenuActionVisible('disconnect', entries, { platform: 'windows' })).toBe(true);
|
||||
});
|
||||
|
||||
it('hides disconnect for fixed local drive roots', () => {
|
||||
const entries = [createDirectoryEntry('D:/')];
|
||||
|
||||
expect(isContextMenuActionVisible('disconnect', entries, { platform: 'windows' })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
import type { ContextMenuAction, ContextMenuItemConfig, EntryType, SelectionType } from '@/modules/navigator/components/file-browser/types';
|
||||
import { isActionBlockedByEntryPolicy } from '@/utils/entry-action-policy';
|
||||
import { canDisconnectDriveEntry } from '@/utils/drive-disconnect-policy';
|
||||
|
||||
const CONTEXT_MENU_ITEMS: ContextMenuItemConfig[] = [
|
||||
{
|
||||
@@ -119,6 +120,14 @@ export function isContextMenuActionVisible(
|
||||
return isContextMenuActionVisible('delete', entries, options);
|
||||
}
|
||||
|
||||
if (action === 'disconnect') {
|
||||
if (entries.length !== 1 || !entries[0].is_dir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return canDisconnectDriveEntry(entries[0], options?.platform ?? null);
|
||||
}
|
||||
|
||||
const config = CONTEXT_MENU_ITEMS.find(item => item.action === action);
|
||||
|
||||
if (!config) {
|
||||
|
||||
@@ -21,6 +21,9 @@ export type DriveInfo = {
|
||||
export type DriveEntryMetadata = {
|
||||
drive_type: string;
|
||||
is_removable: boolean;
|
||||
is_mounted: boolean;
|
||||
mount_point: string;
|
||||
device_path: string;
|
||||
};
|
||||
|
||||
export type MountableDevice = {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
import {
|
||||
canDisconnectDriveEntry,
|
||||
canDisconnectDriveMetadata,
|
||||
getDisconnectDriveTarget,
|
||||
} from '@/utils/drive-disconnect-policy';
|
||||
|
||||
function createNetworkDriveEntry(path = 'Z:/'): DirEntry {
|
||||
return {
|
||||
name: 'test (Z:)',
|
||||
path,
|
||||
is_file: false,
|
||||
is_dir: true,
|
||||
is_hidden: false,
|
||||
is_symlink: false,
|
||||
size: 1000,
|
||||
item_count: null,
|
||||
created_time: 0,
|
||||
modified_time: 0,
|
||||
accessed_time: 0,
|
||||
ext: null,
|
||||
mime: null,
|
||||
drive_metadata: {
|
||||
drive_type: 'Network',
|
||||
is_removable: false,
|
||||
is_mounted: true,
|
||||
mount_point: 'Z:\\',
|
||||
device_path: 'Z:\\',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('canDisconnectDriveEntry', () => {
|
||||
it('shows disconnect for network drive entries using metadata', () => {
|
||||
expect(canDisconnectDriveEntry(createNetworkDriveEntry(), 'windows')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not show disconnect for regular directories', () => {
|
||||
const entry: DirEntry = {
|
||||
...createNetworkDriveEntry('D:/Apps'),
|
||||
drive_metadata: undefined,
|
||||
};
|
||||
|
||||
expect(canDisconnectDriveEntry(entry, 'windows')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDisconnectDriveTarget', () => {
|
||||
it('builds a backend command target from entry metadata', () => {
|
||||
const entry = createNetworkDriveEntry();
|
||||
const target = getDisconnectDriveTarget(entry);
|
||||
|
||||
expect(target).toEqual({
|
||||
devicePath: 'Z:\\',
|
||||
mountPoint: 'Z:\\',
|
||||
driveType: 'Network',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('canDisconnectDriveMetadata', () => {
|
||||
it('allows network metadata on every platform', () => {
|
||||
expect(canDisconnectDriveMetadata({
|
||||
drive_type: 'Network',
|
||||
is_removable: false,
|
||||
is_mounted: true,
|
||||
mount_point: 'Z:\\',
|
||||
device_path: 'Z:\\',
|
||||
}, 'windows')).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks unmounted metadata', () => {
|
||||
expect(canDisconnectDriveMetadata({
|
||||
drive_type: 'Network',
|
||||
is_removable: false,
|
||||
is_mounted: false,
|
||||
mount_point: 'Z:\\',
|
||||
device_path: 'Z:\\',
|
||||
}, 'windows')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -6,33 +6,35 @@ import { describe, expect, it } from 'vitest';
|
||||
import { HardDriveIcon, NetworkIcon, UsbIcon } from '@lucide/vue';
|
||||
import UbuntuWslIcon from '@/components/icons/ubuntu-wsl-icon.vue';
|
||||
import { getDriveIconComponent } from '@/utils/drive-icon';
|
||||
import type { DriveEntryMetadata } from '@/types/drive-info';
|
||||
|
||||
function createDriveMetadata(
|
||||
driveType: string,
|
||||
isRemovable: boolean,
|
||||
): DriveEntryMetadata {
|
||||
return {
|
||||
drive_type: driveType,
|
||||
is_removable: isRemovable,
|
||||
is_mounted: true,
|
||||
mount_point: '',
|
||||
device_path: '',
|
||||
};
|
||||
}
|
||||
|
||||
describe('getDriveIconComponent', () => {
|
||||
it('returns the wsl icon for wsl drives', () => {
|
||||
expect(getDriveIconComponent({
|
||||
drive_type: 'WSL',
|
||||
is_removable: false,
|
||||
})).toBe(UbuntuWslIcon);
|
||||
expect(getDriveIconComponent(createDriveMetadata('WSL', false))).toBe(UbuntuWslIcon);
|
||||
});
|
||||
|
||||
it('returns the network icon for network drives', () => {
|
||||
expect(getDriveIconComponent({
|
||||
drive_type: 'Network',
|
||||
is_removable: false,
|
||||
})).toBe(NetworkIcon);
|
||||
expect(getDriveIconComponent(createDriveMetadata('Network', false))).toBe(NetworkIcon);
|
||||
});
|
||||
|
||||
it('returns the usb icon for removable drives', () => {
|
||||
expect(getDriveIconComponent({
|
||||
drive_type: 'SSD',
|
||||
is_removable: true,
|
||||
})).toBe(UsbIcon);
|
||||
expect(getDriveIconComponent(createDriveMetadata('SSD', true))).toBe(UsbIcon);
|
||||
});
|
||||
|
||||
it('returns the hard drive icon for fixed local drives', () => {
|
||||
expect(getDriveIconComponent({
|
||||
drive_type: 'SSD',
|
||||
is_removable: false,
|
||||
})).toBe(HardDriveIcon);
|
||||
expect(getDriveIconComponent(createDriveMetadata('SSD', false))).toBe(HardDriveIcon);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,9 @@ describe('virtual-locations', () => {
|
||||
expect(driveEntry.drive_metadata).toEqual({
|
||||
drive_type: 'SSD',
|
||||
is_removable: false,
|
||||
is_mounted: true,
|
||||
mount_point: 'C:\\',
|
||||
device_path: '\\\\.\\C:',
|
||||
});
|
||||
expect(driveEntry.item_count).toBeNull();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
import {
|
||||
getDisconnectDriveTarget,
|
||||
type DisconnectDriveResult,
|
||||
type DisconnectDriveTarget,
|
||||
} from '@/utils/drive-disconnect-policy';
|
||||
|
||||
export async function disconnectDrive(target: DisconnectDriveTarget): Promise<DisconnectDriveResult> {
|
||||
try {
|
||||
await invoke('disconnect_drive', {
|
||||
devicePath: target.devicePath,
|
||||
mountPoint: target.mountPoint,
|
||||
driveType: target.driveType,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
catch (disconnectError) {
|
||||
return {
|
||||
success: false,
|
||||
error: disconnectError instanceof Error ? disconnectError.message : String(disconnectError),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function disconnectDriveForEntry(entry: DirEntry): Promise<DisconnectDriveResult> {
|
||||
const target = getDisconnectDriveTarget(entry);
|
||||
|
||||
if (!target) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Drive metadata is missing for ${entry.path}`,
|
||||
};
|
||||
}
|
||||
|
||||
return disconnectDrive(target);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// License: GNU GPLv3 or later. See the license file in the project root for more information.
|
||||
// Copyright © 2021 - present Aleksey Hoffman. All rights reserved.
|
||||
|
||||
import type { DirEntry } from '@/types/dir-entry';
|
||||
import type { DriveEntryMetadata } from '@/types/drive-info';
|
||||
|
||||
export type DisconnectDriveTarget = {
|
||||
devicePath: string;
|
||||
mountPoint: string;
|
||||
driveType: string;
|
||||
};
|
||||
|
||||
export type DisconnectDriveResult = {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export function canDisconnectDriveMetadata(
|
||||
metadata: DriveEntryMetadata,
|
||||
platform: string | null,
|
||||
): boolean {
|
||||
if (!metadata.is_mounted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (metadata.drive_type === 'Network') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (metadata.drive_type === 'WSL') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (platform === 'windows') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return metadata.is_removable;
|
||||
}
|
||||
|
||||
export function canDisconnectDriveEntry(
|
||||
entry: DirEntry,
|
||||
platform: string | null,
|
||||
): boolean {
|
||||
if (!entry.is_dir) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return entry.drive_metadata
|
||||
? canDisconnectDriveMetadata(entry.drive_metadata, platform)
|
||||
: false;
|
||||
}
|
||||
|
||||
export function getDisconnectDriveTarget(entry: DirEntry): DisconnectDriveTarget | null {
|
||||
const metadata = entry.drive_metadata;
|
||||
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
devicePath: metadata.device_path,
|
||||
mountPoint: metadata.mount_point,
|
||||
driveType: metadata.drive_type,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,9 @@ export function createDriveEntryMetadata(drive: DriveInfo): DriveEntryMetadata {
|
||||
return {
|
||||
drive_type: drive.drive_type,
|
||||
is_removable: drive.is_removable,
|
||||
is_mounted: drive.is_mounted,
|
||||
mount_point: drive.mount_point,
|
||||
device_path: drive.device_path,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user