Claude: File chunking system

This commit is contained in:
Niklas Jensen
2026-01-05 20:33:16 +01:00
committed by Rune Harlyk
parent 725d62747d
commit f440fa3973
8 changed files with 1472 additions and 19 deletions
+6 -6
View File
@@ -1,8 +1,8 @@
declare module 'app-env' {
interface ENV {
VITE_USE_HOST_NAME: boolean
}
declare module "app-env" {
interface ENV {
VITE_USE_HOST_NAME: boolean;
}
const appEnv: ENV
export default appEnv
const appEnv: ENV;
export default appEnv;
}
@@ -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>
+326
View File
@@ -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()