Browse Source
- Implement folder navigation with breadcrumbs in BackupFilesList - Distinguish files and folders in UI, allow folder navigation - Add debug mode to forcibly treat all entries as files for diagnosis - Add detailed debug logging to file discovery (readdir, stat, entries) - Show warning in UI when debug mode is active - Prepare for further improvements to handle stat failures gracefully Co-authored-by: Matthew Raymercapacitor-local-save
11 changed files with 1784 additions and 75 deletions
@ -0,0 +1,597 @@ |
|||
/** |
|||
* Backup Files List Component |
|||
* |
|||
* Displays a list of backup files saved by the app and provides options to: |
|||
* - View backup files by type (contacts, seed, other) |
|||
* - Open individual files in the device's file viewer |
|||
* - Access the backup directory in the device's file explorer |
|||
* |
|||
* @component |
|||
* @displayName BackupFilesList |
|||
* @example |
|||
* ```vue |
|||
* <BackupFilesList /> |
|||
* ``` |
|||
*/ |
|||
|
|||
<template> |
|||
<div class="backup-files-list"> |
|||
<div class="flex justify-between items-center mb-4"> |
|||
<h3 class="text-lg font-semibold">Backup Files</h3> |
|||
<div class="flex gap-2"> |
|||
<button |
|||
v-if="platformCapabilities.hasFileSystem" |
|||
@click="refreshFiles()" |
|||
class="text-sm bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded" |
|||
:disabled="isLoading" |
|||
> |
|||
<font-awesome |
|||
icon="refresh" |
|||
class="fa-fw" |
|||
:class="{ 'animate-spin': isLoading }" |
|||
/> |
|||
Refresh |
|||
</button> |
|||
<button |
|||
v-if="platformCapabilities.hasFileSystem" |
|||
@click="openBackupDirectory()" |
|||
class="text-sm bg-green-500 hover:bg-green-600 text-white px-3 py-1 rounded" |
|||
:disabled="isLoading" |
|||
> |
|||
<font-awesome icon="folder-open" class="fa-fw" /> |
|||
Open Directory |
|||
</button> |
|||
<button |
|||
v-if="platformCapabilities.hasFileSystem && isDevelopment" |
|||
@click="debugFileDiscovery()" |
|||
class="text-sm bg-yellow-500 hover:bg-yellow-600 text-white px-3 py-1 rounded" |
|||
:disabled="isLoading" |
|||
title="Debug file discovery (development only)" |
|||
> |
|||
<font-awesome icon="bug" class="fa-fw" /> |
|||
Debug |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div v-if="isLoading" class="text-center py-4"> |
|||
<font-awesome icon="spinner" class="animate-spin fa-2x" /> |
|||
<p class="mt-2">Loading backup files...</p> |
|||
</div> |
|||
|
|||
<div v-else-if="backupFiles.length === 0" class="text-center py-4 text-gray-500"> |
|||
<font-awesome icon="folder-open" class="fa-2x mb-2" /> |
|||
<p>No backup files found</p> |
|||
<p class="text-sm mt-1">Create backups using the export functions above</p> |
|||
</div> |
|||
|
|||
<div v-else class="space-y-2"> |
|||
<!-- File Type Filter --> |
|||
<div class="flex gap-2 mb-3"> |
|||
<button |
|||
v-for="type in ['all', 'contacts', 'seed', 'other'] as const" |
|||
:key="type" |
|||
@click="selectedType = type" |
|||
:class="[ |
|||
'text-sm px-3 py-1 rounded border', |
|||
selectedType === type |
|||
? 'bg-blue-500 text-white border-blue-500' |
|||
: 'bg-white text-gray-700 border-gray-300 hover:bg-gray-50' |
|||
]" |
|||
> |
|||
{{ type === 'all' ? 'All' : type.charAt(0).toUpperCase() + type.slice(1) }} |
|||
<span class="ml-1 text-xs"> |
|||
({{ getFileCountByType(type) }}) |
|||
</span> |
|||
</button> |
|||
</div> |
|||
|
|||
<!-- Files List --> |
|||
<div class="flex items-center gap-2 mb-2"> |
|||
<span v-for="(crumb, idx) in breadcrumbs" :key="idx"> |
|||
<span |
|||
class="text-blue-600 cursor-pointer underline" |
|||
@click="goToBreadcrumb(idx)" |
|||
v-if="idx < breadcrumbs.length - 1" |
|||
> |
|||
{{ crumb }} |
|||
</span> |
|||
<span v-else class="font-bold">{{ crumb }}</span> |
|||
<span v-if="idx < breadcrumbs.length - 1"> / </span> |
|||
</span> |
|||
</div> |
|||
<div v-if="currentPath.length > 1" class="mb-2"> |
|||
<button @click="goUp" class="text-xs text-blue-500 underline">⬅ Up</button> |
|||
</div> |
|||
<div class="mb-2"> |
|||
<label class="inline-flex items-center"> |
|||
<input type="checkbox" v-model="debugShowAll" @change="loadDirectory" class="mr-2" /> |
|||
<span class="text-xs">Debug: Show all entries as files</span> |
|||
</label> |
|||
<span v-if="debugShowAll" class="text-xs text-red-600 ml-2">[Debug mode: forcibly treating all entries as files]</span> |
|||
</div> |
|||
<div class="space-y-2 max-h-64 overflow-y-auto"> |
|||
<div |
|||
v-for="entry in folders" |
|||
:key="'folder-' + entry.path" |
|||
class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 cursor-pointer" |
|||
@click="openFolder(entry)" |
|||
> |
|||
<div class="flex items-center gap-2"> |
|||
<font-awesome icon="folder" class="fa-fw text-yellow-500" /> |
|||
<span class="font-medium">{{ entry.name }}</span> |
|||
<span class="text-xs bg-gray-200 text-gray-700 px-2 py-0.5 rounded-full ml-2">Folder</span> |
|||
</div> |
|||
</div> |
|||
<div |
|||
v-for="entry in files" |
|||
:key="'file-' + entry.path" |
|||
class="flex items-center justify-between p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50" |
|||
> |
|||
<div class="flex-1 min-w-0"> |
|||
<div class="flex items-center gap-2"> |
|||
<font-awesome icon="file-alt" class="fa-fw text-gray-500" /> |
|||
<span class="font-medium truncate">{{ entry.name }}</span> |
|||
</div> |
|||
<div class="text-sm text-gray-500 mt-1"> |
|||
<span v-if="entry.size">{{ formatFileSize(entry.size) }}</span> |
|||
<span v-else>Size unknown</span> |
|||
<span v-if="entry.path && !platformCapabilities.isIOS" class="ml-2 text-xs text-blue-600">📁 {{ entry.path }}</span> |
|||
</div> |
|||
</div> |
|||
<div class="flex gap-2 ml-3"> |
|||
<button |
|||
@click="openFile(entry.uri, entry.name)" |
|||
class="text-blue-500 hover:text-blue-700 p-1" |
|||
title="Open file" |
|||
> |
|||
<font-awesome icon="external-link-alt" class="fa-fw" /> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Summary --> |
|||
<div class="text-sm text-gray-500 mt-3 pt-3 border-t"> |
|||
Showing {{ filteredFiles.length }} of {{ backupFiles.length }} backup files |
|||
</div> |
|||
|
|||
<div class="text-sm text-gray-600 mb-2"> |
|||
<p>📁 Backup files are saved to persistent storage that survives app installations:</p> |
|||
<ul class="list-disc list-inside ml-2 mt-1 text-xs"> |
|||
<li v-if="platformCapabilities.isIOS">iOS: Documents folder (accessible via Files app)</li> |
|||
<li v-if="platformCapabilities.isMobile && !platformCapabilities.isIOS">Android: Downloads/TimeSafari or external storage (accessible via file managers)</li> |
|||
<li v-if="!platformCapabilities.isMobile">Desktop: User's download directory</li> |
|||
</ul> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script lang="ts"> |
|||
import { Component, Vue, Watch } from "vue-facing-decorator"; |
|||
import { NotificationIface } from "../constants/app"; |
|||
import { logger } from "../utils/logger"; |
|||
import { PlatformServiceFactory } from "../services/PlatformServiceFactory"; |
|||
import { |
|||
PlatformService, |
|||
PlatformCapabilities, |
|||
} from "../services/PlatformService"; |
|||
|
|||
/** |
|||
* @vue-component |
|||
* Backup Files List Component |
|||
* Displays and manages backup files with platform-specific functionality |
|||
*/ |
|||
@Component |
|||
export default class BackupFilesList extends Vue { |
|||
/** |
|||
* Notification function injected by Vue |
|||
* Used to show success/error messages to the user |
|||
*/ |
|||
$notify!: (notification: NotificationIface, timeout?: number) => void; |
|||
|
|||
/** |
|||
* Platform service instance for platform-specific operations |
|||
*/ |
|||
private platformService: PlatformService = PlatformServiceFactory.getInstance(); |
|||
|
|||
/** |
|||
* Platform capabilities for the current platform |
|||
*/ |
|||
private get platformCapabilities(): PlatformCapabilities { |
|||
return this.platformService.getCapabilities(); |
|||
} |
|||
|
|||
/** |
|||
* List of backup files found on the device |
|||
*/ |
|||
backupFiles: Array<{name: string, uri: string, size?: number, type: 'contacts' | 'seed' | 'other', path?: string}> = []; |
|||
|
|||
/** |
|||
* Currently selected file type filter |
|||
*/ |
|||
selectedType: 'all' | 'contacts' | 'seed' | 'other' = 'all'; |
|||
|
|||
/** |
|||
* Loading state for file operations |
|||
*/ |
|||
isLoading = false; |
|||
|
|||
/** |
|||
* Interval for periodic refresh (5 minutes) |
|||
*/ |
|||
private refreshInterval: number | null = null; |
|||
|
|||
/** |
|||
* Current path for folder navigation (array for breadcrumbs) |
|||
*/ |
|||
currentPath: string[] = []; |
|||
|
|||
/** |
|||
* List of files/folders in the current directory |
|||
*/ |
|||
directoryEntries: Array<{name: string, uri: string, size?: number, path: string, type: 'file' | 'folder'}> = []; |
|||
|
|||
/** |
|||
* Temporary debug mode to show all entries as files |
|||
*/ |
|||
debugShowAll = false; |
|||
|
|||
/** |
|||
* Lifecycle hook to load backup files when component is mounted |
|||
*/ |
|||
async mounted() { |
|||
if (this.platformCapabilities.hasFileSystem) { |
|||
// Set default root path |
|||
if (this.platformCapabilities.isIOS) { |
|||
this.currentPath = ['.']; |
|||
} else { |
|||
this.currentPath = ['Download', 'TimeSafari']; |
|||
} |
|||
await this.loadDirectory(); |
|||
this.refreshInterval = window.setInterval(() => { |
|||
this.loadDirectory(); |
|||
}, 5 * 60 * 1000); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Lifecycle hook to clean up resources when component is unmounted |
|||
*/ |
|||
beforeUnmount() { |
|||
if (this.refreshInterval) { |
|||
clearInterval(this.refreshInterval); |
|||
this.refreshInterval = null; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Computed property for filtered files based on selected type |
|||
*/ |
|||
get filteredFiles() { |
|||
if (this.selectedType === 'all') { |
|||
return this.backupFiles; |
|||
} |
|||
return this.backupFiles.filter(file => file.type === this.selectedType); |
|||
} |
|||
|
|||
/** |
|||
* Computed property to check if we're in development mode |
|||
*/ |
|||
get isDevelopment(): boolean { |
|||
return import.meta.env.DEV; |
|||
} |
|||
|
|||
/** |
|||
* Load the current directory entries |
|||
*/ |
|||
async loadDirectory() { |
|||
if (!this.platformCapabilities.hasFileSystem) return; |
|||
this.isLoading = true; |
|||
try { |
|||
const path = this.currentPath.join('/') || (this.platformCapabilities.isIOS ? '.' : 'Download/TimeSafari'); |
|||
this.directoryEntries = await (this.platformService as any).listFilesInDirectory(path, this.debugShowAll); |
|||
logger.log('[BackupFilesList] Loaded directory:', { path, entries: this.directoryEntries }); |
|||
} catch (error) { |
|||
logger.error('[BackupFilesList] Failed to load directory:', error); |
|||
this.directoryEntries = []; |
|||
} finally { |
|||
this.isLoading = false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Navigate into a folder |
|||
*/ |
|||
async openFolder(entry: { name: string; path: string }) { |
|||
this.currentPath.push(entry.name); |
|||
await this.loadDirectory(); |
|||
} |
|||
|
|||
/** |
|||
* Navigate to a breadcrumb |
|||
*/ |
|||
async goToBreadcrumb(index: number) { |
|||
this.currentPath = this.currentPath.slice(0, index + 1); |
|||
await this.loadDirectory(); |
|||
} |
|||
|
|||
/** |
|||
* Go up one directory |
|||
*/ |
|||
async goUp() { |
|||
if (this.currentPath.length > 1) { |
|||
this.currentPath.pop(); |
|||
await this.loadDirectory(); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Computed property for breadcrumbs |
|||
*/ |
|||
get breadcrumbs() { |
|||
return this.currentPath; |
|||
} |
|||
|
|||
/** |
|||
* Computed property for showing files and folders |
|||
*/ |
|||
get folders() { |
|||
return this.directoryEntries.filter(e => e.type === 'folder'); |
|||
} |
|||
get files() { |
|||
return this.directoryEntries.filter(e => e.type === 'file'); |
|||
} |
|||
|
|||
/** |
|||
* Refreshes the list of backup files from the device |
|||
*/ |
|||
async refreshFiles() { |
|||
if (!this.platformCapabilities.hasFileSystem) { |
|||
return; |
|||
} |
|||
|
|||
this.isLoading = true; |
|||
try { |
|||
this.backupFiles = await this.platformService.listBackupFiles(); |
|||
|
|||
logger.log("[BackupFilesList] Refreshed backup files:", { |
|||
count: this.backupFiles.length, |
|||
files: this.backupFiles.map(f => ({ |
|||
name: f.name, |
|||
type: f.type, |
|||
path: f.path, |
|||
size: f.size |
|||
})), |
|||
platform: this.platformCapabilities.isIOS ? "iOS" : "Android", |
|||
timestamp: new Date().toISOString(), |
|||
}); |
|||
|
|||
// Debug: Log file type distribution |
|||
const typeCounts = { |
|||
contacts: this.backupFiles.filter(f => f.type === 'contacts').length, |
|||
seed: this.backupFiles.filter(f => f.type === 'seed').length, |
|||
other: this.backupFiles.filter(f => f.type === 'other').length, |
|||
total: this.backupFiles.length |
|||
}; |
|||
logger.log("[BackupFilesList] File type distribution:", typeCounts); |
|||
} catch (error) { |
|||
logger.error("[BackupFilesList] Failed to refresh backup files:", error); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error Loading Files", |
|||
text: "Failed to load backup files from your device.", |
|||
}, |
|||
5000, |
|||
); |
|||
} finally { |
|||
this.isLoading = false; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Public method to refresh files from external components |
|||
* Used by DataExportSection to refresh after saving new files |
|||
*/ |
|||
public async refreshAfterSave() { |
|||
logger.log("[BackupFilesList] Refreshing files after save operation"); |
|||
await this.refreshFiles(); |
|||
} |
|||
|
|||
/** |
|||
* Opens a specific file in the device's file viewer |
|||
* @param fileUri - URI of the file to open |
|||
* @param fileName - Name of the file for display |
|||
*/ |
|||
async openFile(fileUri: string, fileName: string) { |
|||
try { |
|||
const result = await this.platformService.openFile(fileUri, fileName); |
|||
|
|||
if (result.success) { |
|||
logger.log("[BackupFilesList] File opened successfully:", { |
|||
fileName, |
|||
timestamp: new Date().toISOString(), |
|||
}); |
|||
} else { |
|||
throw new Error(result.error || "Failed to open file"); |
|||
} |
|||
} catch (error) { |
|||
logger.error("[BackupFilesList] Failed to open file:", error); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error Opening File", |
|||
text: `Failed to open ${fileName}. ${error instanceof Error ? error.message : String(error)}`, |
|||
}, |
|||
5000, |
|||
); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Opens the backup directory in the device's file explorer |
|||
*/ |
|||
async openBackupDirectory() { |
|||
try { |
|||
const result = await this.platformService.openBackupDirectory(); |
|||
|
|||
if (result.success) { |
|||
logger.log("[BackupFilesList] Backup directory opened successfully:", { |
|||
timestamp: new Date().toISOString(), |
|||
}); |
|||
} else { |
|||
throw new Error(result.error || "Failed to open backup directory"); |
|||
} |
|||
} catch (error) { |
|||
logger.error("[BackupFilesList] Failed to open backup directory:", error); |
|||
this.$notify( |
|||
{ |
|||
group: "alert", |
|||
type: "danger", |
|||
title: "Error Opening Directory", |
|||
text: `Failed to open backup directory. ${error instanceof Error ? error.message : String(error)}`, |
|||
}, |
|||
5000, |
|||
); |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Gets the count of files for a specific type |
|||
* @param type - File type to count |
|||
* @returns Number of files of the specified type |
|||
*/ |
|||
getFileCountByType(type: 'all' | 'contacts' | 'seed' | 'other'): number { |
|||
if (type === 'all') { |
|||
return this.backupFiles.length; |
|||
} |
|||
return this.backupFiles.filter(file => file.type === type).length; |
|||
} |
|||
|
|||
/** |
|||
* Gets the appropriate icon for a file type |
|||
* @param type - File type |
|||
* @returns FontAwesome icon name |
|||
*/ |
|||
getFileIcon(type: 'contacts' | 'seed' | 'other'): string { |
|||
switch (type) { |
|||
case 'contacts': |
|||
return 'address-book'; |
|||
case 'seed': |
|||
return 'key'; |
|||
default: |
|||
return 'file-alt'; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Gets the appropriate icon color for a file type |
|||
* @param type - File type |
|||
* @returns CSS color class |
|||
*/ |
|||
getFileIconColor(type: 'contacts' | 'seed' | 'other'): string { |
|||
switch (type) { |
|||
case 'contacts': |
|||
return 'text-blue-500'; |
|||
case 'seed': |
|||
return 'text-orange-500'; |
|||
default: |
|||
return 'text-gray-500'; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Gets the appropriate badge color for a file type |
|||
* @param type - File type |
|||
* @returns CSS color class |
|||
*/ |
|||
getTypeBadgeColor(type: 'contacts' | 'seed' | 'other'): string { |
|||
switch (type) { |
|||
case 'contacts': |
|||
return 'bg-blue-100 text-blue-800'; |
|||
case 'seed': |
|||
return 'bg-orange-100 text-orange-800'; |
|||
default: |
|||
return 'bg-gray-100 text-gray-800'; |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Formats file size in human-readable format |
|||
* @param bytes - File size in bytes |
|||
* @returns Formatted file size string |
|||
*/ |
|||
formatFileSize(bytes: number): string { |
|||
if (bytes === 0) return '0 Bytes'; |
|||
|
|||
const k = 1024; |
|||
const sizes = ['Bytes', 'KB', 'MB', 'GB']; |
|||
const i = Math.floor(Math.log(bytes) / Math.log(k)); |
|||
|
|||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; |
|||
} |
|||
|
|||
/** |
|||
* Debug method to test file discovery |
|||
* Can be called from browser console for troubleshooting |
|||
*/ |
|||
public async debugFileDiscovery() { |
|||
try { |
|||
logger.log("[BackupFilesList] Starting debug file discovery..."); |
|||
|
|||
// Test the platform service's test methods |
|||
const platformService = PlatformServiceFactory.getInstance(); |
|||
|
|||
// Test listing all user files |
|||
const allFilesResult = await platformService.testListUserFiles(); |
|||
logger.log("[BackupFilesList] All user files test result:", allFilesResult); |
|||
|
|||
// Test listing backup files specifically |
|||
const backupFilesResult = await platformService.testBackupFiles(); |
|||
logger.log("[BackupFilesList] Backup files test result:", backupFilesResult); |
|||
|
|||
// Test listing all backup files (if available) |
|||
if ('testListAllBackupFiles' in platformService) { |
|||
const allBackupFilesResult = await (platformService as any).testListAllBackupFiles(); |
|||
logger.log("[BackupFilesList] All backup files test result:", allBackupFilesResult); |
|||
} |
|||
|
|||
// Test debug listing all files without filtering (if available) |
|||
if ('debugListAllFiles' in platformService) { |
|||
const debugAllFiles = await (platformService as any).debugListAllFiles(); |
|||
logger.log("[BackupFilesList] Debug all files (no filtering):", { |
|||
count: debugAllFiles.length, |
|||
files: debugAllFiles.map((f: any) => ({ name: f.name, path: f.path, size: f.size })) |
|||
}); |
|||
} |
|||
|
|||
// Test comprehensive step-by-step debug (if available) |
|||
if ('debugFileDiscoveryStepByStep' in platformService) { |
|||
const stepByStepDebug = await (platformService as any).debugFileDiscoveryStepByStep(); |
|||
logger.log("[BackupFilesList] Step-by-step debug output:", stepByStepDebug); |
|||
} |
|||
|
|||
return { |
|||
allFiles: allFilesResult, |
|||
backupFiles: backupFilesResult, |
|||
currentBackupFiles: this.backupFiles, |
|||
debugAllFiles: 'debugListAllFiles' in platformService ? await (platformService as any).debugListAllFiles() : null |
|||
}; |
|||
} catch (error) { |
|||
logger.error("[BackupFilesList] Debug file discovery failed:", error); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
@Watch('platformCapabilities.hasFileSystem', { immediate: true }) |
|||
async onFileSystemCapabilityChanged(newVal: boolean) { |
|||
if (newVal) { |
|||
await this.refreshFiles(); |
|||
} |
|||
} |
|||
} |
|||
</script> |
Loading…
Reference in new issue