Skip to content

Commit 9deba72

Browse files
committed
MTP: Fix: Pane falls back to local root after copy
- Guard `handle_directory_change` with `supports_watching()` check: the `refresh_listing` command was calling `list_directory_core` (std::fs) for MTP listings, which returned `NotFound` for paths like `/DCIM` and emitted a spurious `directory-deleted` event - Fix `toVolumeRelativePath` for non-local volumes: when `volumePath` uses a URL scheme (`mtp://`, etc.) and `fullPath` is already volume-relative, pass it through instead of falling back to `/` - Fix `start_scan_preview` runtime context: make the Tauri command `async` so `Handle::current()` works, pass the handle to the spawned thread via `handle.enter()` for MTP volume scans
1 parent 547a413 commit 9deba72

8 files changed

Lines changed: 116 additions & 78 deletions

File tree

apps/desktop/src-tauri/src/commands/file_system.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -540,7 +540,15 @@ pub async fn start_scan_preview(
540540
};
541541

542542
let progress_interval = progress_interval_ms.unwrap_or(500);
543-
ops_start_scan_preview(app, sources, source_volume, sort_column, sort_order, progress_interval, runtime_handle)
543+
ops_start_scan_preview(
544+
app,
545+
sources,
546+
source_volume,
547+
sort_column,
548+
sort_order,
549+
progress_interval,
550+
runtime_handle,
551+
)
544552
}
545553

546554
#[tauri::command]

apps/desktop/src-tauri/src/file_system/watcher.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ pub fn init_watcher_manager(app: AppHandle) {
113113
///
114114
/// Note: Initial entries are read from LISTING_CACHE when needed.
115115
pub fn start_watching(listing_id: &str, path: &Path) -> Result<(), String> {
116+
log::debug!("start_watching: listing_id={}, path={}", listing_id, path.display());
116117
let listing_id_owned = listing_id.to_string();
117118
let listing_for_closure = listing_id_owned.clone();
118119

@@ -346,6 +347,28 @@ fn handle_directory_change_incremental(listing_id: &str, events: Vec<DebouncedEv
346347
/// Called by the file watcher on change events, and also available as a Tauri
347348
/// command for cases where the watcher doesn't fire (e.g. rename-move on Linux).
348349
pub fn handle_directory_change(listing_id: &str) {
350+
log::debug!("handle_directory_change: listing_id={}", listing_id);
351+
352+
// This function uses std::fs which only works for local volumes.
353+
// Non-local volumes (MTP, network, etc.) have their own change detection mechanisms.
354+
// Check via supports_watching() — the same guard used when starting watchers.
355+
{
356+
use crate::file_system::listing::caching::LISTING_CACHE;
357+
if let Ok(cache) = LISTING_CACHE.read() {
358+
if let Some(listing) = cache.get(listing_id) {
359+
if let Some(vol) = crate::file_system::get_volume_manager().get(&listing.volume_id) {
360+
if !vol.supports_watching() {
361+
log::debug!(
362+
"handle_directory_change: skipping non-watchable volume (volume={})",
363+
listing.volume_id
364+
);
365+
return;
366+
}
367+
}
368+
}
369+
}
370+
}
371+
349372
// Get old entries and path from the unified LISTING_CACHE
350373
let Some((path, old_entries)) = get_listing_entries(listing_id) else {
351374
return; // Listing no longer exists

apps/desktop/src/lib/file-explorer/navigation/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,8 @@ Pure logic for organizing volumes into display groups. No reactive state.
130130
Reactive state machine for fetching, retrying, and caching disk space info per volume. Created via
131131
`createVolumeSpaceManager()` (functional factory, no classes).
132132

133-
`getVolumeSpace()` returns `TimedOut<T>` wrappers. The manager tracks timeout state and exposes
134-
reactive sets for the component to render inline indicators (no toasts):
133+
`getVolumeSpace()` returns `TimedOut<T>` wrappers. The manager tracks timeout state and exposes reactive sets for the
134+
component to render inline indicators (no toasts):
135135

136136
- **Volume space timeout** (`spaceTimedOutSet`): Three-state cycle with per-volume tracking:
137137
- **Idle**: Dashed-outline placeholder bar with "?" icon, "Unavailable" text, tooltip "Couldn't fetch disk space --

apps/desktop/src/lib/file-operations/transfer/TransferDialog.svelte

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,9 @@
9595
// so look up the volume path directly from the props.
9696
const initialVolumePath = volumes.find((v) => v.id === currentVolumeId)?.path ?? '/'
9797
let editedPath = $state(toVolumeRelativePath(destinationPath, initialVolumePath))
98+
log.debug('Initial path resolution: destinationPath={destinationPath}, currentVolumeId={currentVolumeId}, initialVolumePath={initialVolumePath}, editedPath={editedPath}', {
99+
destinationPath, currentVolumeId, initialVolumePath, editedPath,
100+
})
98101
let selectedVolumeId = $state(currentVolumeId)
99102
let pathInputRef: HTMLInputElement | undefined = $state()
100103

apps/desktop/src/lib/file-operations/transfer/transfer-dialog-utils.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ export function toBackendCursorIndex(frontendIndex: number, hasParent: boolean):
8888

8989
/** Strips the volume prefix to get a volume-relative path. Always returns a `/`-prefixed string. */
9090
export function toVolumeRelativePath(fullPath: string, volumePath: string): string {
91+
// MTP/non-local volumes: fullPath may already be volume-relative (like "/DCIM")
92+
// while volumePath is a URL (like "mtp://device/storage"). Just pass through.
93+
if (!fullPath.startsWith(volumePath) && volumePath.includes('://')) {
94+
return fullPath || '/'
95+
}
9196
if (volumePath === '/') return fullPath
9297
if (fullPath.startsWith(volumePath)) {
9398
return fullPath.slice(volumePath.length) || '/'

apps/desktop/src/lib/stores/volume-store.svelte.ts

Lines changed: 58 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ const logger = getAppLogger('volume-store')
1717

1818
/** Payload shape matching Rust's `VolumesChangedPayload`. */
1919
interface VolumesChangedPayload {
20-
data: VolumeInfo[]
21-
timedOut: boolean
20+
data: VolumeInfo[]
21+
timedOut: boolean
2222
}
2323

2424
let volumes = $state<VolumeInfo[]>([])
@@ -32,23 +32,23 @@ let unlistenVolumesChanged: UnlistenFn | undefined
3232

3333
/** Returns the current volume list. Reactive. */
3434
export function getVolumes(): VolumeInfo[] {
35-
return volumes
35+
return volumes
3636
}
3737

3838
/** Returns whether the last volume listing timed out (some volumes may be missing). Reactive. */
3939
export function getVolumesTimedOut(): boolean {
40-
return timedOut
40+
return timedOut
4141
}
4242

4343
/** Returns whether a volume refresh is in progress. Reactive. */
4444
export function isVolumesRefreshing(): boolean {
45-
return refreshing
45+
return refreshing
4646
}
4747

4848
/** Returns whether a retry just completed but the listing is still timed out. Reactive.
4949
* Auto-resets to false after 3 seconds. */
5050
export function isVolumeRetryFailed(): boolean {
51-
return retryFailed
51+
return retryFailed
5252
}
5353

5454
/**
@@ -57,15 +57,15 @@ export function isVolumeRetryFailed(): boolean {
5757
* Used by the retry button when the initial listing timed out.
5858
*/
5959
export function requestVolumeRefresh(): void {
60-
if (refreshing) return
60+
if (refreshing) return
6161

62-
refreshing = true
63-
retryFailed = false
64-
if (retryFailedTimer) clearTimeout(retryFailedTimer)
62+
refreshing = true
63+
retryFailed = false
64+
if (retryFailedTimer) clearTimeout(retryFailedTimer)
6565

66-
// Tell the backend to re-broadcast. The result arrives via the
67-
// `volumes-changed` event listener, which handles retryFailed.
68-
void refreshVolumes()
66+
// Tell the backend to re-broadcast. The result arrives via the
67+
// `volumes-changed` event listener, which handles retryFailed.
68+
void refreshVolumes()
6969
}
7070

7171
/**
@@ -79,55 +79,55 @@ export function requestVolumeRefresh(): void {
7979
* Idempotent — calling multiple times is safe.
8080
*/
8181
export async function initVolumeStore(): Promise<void> {
82-
if (initialized) return
83-
84-
// Subscribe to backend-pushed volume list updates
85-
unlistenVolumesChanged = await listen<VolumesChangedPayload>('volumes-changed', (event) => {
86-
receivedEvent = true
87-
volumes = event.payload.data
88-
timedOut = event.payload.timedOut
89-
90-
// Detect retry failure: we were refreshing and it's still timed out
91-
if (refreshing) {
92-
refreshing = false
93-
if (event.payload.timedOut) {
94-
retryFailed = true
95-
retryFailedTimer = setTimeout(() => {
96-
retryFailed = false
97-
}, 3000)
98-
}
99-
}
100-
101-
logger.debug('volumes-changed: {count} volumes, timedOut={timedOut}', {
102-
count: event.payload.data.length,
103-
timedOut: event.payload.timedOut,
104-
})
105-
})
106-
107-
// Bootstrap: fetch initial list via IPC (in case the backend event
108-
// fired before we subscribed, or hasn't fired yet)
109-
const result = await listVolumes()
110-
// Only use bootstrap data if no event has arrived yet
111-
if (!receivedEvent) {
112-
volumes = result.data
113-
timedOut = result.timedOut
114-
logger.debug('Bootstrap: {count} volumes', { count: result.data.length })
82+
if (initialized) return
83+
84+
// Subscribe to backend-pushed volume list updates
85+
unlistenVolumesChanged = await listen<VolumesChangedPayload>('volumes-changed', (event) => {
86+
receivedEvent = true
87+
volumes = event.payload.data
88+
timedOut = event.payload.timedOut
89+
90+
// Detect retry failure: we were refreshing and it's still timed out
91+
if (refreshing) {
92+
refreshing = false
93+
if (event.payload.timedOut) {
94+
retryFailed = true
95+
retryFailedTimer = setTimeout(() => {
96+
retryFailed = false
97+
}, 3000)
98+
}
11599
}
116100

117-
initialized = true
118-
logger.debug('Volume store initialized')
101+
logger.debug('volumes-changed: {count} volumes, timedOut={timedOut}', {
102+
count: event.payload.data.length,
103+
timedOut: event.payload.timedOut,
104+
})
105+
})
106+
107+
// Bootstrap: fetch initial list via IPC (in case the backend event
108+
// fired before we subscribed, or hasn't fired yet)
109+
const result = await listVolumes()
110+
// Only use bootstrap data if no event has arrived yet
111+
if (!receivedEvent) {
112+
volumes = result.data
113+
timedOut = result.timedOut
114+
logger.debug('Bootstrap: {count} volumes', { count: result.data.length })
115+
}
116+
117+
initialized = true
118+
logger.debug('Volume store initialized')
119119
}
120120

121121
/** Cleans up the volume store. Call on app shutdown. */
122122
export function cleanupVolumeStore(): void {
123-
unlistenVolumesChanged?.()
124-
unlistenVolumesChanged = undefined
125-
volumes = []
126-
timedOut = false
127-
refreshing = false
128-
retryFailed = false
129-
if (retryFailedTimer) clearTimeout(retryFailedTimer)
130-
retryFailedTimer = null
131-
receivedEvent = false
132-
initialized = false
123+
unlistenVolumesChanged?.()
124+
unlistenVolumesChanged = undefined
125+
volumes = []
126+
timedOut = false
127+
refreshing = false
128+
retryFailed = false
129+
if (retryFailedTimer) clearTimeout(retryFailedTimer)
130+
retryFailedTimer = null
131+
receivedEvent = false
132+
initialized = false
133133
}

apps/desktop/src/lib/tauri-commands/mtp.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,6 @@ export interface MtpDeviceDisconnectedEvent {
179179
reason: 'user' | 'disconnected'
180180
}
181181

182-
183182
/**
184183
* Subscribes to MTP exclusive access error events.
185184
* Emitted when connecting fails because another process (like ptpcamerad) has the device.

docs/tooling/logging.md

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -108,23 +108,23 @@ name.
108108

109109
Copy-paste commands for common debugging scenarios. All include `info` as the base level.
110110

111-
| Area | Command |
112-
| ---------------------------------- | --------------------------------------------------------------------------------- |
113-
| Network/SMB | `RUST_LOG=cmdr_lib::network=debug,mdns_sd=debug,smb=warn,sspi=warn,info pnpm dev` |
114-
| Drive indexing | `RUST_LOG=cmdr_lib::indexing=debug,info pnpm dev` |
115-
| Indexing scanner only | `RUST_LOG=cmdr_lib::indexing::scanner=debug,info pnpm dev` |
116-
| Indexing FSEvents | `RUST_LOG=cmdr_lib::indexing::watcher=debug,info pnpm dev` |
117-
| File operations (copy/move/delete) | `RUST_LOG=cmdr_lib::file_system::write_operations=debug,info pnpm dev` |
118-
| Directory listing | `RUST_LOG=cmdr_lib::file_system::listing=debug,info pnpm dev` |
119-
| File viewer | `RUST_LOG=cmdr_lib::file_viewer=debug,FE:viewer=debug,info pnpm dev` |
120-
| MTP (Android devices) | `RUST_LOG=cmdr_lib::mtp=debug,FE:mtp=debug,info pnpm dev` |
111+
| Area | Command |
112+
| ---------------------------------- | ------------------------------------------------------------------------------------------ |
113+
| Network/SMB | `RUST_LOG=cmdr_lib::network=debug,mdns_sd=debug,smb=warn,sspi=warn,info pnpm dev` |
114+
| Drive indexing | `RUST_LOG=cmdr_lib::indexing=debug,info pnpm dev` |
115+
| Indexing scanner only | `RUST_LOG=cmdr_lib::indexing::scanner=debug,info pnpm dev` |
116+
| Indexing FSEvents | `RUST_LOG=cmdr_lib::indexing::watcher=debug,info pnpm dev` |
117+
| File operations (copy/move/delete) | `RUST_LOG=cmdr_lib::file_system::write_operations=debug,info pnpm dev` |
118+
| Directory listing | `RUST_LOG=cmdr_lib::file_system::listing=debug,info pnpm dev` |
119+
| File viewer | `RUST_LOG=cmdr_lib::file_viewer=debug,FE:viewer=debug,info pnpm dev` |
120+
| MTP (Android devices) | `RUST_LOG=cmdr_lib::mtp=debug,FE:mtp=debug,info pnpm dev` |
121121
| Volume discovery + broadcast | `RUST_LOG=cmdr_lib::volume_broadcast=debug,cmdr_lib::volumes::watcher=debug,info pnpm dev` |
122-
| AI/LLM | `RUST_LOG=cmdr_lib::ai=debug,info pnpm dev` |
123-
| Licensing | `RUST_LOG=cmdr_lib::licensing=debug,info pnpm dev` |
124-
| MCP server | `RUST_LOG=cmdr_lib::mcp=debug,info pnpm dev` |
125-
| All frontend logs | `RUST_LOG=FE:=debug,info pnpm dev` |
126-
| Specific FE feature | `RUST_LOG=FE:fileExplorer=debug,info pnpm dev` |
127-
| Everything (noisy deps suppressed) | `RUST_LOG=debug,smb=warn,sspi=warn,mdns_sd=warn,hyper=warn pnpm dev` |
122+
| AI/LLM | `RUST_LOG=cmdr_lib::ai=debug,info pnpm dev` |
123+
| Licensing | `RUST_LOG=cmdr_lib::licensing=debug,info pnpm dev` |
124+
| MCP server | `RUST_LOG=cmdr_lib::mcp=debug,info pnpm dev` |
125+
| All frontend logs | `RUST_LOG=FE:=debug,info pnpm dev` |
126+
| Specific FE feature | `RUST_LOG=FE:fileExplorer=debug,info pnpm dev` |
127+
| Everything (noisy deps suppressed) | `RUST_LOG=debug,smb=warn,sspi=warn,mdns_sd=warn,hyper=warn pnpm dev` |
128128

129129
Frontend log targets use `FE:{category}` where category matches the `getAppLogger('category')` name.
130130

0 commit comments

Comments
 (0)