Claude: File chunking system
This commit is contained in:
committed by
Rune Harlyk
parent
725d62747d
commit
f440fa3973
@@ -0,0 +1,374 @@
|
||||
<script lang="ts">
|
||||
import { fileSystemClient, type TransferProgress } from '$lib/filesystem/chunkedTransfer'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
let currentPath = '/'
|
||||
let files: Array<{ name: string; size: number }> = []
|
||||
let directories: Array<{ name: string }> = []
|
||||
let loading = false
|
||||
let error = ''
|
||||
let uploadProgress: TransferProgress | null = null
|
||||
let downloadProgress: TransferProgress | null = null
|
||||
|
||||
async function loadDirectory() {
|
||||
loading = true
|
||||
error = ''
|
||||
try {
|
||||
const result = await fileSystemClient.listDirectory(currentPath)
|
||||
if (result.success) {
|
||||
files = result.files
|
||||
directories = result.directories
|
||||
} else {
|
||||
error = result.error || 'Failed to load directory'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Unknown error'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function navigateTo(path: string) {
|
||||
currentPath = path
|
||||
await loadDirectory()
|
||||
}
|
||||
|
||||
async function handleFileUpload(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
uploadProgress = null
|
||||
error = ''
|
||||
|
||||
const destinationPath = currentPath.endsWith('/')
|
||||
? currentPath + file.name
|
||||
: currentPath + '/' + file.name
|
||||
|
||||
try {
|
||||
const result = await fileSystemClient.uploadFileFromBrowser(
|
||||
destinationPath,
|
||||
file,
|
||||
(progress) => {
|
||||
uploadProgress = progress
|
||||
}
|
||||
)
|
||||
|
||||
if (result.success) {
|
||||
await loadDirectory()
|
||||
} else {
|
||||
error = result.error || 'Upload failed'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Upload error'
|
||||
} finally {
|
||||
uploadProgress = null
|
||||
input.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDownload(filename: string) {
|
||||
downloadProgress = null
|
||||
error = ''
|
||||
|
||||
const filePath = currentPath.endsWith('/')
|
||||
? currentPath + filename
|
||||
: currentPath + '/' + filename
|
||||
|
||||
try {
|
||||
const result = await fileSystemClient.downloadFileAndSave(filePath, filename, (progress) => {
|
||||
downloadProgress = progress
|
||||
})
|
||||
|
||||
if (!result.success) {
|
||||
error = result.error || 'Download failed'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Download error'
|
||||
} finally {
|
||||
downloadProgress = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(name: string, isDirectory: boolean) {
|
||||
if (!confirm(`Delete ${isDirectory ? 'directory' : 'file'} "${name}"?`)) return
|
||||
|
||||
error = ''
|
||||
const path = currentPath.endsWith('/') ? currentPath + name : currentPath + '/' + name
|
||||
|
||||
try {
|
||||
const result = await fileSystemClient.deleteFile(path)
|
||||
if (result.success) {
|
||||
await loadDirectory()
|
||||
} else {
|
||||
error = result.error || 'Delete failed'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Delete error'
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateDirectory() {
|
||||
const name = prompt('Enter directory name:')
|
||||
if (!name) return
|
||||
|
||||
error = ''
|
||||
const path = currentPath.endsWith('/') ? currentPath + name : currentPath + '/' + name
|
||||
|
||||
try {
|
||||
const result = await fileSystemClient.createDirectory(path)
|
||||
if (result.success) {
|
||||
await loadDirectory()
|
||||
} else {
|
||||
error = result.error || 'Failed to create directory'
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Error creating directory'
|
||||
}
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadDirectory()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="file-manager">
|
||||
<div class="toolbar">
|
||||
<h2>File Manager</h2>
|
||||
<div class="path">Current: {currentPath}</div>
|
||||
<div class="actions">
|
||||
<button on:click={handleCreateDirectory}>New Folder</button>
|
||||
<label class="upload-btn">
|
||||
Upload File
|
||||
<input type="file" on:change={handleFileUpload} style="display: none;" />
|
||||
</label>
|
||||
<button on:click={loadDirectory}>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if uploadProgress}
|
||||
<div class="progress">
|
||||
<div class="progress-label">
|
||||
Uploading: {uploadProgress.percentage.toFixed(1)}% ({formatBytes(
|
||||
uploadProgress.bytesTransferred
|
||||
)} / {formatBytes(uploadProgress.totalBytes)})
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {uploadProgress.percentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if downloadProgress}
|
||||
<div class="progress">
|
||||
<div class="progress-label">
|
||||
Downloading: {downloadProgress.percentage.toFixed(1)}% ({formatBytes(
|
||||
downloadProgress.bytesTransferred
|
||||
)} / {formatBytes(downloadProgress.totalBytes)})
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {downloadProgress.percentage}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="file-list">
|
||||
{#if loading}
|
||||
<div class="loading">Loading...</div>
|
||||
{:else}
|
||||
{#if currentPath !== '/'}
|
||||
<div class="file-item directory" on:click={() => navigateTo('/')}>
|
||||
<span class="icon">📁</span>
|
||||
<span class="name">..</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#each directories as dir}
|
||||
<div class="file-item directory">
|
||||
<span class="icon">📁</span>
|
||||
<span class="name" on:click={() => navigateTo(currentPath + '/' + dir.name)}
|
||||
>{dir.name}</span
|
||||
>
|
||||
<button class="delete-btn" on:click={() => handleDelete(dir.name, true)}>Delete</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each files as file}
|
||||
<div class="file-item">
|
||||
<span class="icon">📄</span>
|
||||
<span class="name">{file.name}</span>
|
||||
<span class="size">{formatBytes(file.size)}</span>
|
||||
<button class="download-btn" on:click={() => handleDownload(file.name)}>Download</button>
|
||||
<button class="delete-btn" on:click={() => handleDelete(file.name, false)}>Delete</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#if files.length === 0 && directories.length === 0}
|
||||
<div class="empty">Directory is empty</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.file-manager {
|
||||
max-width: 800px;
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.toolbar h2 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.path {
|
||||
font-family: monospace;
|
||||
background: #f5f5f5;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.actions button,
|
||||
.upload-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.actions button:hover,
|
||||
.upload-btn:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 20px;
|
||||
background: #e9ecef;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #28a745;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.file-item.directory {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.size {
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.download-btn,
|
||||
.delete-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
background: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
background: #218838;
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,326 @@
|
||||
import { socket } from '$lib/stores/socket'
|
||||
import * as Messages from '$lib/platform_shared/message'
|
||||
import type {
|
||||
FSDeleteRequest,
|
||||
FSMkdirRequest,
|
||||
FSListRequest,
|
||||
FSDownloadStartRequest,
|
||||
FSDownloadChunkRequest,
|
||||
FSUploadStartRequest,
|
||||
FSUploadChunkRequest,
|
||||
FSCancelTransferRequest,
|
||||
CorrelationResponse
|
||||
} from '$lib/platform_shared/message'
|
||||
|
||||
const MAX_CHUNK_SIZE = 1024
|
||||
|
||||
export interface FileInfo {
|
||||
name: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export interface DirectoryInfo {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface ListResult {
|
||||
success: boolean
|
||||
error?: string
|
||||
files: FileInfo[]
|
||||
directories: DirectoryInfo[]
|
||||
}
|
||||
|
||||
export interface TransferProgress {
|
||||
transferId: string
|
||||
bytesTransferred: number
|
||||
totalBytes: number
|
||||
chunksCompleted: number
|
||||
totalChunks: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
export type ProgressCallback = (progress: TransferProgress) => void
|
||||
|
||||
export class FileSystemClient {
|
||||
/**
|
||||
* Delete a file or directory on the ESP32
|
||||
*/
|
||||
async deleteFile(path: string): Promise<{ success: boolean; error?: string }> {
|
||||
const request: FSDeleteRequest = { path }
|
||||
|
||||
const response = await socket.request({
|
||||
fsDeleteRequest: request
|
||||
})
|
||||
|
||||
if (response.fsDeleteResponse) {
|
||||
return {
|
||||
success: response.fsDeleteResponse.success,
|
||||
error: response.fsDeleteResponse.error || undefined
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: 'No response received' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a directory on the ESP32
|
||||
*/
|
||||
async createDirectory(path: string): Promise<{ success: boolean; error?: string }> {
|
||||
const request: FSMkdirRequest = { path }
|
||||
|
||||
const response = await socket.request({
|
||||
fsMkdirRequest: request
|
||||
})
|
||||
|
||||
if (response.fsMkdirResponse) {
|
||||
return {
|
||||
success: response.fsMkdirResponse.success,
|
||||
error: response.fsMkdirResponse.error || undefined
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: 'No response received' }
|
||||
}
|
||||
|
||||
/**
|
||||
* List files and directories at the given path
|
||||
*/
|
||||
async listDirectory(path: string = '/'): Promise<ListResult> {
|
||||
const request: FSListRequest = { path }
|
||||
|
||||
const response = await socket.request({
|
||||
fsListRequest: request
|
||||
})
|
||||
|
||||
if (response.fsListResponse) {
|
||||
const resp = response.fsListResponse
|
||||
return {
|
||||
success: resp.success,
|
||||
error: resp.error || undefined,
|
||||
files: (resp.files || []).map((f) => ({ name: f.name, size: f.size })),
|
||||
directories: (resp.directories || []).map((d) => ({ name: d.name }))
|
||||
}
|
||||
}
|
||||
|
||||
return { success: false, error: 'No response received', files: [], directories: [] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from the ESP32
|
||||
*/
|
||||
async downloadFile(
|
||||
path: string,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<{ success: boolean; data?: Uint8Array; error?: string }> {
|
||||
// Start download
|
||||
const startRequest: FSDownloadStartRequest = { path }
|
||||
|
||||
const startResponse = await socket.request({
|
||||
fsDownloadStartRequest: startRequest
|
||||
})
|
||||
|
||||
if (!startResponse.fsDownloadStartResponse) {
|
||||
return { success: false, error: 'Failed to start download' }
|
||||
}
|
||||
|
||||
const startResp = startResponse.fsDownloadStartResponse
|
||||
|
||||
if (!startResp.success) {
|
||||
return { success: false, error: startResp.error || 'Failed to start download' }
|
||||
}
|
||||
|
||||
const transferId = startResp.transferId
|
||||
const totalChunks = startResp.totalChunks
|
||||
const fileSize = startResp.fileSize
|
||||
|
||||
// Allocate buffer for entire file
|
||||
const buffer = new Uint8Array(fileSize)
|
||||
let offset = 0
|
||||
|
||||
// Download chunks sequentially
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
const chunkRequest: FSDownloadChunkRequest = {
|
||||
transferId,
|
||||
chunkIndex
|
||||
}
|
||||
|
||||
const chunkResponse = await socket.request({
|
||||
fsDownloadChunkRequest: chunkRequest
|
||||
})
|
||||
|
||||
if (!chunkResponse.fsDownloadChunkResponse) {
|
||||
await this.cancelTransfer(transferId)
|
||||
return { success: false, error: `Failed to download chunk ${chunkIndex}` }
|
||||
}
|
||||
|
||||
const chunkResp = chunkResponse.fsDownloadChunkResponse
|
||||
|
||||
if (chunkResp.error) {
|
||||
await this.cancelTransfer(transferId)
|
||||
return { success: false, error: chunkResp.error }
|
||||
}
|
||||
|
||||
// Copy chunk data to buffer
|
||||
if (chunkResp.data) {
|
||||
buffer.set(chunkResp.data, offset)
|
||||
offset += chunkResp.data.length
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
transferId,
|
||||
bytesTransferred: offset,
|
||||
totalBytes: fileSize,
|
||||
chunksCompleted: chunkIndex + 1,
|
||||
totalChunks,
|
||||
percentage: ((chunkIndex + 1) / totalChunks) * 100
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, data: buffer }
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to the ESP32
|
||||
*/
|
||||
async uploadFile(
|
||||
path: string,
|
||||
data: Uint8Array,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const fileSize = data.length
|
||||
const chunkSize = MAX_CHUNK_SIZE
|
||||
const totalChunks = Math.ceil(fileSize / chunkSize)
|
||||
|
||||
// Start upload
|
||||
const startRequest: FSUploadStartRequest = {
|
||||
path,
|
||||
fileSize,
|
||||
chunkSize
|
||||
}
|
||||
|
||||
const startResponse = await socket.request({
|
||||
fsUploadStartRequest: startRequest
|
||||
})
|
||||
|
||||
if (!startResponse.fsUploadStartResponse) {
|
||||
return { success: false, error: 'Failed to start upload' }
|
||||
}
|
||||
|
||||
const startResp = startResponse.fsUploadStartResponse
|
||||
|
||||
if (!startResp.success) {
|
||||
return { success: false, error: startResp.error || 'Failed to start upload' }
|
||||
}
|
||||
|
||||
const transferId = startResp.transferId
|
||||
const maxChunkSize = startResp.maxChunkSize || MAX_CHUNK_SIZE
|
||||
|
||||
// Upload chunks sequentially
|
||||
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
|
||||
const offset = chunkIndex * chunkSize
|
||||
const end = Math.min(offset + chunkSize, fileSize)
|
||||
const chunkData = data.slice(offset, end)
|
||||
const isLast = chunkIndex === totalChunks - 1
|
||||
|
||||
const chunkRequest: FSUploadChunkRequest = {
|
||||
transferId,
|
||||
chunkIndex,
|
||||
data: chunkData,
|
||||
isLast
|
||||
}
|
||||
|
||||
const chunkResponse = await socket.request({
|
||||
fsUploadChunkRequest: chunkRequest
|
||||
})
|
||||
|
||||
if (!chunkResponse.fsUploadChunkResponse) {
|
||||
await this.cancelTransfer(transferId)
|
||||
return { success: false, error: `Failed to upload chunk ${chunkIndex}` }
|
||||
}
|
||||
|
||||
const chunkResp = chunkResponse.fsUploadChunkResponse
|
||||
|
||||
if (!chunkResp.success) {
|
||||
await this.cancelTransfer(transferId)
|
||||
return { success: false, error: chunkResp.error || 'Failed to upload chunk' }
|
||||
}
|
||||
|
||||
// Report progress
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
transferId,
|
||||
bytesTransferred: end,
|
||||
totalBytes: fileSize,
|
||||
chunksCompleted: chunkIndex + 1,
|
||||
totalChunks,
|
||||
percentage: ((chunkIndex + 1) / totalChunks) * 100
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an ongoing transfer
|
||||
*/
|
||||
async cancelTransfer(transferId: string): Promise<{ success: boolean }> {
|
||||
const request: FSCancelTransferRequest = { transferId }
|
||||
|
||||
const response = await socket.request({
|
||||
fsCancelTransferRequest: request
|
||||
})
|
||||
|
||||
if (response.fsCancelTransferResponse) {
|
||||
return { success: response.fsCancelTransferResponse.success }
|
||||
}
|
||||
|
||||
return { success: false }
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Upload a File object from browser
|
||||
*/
|
||||
async uploadFileFromBrowser(
|
||||
destinationPath: string,
|
||||
file: File,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const arrayBuffer = await file.arrayBuffer()
|
||||
const data = new Uint8Array(arrayBuffer)
|
||||
return this.uploadFile(destinationPath, data, onProgress)
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Download a file and save it to browser
|
||||
*/
|
||||
async downloadFileAndSave(
|
||||
path: string,
|
||||
filename: string,
|
||||
onProgress?: ProgressCallback
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
const result = await this.downloadFile(path, onProgress)
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
return { success: false, error: result.error }
|
||||
}
|
||||
|
||||
// Create blob and trigger download
|
||||
const blob = new Blob([result.data])
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
}
|
||||
|
||||
export const fileSystemClient = new FileSystemClient()
|
||||
Reference in New Issue
Block a user