|
50 | 50 | import { initNetworkDiscovery, cleanupNetworkDiscovery } from '$lib/network-store.svelte' |
51 | 51 | import { openFileViewer } from '$lib/file-viewer/open-viewer' |
52 | 52 | 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' |
53 | 56 |
|
54 | 57 | const log = getAppLogger('fileExplorer') |
55 | 58 |
|
|
115 | 118 | initialName: string |
116 | 119 | } | null>(null) |
117 | 120 |
|
| 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 | +
|
118 | 132 | // Navigation history for each pane (per-pane, session-only) |
119 | 133 | // Initialize with default volume - will be updated on mount with actual state |
120 | 134 | let leftHistory = $state<NavigationHistory>(createHistory(DEFAULT_VOLUME_ID, '~')) |
|
939 | 953 | void openFileViewer(file.path) |
940 | 954 | } |
941 | 955 |
|
| 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 | +
|
942 | 1068 | /** Opens the copy dialog with the current selection info. */ |
943 | 1069 | export async function openCopyDialog() { |
944 | 1070 | const isLeft = focusedPane === 'left' |
945 | 1071 | 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 | + } |
946 | 1085 |
|
| 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) { |
947 | 1102 | // eslint-disable-next-line @typescript-eslint/no-unsafe-call |
948 | 1103 | const listingId = sourcePaneRef?.getListingId?.() as string | undefined |
949 | 1104 | if (!listingId) return |
|
1093 | 1248 | containerElement?.focus() |
1094 | 1249 | } |
1095 | 1250 |
|
| 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 | +
|
1096 | 1283 | // Focus the container after initialization so keyboard events work |
1097 | 1284 | $effect(() => { |
1098 | 1285 | if (initialized) { |
|
1411 | 1598 | /> |
1412 | 1599 | {/if} |
1413 | 1600 |
|
| 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 | + |
1414 | 1615 | <style> |
1415 | 1616 | .dual-pane-explorer { |
1416 | 1617 | display: flex; |
|
0 commit comments