Skip to content

Commit b08af36

Browse files
committed
MTP: Add file operations UI (copy, delete, rename, new folder)
1 parent 7ac1528 commit b08af36

10 files changed

Lines changed: 1865 additions & 10 deletions

apps/desktop/coverage-allowlist.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
"licensing-store.svelte.ts": { "reason": "Depends on Tauri store APIs" },
2828
"logger.ts": { "reason": "LogTape config, initialization code only" },
2929
"mtp/MtpBrowser.svelte": { "reason": "MTP file browser component, depends on Tauri APIs" },
30+
"mtp/MtpCopyDialog.svelte": { "reason": "UI modal for MTP copy progress, depends on Tauri APIs" },
31+
"mtp/MtpDeleteDialog.svelte": { "reason": "UI modal for MTP delete confirmation" },
32+
"mtp/MtpNewFolderDialog.svelte": { "reason": "UI modal for MTP new folder" },
33+
"mtp/MtpProgressDialog.svelte": { "reason": "UI modal for MTP progress tracking" },
34+
"mtp/MtpRenameDialog.svelte": { "reason": "UI modal for MTP rename" },
3035
"mtp/PtpcameradDialog.svelte": { "reason": "UI modal for macOS MTP workaround" },
3136
"mtp/mtp-path-utils.ts": { "reason": "MTP path utilities, tested implicitly via component usage" },
3237
"mtp/mtp-store.svelte.ts": { "reason": "Depends on Tauri APIs, tests planned in Phase 6" },

apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@
5050
import { initNetworkDiscovery, cleanupNetworkDiscovery } from '$lib/network-store.svelte'
5151
import { openFileViewer } from '$lib/file-viewer/open-viewer'
5252
import { getAppLogger } from '$lib/logger'
53+
import { isMtpVolumeId, parseMtpVolumeId, MtpBrowser } from '$lib/mtp'
54+
import MtpCopyDialog from '$lib/mtp/MtpCopyDialog.svelte'
55+
import type { FileEntry } from './types'
5356
5457
const log = getAppLogger('fileExplorer')
5558
@@ -115,6 +118,17 @@
115118
initialName: string
116119
} | null>(null)
117120
121+
// MTP copy dialog state
122+
let showMtpCopyDialog = $state(false)
123+
let mtpCopyDialogProps = $state<{
124+
operationType: 'download' | 'upload'
125+
sourceFiles: FileEntry[] | string[]
126+
destinationPath: string
127+
deviceId: string
128+
storageId: number
129+
mtpBasePath: string
130+
} | null>(null)
131+
118132
// Navigation history for each pane (per-pane, session-only)
119133
// Initialize with default volume - will be updated on mount with actual state
120134
let leftHistory = $state<NavigationHistory>(createHistory(DEFAULT_VOLUME_ID, '~'))
@@ -939,11 +953,152 @@
939953
void openFileViewer(file.path)
940954
}
941955
956+
/** Handles MTP download operation (MTP source -> local destination). */
957+
function openMtpDownloadDialog(
958+
sourcePaneRef: FilePane | undefined,
959+
sourceVolumeId: string,
960+
destPath: string,
961+
): boolean {
962+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
963+
const mtpBrowser = sourcePaneRef?.getMtpBrowser?.() as MtpBrowser | undefined
964+
if (!mtpBrowser) return false
965+
966+
const mtpInfo = parseMtpVolumeId(sourceVolumeId)
967+
if (!mtpInfo) return false
968+
969+
// Get selected files or file under cursor
970+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
971+
const selectedFiles = sourcePaneRef?.getMtpSelectedFiles?.() as FileEntry[] | undefined
972+
const sourceFiles =
973+
selectedFiles && selectedFiles.length > 0 ? selectedFiles : getMtpEntryUnderCursorAsArray(sourcePaneRef)
974+
if (sourceFiles.length === 0) return false
975+
976+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
977+
const mtpBrowserInfo = mtpBrowser.getMtpInfo()
978+
979+
mtpCopyDialogProps = {
980+
operationType: 'download',
981+
sourceFiles,
982+
destinationPath: destPath,
983+
deviceId: mtpInfo.deviceId,
984+
storageId: mtpInfo.storageId,
985+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
986+
mtpBasePath: mtpBrowserInfo.currentPath,
987+
}
988+
showMtpCopyDialog = true
989+
return true
990+
}
991+
992+
/** Gets the MTP entry under cursor as an array, or empty array if not valid. */
993+
function getMtpEntryUnderCursorAsArray(paneRef: FilePane | undefined): FileEntry[] {
994+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
995+
const entry = paneRef?.getMtpEntryUnderCursor?.() as FileEntry | null
996+
if (!entry || entry.name === '..') return []
997+
return [entry]
998+
}
999+
1000+
/** Handles MTP upload operation (local source -> MTP destination). */
1001+
async function openMtpUploadDialog(
1002+
sourcePaneRef: FilePane | undefined,
1003+
destPaneRef: FilePane | undefined,
1004+
destVolumeId: string,
1005+
destPath: string,
1006+
): Promise<boolean> {
1007+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
1008+
const mtpBrowser = destPaneRef?.getMtpBrowser?.() as MtpBrowser | undefined
1009+
if (!mtpBrowser) return false
1010+
1011+
const mtpInfo = parseMtpVolumeId(destVolumeId)
1012+
if (!mtpInfo) return false
1013+
1014+
const sourcePaths = await getLocalSourcePaths(sourcePaneRef)
1015+
if (sourcePaths.length === 0) return false
1016+
1017+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call
1018+
const mtpBrowserInfo = mtpBrowser.getMtpInfo()
1019+
1020+
mtpCopyDialogProps = {
1021+
operationType: 'upload',
1022+
sourceFiles: sourcePaths,
1023+
destinationPath: destPath,
1024+
deviceId: mtpInfo.deviceId,
1025+
storageId: mtpInfo.storageId,
1026+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
1027+
mtpBasePath: mtpBrowserInfo.currentPath,
1028+
}
1029+
showMtpCopyDialog = true
1030+
return true
1031+
}
1032+
1033+
/** Gets path of file under cursor. */
1034+
async function getPathUnderCursor(
1035+
listingId: string,
1036+
sourcePaneRef: FilePane | undefined,
1037+
hasParent: boolean,
1038+
): Promise<string[]> {
1039+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
1040+
const cursorIndex = sourcePaneRef?.getCursorIndex?.() as number | undefined
1041+
const backendIndex = toBackendCursorIndex(cursorIndex ?? -1, hasParent)
1042+
if (backendIndex === null) return []
1043+
1044+
const file = await getFileAt(listingId, backendIndex, showHiddenFiles)
1045+
if (!file || file.name === '..') return []
1046+
return [file.path]
1047+
}
1048+
1049+
/** Gets local source paths from selection or cursor. */
1050+
async function getLocalSourcePaths(sourcePaneRef: FilePane | undefined): Promise<string[]> {
1051+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
1052+
const listingId = sourcePaneRef?.getListingId?.() as string | undefined
1053+
if (!listingId) return []
1054+
1055+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
1056+
const hasParent = sourcePaneRef?.hasParentEntry?.() as boolean | undefined
1057+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
1058+
const selectedIndices = sourcePaneRef?.getSelectedIndices?.() as number[] | undefined
1059+
1060+
if (selectedIndices && selectedIndices.length > 0) {
1061+
const backendIndices = toBackendIndices(selectedIndices, hasParent ?? false)
1062+
return getSelectedFilePaths(listingId, backendIndices)
1063+
}
1064+
1065+
return getPathUnderCursor(listingId, sourcePaneRef, hasParent ?? false)
1066+
}
1067+
9421068
/** Opens the copy dialog with the current selection info. */
9431069
export async function openCopyDialog() {
9441070
const isLeft = focusedPane === 'left'
9451071
const sourcePaneRef = isLeft ? leftPaneRef : rightPaneRef
1072+
const destPaneRef = isLeft ? rightPaneRef : leftPaneRef
1073+
const sourceVolumeId = isLeft ? leftVolumeId : rightVolumeId
1074+
const destVolumeId = isLeft ? rightVolumeId : leftVolumeId
1075+
const destPath = isLeft ? rightPath : leftPath
1076+
1077+
const sourceIsMtp = isMtpVolumeId(sourceVolumeId)
1078+
const destIsMtp = isMtpVolumeId(destVolumeId)
1079+
1080+
// MTP to MTP copy is not supported
1081+
if (sourceIsMtp && destIsMtp) {
1082+
log.warn('MTP to MTP copy is not supported')
1083+
return
1084+
}
9461085
1086+
// Handle MTP operations
1087+
if (sourceIsMtp) {
1088+
openMtpDownloadDialog(sourcePaneRef, sourceVolumeId, destPath)
1089+
return
1090+
}
1091+
if (destIsMtp) {
1092+
await openMtpUploadDialog(sourcePaneRef, destPaneRef, destVolumeId, destPath)
1093+
return
1094+
}
1095+
1096+
// Standard local-to-local copy
1097+
await openLocalCopyDialog(sourcePaneRef, isLeft)
1098+
}
1099+
1100+
/** Opens the standard local-to-local copy dialog. */
1101+
async function openLocalCopyDialog(sourcePaneRef: FilePane | undefined, isLeft: boolean) {
9471102
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
9481103
const listingId = sourcePaneRef?.getListingId?.() as string | undefined
9491104
if (!listingId) return
@@ -1093,6 +1248,38 @@
10931248
containerElement?.focus()
10941249
}
10951250
1251+
// MTP copy dialog handlers
1252+
function handleMtpCopyComplete(filesProcessed: number, bytesTransferred: number) {
1253+
log.info(`MTP copy complete: ${String(filesProcessed)} files (${formatBytes(bytesTransferred)})`)
1254+
1255+
// Refresh the destination pane
1256+
// For download: refresh local pane; for upload: MTP browser refreshes itself
1257+
if (mtpCopyDialogProps?.operationType === 'download') {
1258+
const localPaneRef = focusedPane === 'left' ? rightPaneRef : leftPaneRef
1259+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
1260+
localPaneRef?.refreshView?.()
1261+
}
1262+
1263+
showMtpCopyDialog = false
1264+
mtpCopyDialogProps = null
1265+
containerElement?.focus()
1266+
}
1267+
1268+
function handleMtpCopyCancel() {
1269+
log.info('MTP copy cancelled')
1270+
showMtpCopyDialog = false
1271+
mtpCopyDialogProps = null
1272+
containerElement?.focus()
1273+
}
1274+
1275+
function handleMtpCopyError(error: string) {
1276+
log.error(`MTP copy failed: ${error}`)
1277+
showMtpCopyDialog = false
1278+
mtpCopyDialogProps = null
1279+
// TODO: Show error notification/toast
1280+
containerElement?.focus()
1281+
}
1282+
10961283
// Focus the container after initialization so keyboard events work
10971284
$effect(() => {
10981285
if (initialized) {
@@ -1411,6 +1598,20 @@
14111598
/>
14121599
{/if}
14131600

1601+
{#if showMtpCopyDialog && mtpCopyDialogProps}
1602+
<MtpCopyDialog
1603+
operationType={mtpCopyDialogProps.operationType}
1604+
sourceFiles={mtpCopyDialogProps.sourceFiles}
1605+
destinationPath={mtpCopyDialogProps.destinationPath}
1606+
deviceId={mtpCopyDialogProps.deviceId}
1607+
storageId={mtpCopyDialogProps.storageId}
1608+
mtpBasePath={mtpCopyDialogProps.mtpBasePath}
1609+
onComplete={handleMtpCopyComplete}
1610+
onCancel={handleMtpCopyCancel}
1611+
onError={handleMtpCopyError}
1612+
/>
1613+
{/if}
1614+
14141615
<style>
14151616
.dual-pane-explorer {
14161617
display: flex;

apps/desktop/src/lib/file-explorer/FilePane.svelte

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,40 @@
250250
cacheGeneration++
251251
}
252252
253+
// Check if this pane is showing an MTP volume
254+
export function isMtp(): boolean {
255+
return isMtpView
256+
}
257+
258+
// Get the current volume ID
259+
export function getVolumeId(): string {
260+
return volumeId
261+
}
262+
263+
// Get the current path
264+
export function getCurrentPath(): string {
265+
return currentPath
266+
}
267+
268+
// Get MTP browser reference for operations
269+
export function getMtpBrowser(): MtpBrowser | undefined {
270+
return mtpBrowserRef
271+
}
272+
273+
// Get selected files from MTP browser
274+
export function getMtpSelectedFiles(): FileEntry[] {
275+
if (!isMtpView || !mtpBrowserRef) return []
276+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
277+
return mtpBrowserRef.getSelectedFiles()
278+
}
279+
280+
// Get file under cursor from MTP browser
281+
export function getMtpEntryUnderCursor(): FileEntry | null {
282+
if (!isMtpView || !mtpBrowserRef) return null
283+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return
284+
return mtpBrowserRef.getEntryUnderCursor()
285+
}
286+
253287
// Set network host state (for history navigation)
254288
export function setNetworkHost(host: NetworkHost | null): void {
255289
currentNetworkHost = host

0 commit comments

Comments
 (0)