Skip to content

Commit 70d8d40

Browse files
committed
MTP: Show toast on device connect, move settings to own section
- Sticky toast on MTP device connect: shows device name, explains ptpcamerad suppression (macOS), with "Don't show again" checkbox, OK, and "Disable MTP..." button - New `fileOperations.mtpConnectionWarning` setting controls whether the toast appears - Move MTP settings from General > File operations to General > MTP (new section) - New `SettingCheckbox` component (Ark UI headless, less prominent than switch) - Add `deviceName` to `mtp-device-connected` backend event payload - Remove old ptpcamerad suppressed/restored string toasts (subsumed by connection toast) - Remove dead `onPtpcameradSuppressed`/`onPtpcameradRestored` exports - Fix ESLint complexity in `VolumeBreadcrumb.svelte` (extract `handleDropdownKey`)
1 parent 2467ece commit 70d8d40

18 files changed

Lines changed: 539 additions & 79 deletions

File tree

apps/desktop/coverage-allowlist.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
"settings/components/SettingRow.svelte": { "reason": "UI component, layout only" },
8989
"settings/components/SettingSelect.svelte": { "reason": "UI component, logic is simple" },
9090
"settings/components/SettingSlider.svelte": { "reason": "UI component, logic is simple" },
91+
"settings/components/SettingCheckbox.svelte": { "reason": "UI component, logic is simple" },
9192
"settings/components/SettingSwitch.svelte": { "reason": "UI component, logic is simple" },
9293
"settings/components/SettingToggleGroup.svelte": { "reason": "UI component, logic is simple" },
9394
"settings/components/SettingsContent.svelte": { "reason": "UI layout component" },
@@ -106,6 +107,7 @@
106107
"settings/mcp-main-bridge.ts": { "reason": "MCP bridge, depends on Tauri event APIs (listen/emit)" },
107108
"settings/mcp-settings-bridge.ts": { "reason": "MCP bridge, depends on Tauri APIs and events" },
108109
"settings/sections/McpServerSection.svelte": { "reason": "UI section, depends on Tauri APIs" },
110+
"settings/sections/MtpSection.svelte": { "reason": "UI section, simple rendering" },
109111
"settings/sections/NetworkSection.svelte": { "reason": "UI section, simple rendering" },
110112
"settings/sections/ThemesSection.svelte": { "reason": "UI section, simple rendering" },
111113
"settings/sections/DriveIndexingSection.svelte": { "reason": "UI section, depends on Tauri invoke" },
@@ -234,6 +236,9 @@
234236
"crash-reporter/CrashReportToastContent.svelte": {
235237
"reason": "UI toast content, depends on Tauri window APIs"
236238
},
239+
"mtp/MtpConnectedToastContent.svelte": {
240+
"reason": "UI toast content, depends on Tauri IPC (setSetting, dismissToast)"
241+
},
237242
"tauri-commands/crash-reporter.ts": {
238243
"reason": "Tauri command wrappers, tested via integration"
239244
},

apps/desktop/src-tauri/src/mtp/CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ USB plug-in
3737
→ probe_write_capability() per storage
3838
→ register MtpVolume in global VolumeManager
3939
→ start_event_loop() per device
40-
→ emit mtp-device-connected
40+
→ emit mtp-device-connected (JSON payload includes `deviceName`: from `connected_info.device.product`, empty string if unknown)
4141
→ broadcast::emit_volumes_changed()
4242
4343
USB unplug

apps/desktop/src-tauri/src/mtp/connection/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,7 @@ impl MtpConnectionManager {
346346
"mtp-device-connected",
347347
serde_json::json!({
348348
"deviceId": device_id,
349+
"deviceName": connected_info.device.product.clone().unwrap_or_default(),
349350
"storages": connected_info.storages
350351
}),
351352
);

apps/desktop/src/lib/file-explorer/navigation/VolumeBreadcrumb.svelte

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -193,29 +193,18 @@
193193
}
194194
}
195195
196-
// Export keyboard handler for parent components to call
197-
export function handleKeyDown(e: KeyboardEvent): boolean {
198-
if (!isOpen) return false
199-
200-
const submenuResult = handleSubmenuKeyDown(e.key)
201-
if (submenuResult !== null) {
202-
e.preventDefault()
203-
return submenuResult
204-
}
205-
206-
switch (e.key) {
196+
/** Handles a single key in the main dropdown (not submenu). Returns true if handled. */
197+
function handleDropdownKey(key: string): boolean {
198+
switch (key) {
207199
case 'ArrowDown':
208-
e.preventDefault()
209200
highlightedIndex = Math.min(highlightedIndex + 1, allVolumes.length - 1)
210201
enterKeyboardMode()
211202
return true
212203
case 'ArrowUp':
213-
e.preventDefault()
214204
highlightedIndex = Math.max(highlightedIndex - 1, 0)
215205
enterKeyboardMode()
216206
return true
217207
case 'ArrowRight':
218-
e.preventDefault()
219208
if (
220209
highlightedIndex >= 0 &&
221210
allVolumes[highlightedIndex]?.smbConnectionState === 'os_mount'
@@ -227,22 +216,18 @@
227216
}
228217
return true
229218
case 'Enter':
230-
e.preventDefault()
231219
if (highlightedIndex >= 0 && highlightedIndex < allVolumes.length) {
232220
void handleVolumeSelect(allVolumes[highlightedIndex])
233221
}
234222
return true
235223
case 'Escape':
236-
e.preventDefault()
237224
isOpen = false
238225
return true
239226
case 'Home':
240-
e.preventDefault()
241227
highlightedIndex = 0
242228
enterKeyboardMode()
243229
return true
244230
case 'End':
245-
e.preventDefault()
246231
highlightedIndex = allVolumes.length - 1
247232
enterKeyboardMode()
248233
return true
@@ -251,6 +236,21 @@
251236
}
252237
}
253238
239+
// Export keyboard handler for parent components to call
240+
export function handleKeyDown(e: KeyboardEvent): boolean {
241+
if (!isOpen) return false
242+
243+
const submenuResult = handleSubmenuKeyDown(e.key)
244+
if (submenuResult !== null) {
245+
e.preventDefault()
246+
return submenuResult
247+
}
248+
249+
const handled = handleDropdownKey(e.key)
250+
if (handled) e.preventDefault()
251+
return handled
252+
}
253+
254254
function enterKeyboardMode() {
255255
isKeyboardMode = true
256256
lastMousePos = null // Will be captured on next mousemove

apps/desktop/src/lib/mtp/CLAUDE.md

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ UI and state management for Android device file browsing via MTP (Media Transfer
77
- **State**: `mtp-store.svelte.ts` — Reactive device list, connection status, Tauri event listeners
88
- **Path utils**: `mtp-path-utils.ts` — Parse/construct MTP paths (`mtp://{deviceId}:{storageId}/path`)
99
- **Dialog**: `PtpcameradDialog.svelte` — macOS-specific helper for `ptpcamerad` workaround (shows Terminal command)
10+
- **Toast**: `MtpConnectedToastContent.svelte` — Sticky toast shown when an MTP device connects. Explains ptpcamerad
11+
suppression (macOS) or generic info (Linux). Offers "Don't show again" checkbox and "Disable MTP..." link. Uses
12+
module-level `$state` (`lastConnectedDeviceName`) set by `+layout.svelte` before `addToast()`, since the toast system
13+
renders components with zero props. Gated by `fileOperations.mtpConnectionWarning` setting (default `true`).
1014
- **Backend**: See `src-tauri/src/mtp/` for device management, file operations, event loop
1115

1216
## Key decisions
@@ -26,9 +30,9 @@ sends event with `deviceId`. Frontend re-fetches current directory if viewing th
2630

2731
### Settings toggle (`fileOperations.mtpEnabled`)
2832

29-
MTP support can be disabled entirely from Settings > General > File operations. The toggle calls `setMtpEnabled()`
30-
(wired through `settings-applier.ts`), which invokes the `set_mtp_enabled` Tauri command. When disabled, all devices
31-
disconnect and hotplug events are ignored. The frontend is passive — it reacts to `volumes-changed` events as usual.
33+
MTP support can be disabled entirely from Settings > General > MTP. The toggle calls `setMtpEnabled()` (wired through
34+
`settings-applier.ts`), which invokes the `set_mtp_enabled` Tauri command. When disabled, all devices disconnect and
35+
hotplug events are ignored. The frontend is passive — it reacts to `volumes-changed` events as usual.
3236

3337
### Automatic ptpcamerad suppression (macOS)
3438

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<script module lang="ts">
2+
// Module-level state: set by +layout.svelte before addToast is called
3+
let lastConnectedDeviceName = $state('MTP device')
4+
5+
export function setLastConnectedDeviceName(name: string) {
6+
lastConnectedDeviceName = name
7+
}
8+
</script>
9+
10+
<script lang="ts">
11+
import { dismissToast } from '$lib/ui/toast'
12+
import { setSetting } from '$lib/settings'
13+
import { isMacOS } from '$lib/shortcuts/key-capture'
14+
15+
const toastId = 'mtp-connected'
16+
let dontShowAgain = $state(false)
17+
18+
function handleOk() {
19+
if (dontShowAgain) {
20+
setSetting('fileOperations.mtpConnectionWarning', false)
21+
}
22+
dismissToast(toastId)
23+
}
24+
25+
function handleDisableMtp() {
26+
setSetting('fileOperations.mtpEnabled', false)
27+
if (dontShowAgain) {
28+
setSetting('fileOperations.mtpConnectionWarning', false)
29+
}
30+
dismissToast(toastId)
31+
}
32+
</script>
33+
34+
<div class="mtp-toast">
35+
<p class="title">Connected to {lastConnectedDeviceName}</p>
36+
<p class="body">
37+
{#if isMacOS()}
38+
Cmdr paused the macOS camera daemon (ptpcamerad) to access this device. To use it in another app, disable
39+
MTP support in settings.
40+
{:else}
41+
To use this device in another app, disable MTP support in settings.
42+
{/if}
43+
</p>
44+
<label class="dont-show-again">
45+
<input type="checkbox" bind:checked={dontShowAgain} />
46+
Don't show again
47+
</label>
48+
<div class="actions">
49+
<button class="ok-button" onclick={handleOk}>OK</button>
50+
<button class="disable-link" onclick={handleDisableMtp}>Disable MTP...</button>
51+
</div>
52+
</div>
53+
54+
<style>
55+
.mtp-toast {
56+
display: flex;
57+
flex-direction: column;
58+
gap: var(--spacing-xs);
59+
}
60+
61+
.title {
62+
margin: 0;
63+
font-weight: 600;
64+
font-size: var(--font-size-sm);
65+
color: var(--color-text-primary);
66+
}
67+
68+
.body {
69+
margin: 0;
70+
font-size: var(--font-size-xs);
71+
color: var(--color-text-secondary);
72+
line-height: 1.4;
73+
}
74+
75+
.dont-show-again {
76+
display: flex;
77+
align-items: center;
78+
gap: var(--spacing-xs);
79+
font-size: var(--font-size-xs);
80+
color: var(--color-text-tertiary);
81+
cursor: default;
82+
margin-top: var(--spacing-xs);
83+
}
84+
85+
.dont-show-again input[type='checkbox'] {
86+
margin: 0;
87+
cursor: default;
88+
}
89+
90+
.actions {
91+
display: flex;
92+
align-items: center;
93+
gap: var(--spacing-sm);
94+
margin-top: var(--spacing-xs);
95+
}
96+
97+
.ok-button {
98+
background: var(--color-accent);
99+
color: var(--color-accent-fg);
100+
border: none;
101+
border-radius: var(--radius-sm);
102+
padding: var(--spacing-xs) var(--spacing-md);
103+
font-size: var(--font-size-xs);
104+
font-weight: 500;
105+
cursor: default;
106+
transition: background var(--transition-fast);
107+
}
108+
109+
.ok-button:hover {
110+
background: var(--color-accent-hover);
111+
}
112+
113+
.disable-link {
114+
background: none;
115+
border: none;
116+
padding: 0;
117+
font-size: var(--font-size-xs);
118+
color: var(--color-text-tertiary);
119+
cursor: default;
120+
transition: color var(--transition-fast);
121+
}
122+
123+
.disable-link:hover {
124+
color: var(--color-text-secondary);
125+
}
126+
</style>

apps/desktop/src/lib/mtp/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
export { default as MtpPermissionDialog } from './MtpPermissionDialog.svelte'
44
export { default as PtpcameradDialog } from './PtpcameradDialog.svelte'
5+
export { default as MtpConnectedToastContent } from './MtpConnectedToastContent.svelte'
56

67
// MTP store for device state management
78
export * from './mtp-store.svelte'

apps/desktop/src/lib/settings/CLAUDE.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,15 @@ Single source of truth for all settings. Each `SettingDefinition` contains:
3333

3434
### Sections (`sections/`)
3535

36-
13 section components rendered inside the settings window. `ListingSection` includes:
36+
14 section components rendered inside the settings window. `ListingSection` includes:
3737

3838
- `listing.sizeDisplay` — enum (smart/logical/physical), default smart, toggle-group. Reactive getter:
3939
`getSizeDisplayMode()`.
4040
- `listing.sizeMismatchWarning` — boolean, default true, switch. Reactive getter: `getSizeMismatchWarning()`.
4141

42-
Full list: `AppearanceSection`, `ListingSection`, `FileOperationsSection`, `KeyboardShortcutsSection`, `NetworkSection`,
43-
`LoggingSection`, `McpServerSection`, `UpdatesSection`, `ThemesSection`, `AdvancedSection`, `DriveIndexingSection`,
44-
`AiSection`, `LicenseSection`.
42+
Full list: `AppearanceSection`, `ListingSection`, `FileOperationsSection`, `MtpSection`, `KeyboardShortcutsSection`,
43+
`NetworkSection`, `LoggingSection`, `McpServerSection`, `UpdatesSection`, `ThemesSection`, `AdvancedSection`,
44+
`DriveIndexingSection`, `AiSection`, `LicenseSection`, `ViewerSection`.
4545

4646
`AiSection` is a hybrid special section (like `LicenseSection` above): it combines dynamic runtime state from the
4747
backend (via `getAiRuntimeStatus()` and Tauri events) with registry settings (`ai.provider`, `ai.cloudProvider`,
@@ -58,11 +58,11 @@ models are available, the Model field becomes a combobox with filtered dropdown;
5858

5959
### Components (`components/`)
6060

61-
11 reusable setting UI primitives used by section components: `SettingsSection` (wrapper providing shared section title
62-
and action button styles), `SettingRow`, `SettingSwitch`, `SettingSelect`, `SettingSlider`, `SettingNumberInput`,
63-
`SettingPasswordInput` (supports both settings-store-driven and controlled/external value+onchange modes),
64-
`SettingRadioGroup`, `SettingToggleGroup`, `SettingsSidebar`, `SettingsContent`. Also `SectionSummary` for
65-
collapsed-section previews.
61+
12 reusable setting UI primitives used by section components: `SettingsSection` (wrapper providing shared section title
62+
and action button styles), `SettingRow`, `SettingSwitch`, `SettingCheckbox` (less prominent than switch, for secondary
63+
boolean options), `SettingSelect`, `SettingSlider`, `SettingNumberInput`, `SettingPasswordInput` (supports both
64+
settings-store-driven and controlled/external value+onchange modes), `SettingRadioGroup`, `SettingToggleGroup`,
65+
`SettingsSidebar`, `SettingsContent`. Also `SectionSummary` for collapsed-section previews.
6666

6767
### 50-50 split layout guideline
6868

0 commit comments

Comments
 (0)