Update uploading widget

This commit is contained in:
2026-10-05 21:47:04 +02:00
parent f3738cbc4d
commit 1f7238841a
9 changed files with 514 additions and 404 deletions

View File

@@ -5,7 +5,6 @@ import Toolbar from "./Toolbar.svelte";
import Breadcrumbs from "./Breadcrumbs.svelte"; import Breadcrumbs from "./Breadcrumbs.svelte";
import DetailsWindow from "./DetailsWindow.svelte"; import DetailsWindow from "./DetailsWindow.svelte";
import FilePreview from "./viewers/FilePreview.svelte"; import FilePreview from "./viewers/FilePreview.svelte";
import FSUploadWidget from "./upload_widget/FSUploadWidget.svelte";
import { global_navigator } from "./FSNavigator" import { global_navigator } from "./FSNavigator"
import { css_from_path } from "filesystem/edit_window/Branding"; import { css_from_path } from "filesystem/edit_window/Branding";
import { update_favicon } from "util/NovaLogo.svelte"; import { update_favicon } from "util/NovaLogo.svelte";
@@ -15,7 +14,6 @@ import ErrorPage from "./ErrorPage.svelte";
let file_preview: FilePreview = $state() let file_preview: FilePreview = $state()
let toolbar: Toolbar = $state() let toolbar: Toolbar = $state()
let upload_widget: FSUploadWidget = $state()
let details_visible = $state(false) let details_visible = $state(false)
let edit_window: EditWindow = $state() let edit_window: EditWindow = $state()
let edit_visible = $state(false) let edit_visible = $state(false)
@@ -142,7 +140,6 @@ const keydown = (e: KeyboardEvent) => {
<FilePreview <FilePreview
bind:this={file_preview} bind:this={file_preview}
nav={nav} nav={nav}
upload_widget={upload_widget}
edit_window={edit_window} edit_window={edit_window}
details_window={details_window} details_window={details_window}
/> />
@@ -165,10 +162,6 @@ const keydown = (e: KeyboardEvent) => {
<EditWindow nav={nav} bind:this={edit_window} bind:visible={edit_visible} /> <EditWindow nav={nav} bind:this={edit_window} bind:visible={edit_visible} />
<!-- This one is included at the highest level so uploads can keep running
even when the user navigates to a different directory -->
<FSUploadWidget nav={nav} bind:this={upload_widget} />
<AffiliatePrompt/> <AffiliatePrompt/>
<style> <style>

View File

@@ -14,7 +14,7 @@ import Button from "layout/Button.svelte";
import { formatDate } from "util/Formatting"; import { formatDate } from "util/Formatting";
import { drop_target } from "lib/DropTarget" import { drop_target } from "lib/DropTarget"
import { drag_click, move_target, type FSNavigator } from "filesystem/FSNavigator"; import { drag_click, move_target, type FSNavigator } from "filesystem/FSNavigator";
import FsUploadWidget from "filesystem/upload_widget/FSUploadWidget.svelte"; import { pick_files, upload_files } from "filesystem/upload_widget/FSUploadWidget.svelte";
import EditWindow from "filesystem/edit_window/EditWindow.svelte"; import EditWindow from "filesystem/edit_window/EditWindow.svelte";
import { FileAction, type FileActionHandler } from "./FileManagerLib"; import { FileAction, type FileActionHandler } from "./FileManagerLib";
import FileMenu from "./FileMenu.svelte"; import FileMenu from "./FileMenu.svelte";
@@ -25,20 +25,17 @@ import { toolbar_store } from "wrap/HeaderStore";
let { let {
nav = $bindable(), nav = $bindable(),
upload_widget,
edit_window = $bindable(), edit_window = $bindable(),
directory_view = $bindable(localStorage.getItem("directory_view") || "list"), directory_view = $bindable(localStorage.getItem("directory_view") || "list"),
children children
}: { }: {
nav: FSNavigator; nav: FSNavigator;
upload_widget: FsUploadWidget;
edit_window: EditWindow; edit_window: EditWindow;
directory_view?: string; directory_view?: string;
children?: import('svelte').Snippet; children?: import('svelte').Snippet;
} = $props(); } = $props();
let large_icons = $state(localStorage.getItem("large_icons") === "true") let large_icons = $state(localStorage.getItem("large_icons") === "true")
let uploader!: FsUploadWidget
// In selection mode a click selects. Moving is the part of selection mode where // In selection mode a click selects. Moving is the part of selection mode where
// the user browses to the directory which the selection has to go to // the user browses to the directory which the selection has to go to
let moving = $state(false) let moving = $state(false)
@@ -49,10 +46,6 @@ $effect(() => {
window.scrollTo(0, scroll_positions.get($nav.base.path) ?? 0) window.scrollTo(0, scroll_positions.get($nav.base.path) ?? 0)
}) })
export const upload = (files: File[]) => {
return uploader.upload_files(files)
}
// Navigation functions // Navigation functions
const file_event: FileActionHandler = (action: FileAction, index: number, orig: Event) => { const file_event: FileActionHandler = (action: FileAction, index: number, orig: Event) => {
const node = nav.children[index] const node = nav.children[index]
@@ -244,7 +237,7 @@ onMount(() => {
<div class="toolbar_right"> <div class="toolbar_right">
{#if $nav.permissions.write} {#if $nav.permissions.write}
<button onclick={() => upload_widget.pick_files()} title="Upload files to this directory"> <button onclick={() => pick_files()} title="Upload files to this directory">
<i class="icon">cloud_upload</i> <i class="icon">cloud_upload</i>
</button> </button>
@@ -296,7 +289,7 @@ onMount(() => {
class="container" class="container"
use:move_target={{nav, node: () => nav.base}} use:move_target={{nav, node: () => nav.base}}
use:drop_target={{ use:drop_target={{
upload: (files) => upload_widget.upload_files(files), upload: files => upload_files(files),
shadow: "var(--accent_color) 0 0 10px 2px inset", shadow: "var(--accent_color) 0 0 10px 2px inset",
}} }}
> >

View File

@@ -1,157 +1,363 @@
<script lang="ts" module> <script lang="ts" module>
export type UploadJob = { // A file, and its path relative to the directory which it is uploaded to
task_id: number, export type Upload = { file: File, path: string }
file: File,
path: string, // The widget is mounted once, in App.svelte, so that uploads keep running when
component: UploadProgress, // the user opens another page. It sets these functions when it mounts
status: string, export let upload_files: (files: Upload[] | Promise<Upload[]>) => void
total_size: number, export let pick_files: () => void
loaded_size: number,
};
</script> </script>
<script lang="ts"> <script lang="ts">
import { tick } from "svelte"; import { global_navigator as nav } from "filesystem/FSNavigator";
import UploadProgress from "./UploadProgress.svelte"; import { fs_path_url } from "lib/FilesystemAPI.svelte";
import type { FSNavigator } from "filesystem/FSNavigator"; import { loading_run } from "lib/Loading";
import { user } from "lib/UserStore";
import { formatDataVolume, formatDuration } from "util/Formatting";
import ProgressBar from "util/ProgressBar.svelte";
import Button from "layout/Button.svelte";
let { nav }: { type Job = Upload & {
nav: FSNavigator; dir: string,
} = $props(); loaded: number,
// What loaded was at the previous update, the difference gives the speed
let file_input_field: HTMLInputElement = $state() prev: number,
let file_input_change = (e: Event) => { speed: number,
// Start uploading the files async xhr?: XMLHttpRequest,
upload_files((e.target as HTMLInputElement).files)
// This resets the file input field
file_input_field.nodeValue = ""
}
export const pick_files = () => {
file_input_field.click()
}
let visible = $state(false)
let upload_queue: UploadJob[] = $state([]);
let task_id_counter = 0
export const upload_files = async (files: File[]|FileList) => {
if (files.length === 0) {
return
} else if (nav.base.type !== "dir") {
alert("Can only upload to directory")
return
}
// Add files to the queue
for (let i = 0; i < files.length; i++) {
await upload_file(files[i])
}
}
export const upload_file = async (file: File) => {
if (nav.base.type !== "dir") {
alert("Can only upload to directory")
return
} else if (file.type === "" && file.size === 0) {
return
}
let path = nav.base.path + "/"
if (file.webkitRelativePath) {
path += file.webkitRelativePath
} else {
path += file.name
}
upload_queue.push({
task_id: task_id_counter,
file: file,
path: path,
component: null,
status: "queued",
total_size: file.size,
loaded_size: 0,
})
task_id_counter++
// Reassign array and wait for tick to complete. After the tick is completed
// each upload progress bar will have bound itself to its array item
upload_queue = upload_queue
if (active_uploads === 0 && status !== "uploading") {
status = "uploading"
visible = true
await tick()
await start_upload()
}
} }
const min_uploads = 2 const min_uploads = 2
const max_uploads = 20 const max_uploads = 20
// Milliseconds between two updates of the widget. The progress bars take as
// long to move to their new position
const update_interval = 250
let active_uploads = 0 // The uploads are deliberately not reactive. A dropped directory can hold
let status = $state("idle") // hundreds of thousands of files, and every running upload reports progress
// many times per second. Every update_interval, update() copies what the
// widget shows to view, so the cost of rendering does not depend on the size of
// the queue
let queue: Job[] = []
let active: Job[] = []
let failed: { path: string, message: string }[] = []
let files_total = 0
let files_done = 0
// In bytes. Files which failed or were cancelled are taken off the total
let size_total = 0
let size_done = 0
// Every byte which was sent, also for uploads which were aborted later
let transferred = 0
let transferred_prev = 0
let speed = 0
let updated = 0
let timer = 0
// What was transferred at each update of the last ten seconds, and how many
// bytes the running uploads may have left to send together, see schedule()
let samples: { time: number, sent: number }[] = []
let threshold = 0
// The title which the page set, and the one we replaced it with
let page_title = ""
let title = ""
const start_upload = async () => { let visible = $state(false)
active_uploads = 0 let collapsed = $state(false)
let uploading_size = 0 let paused = $state(false)
for (let i = 0; i < upload_queue.length; i++) { // The timer which waits to try again after the connection was lost
if (upload_queue[i]) { let retry = $state(0)
// If this file is queued, start the upload let view = $state.raw({
if (upload_queue[i].status === "queued") { uploading: false,
upload_queue[i].component.start() running: 0,
upload_queue[i].status = "uploading" percent: 0,
} loaded: 0,
total: 0,
speed: 0,
eta: "",
files_done: 0,
files_total: 0,
rows: [] as { job: Job, loaded: number, speed: number, queued: boolean }[],
failed,
failed_count: 0,
})
// If this file is already uploading (or just started), count it let file_input: HTMLInputElement
if (upload_queue[i].status === "uploading") { pick_files = () => file_input.click()
uploading_size += upload_queue[i].total_size const file_input_change = () => {
active_uploads++ upload_files(Array.from(file_input.files, file => ({ file, path: file.name })))
}
// If the size threshold or the concurrent upload limit is reached // Without this the same file can't be picked twice in a row
// we break the loop. The system tries to keep an upload queue of file_input.value = ""
// 100 MB and a minimum of two concurrent uploads. }
if (
(uploading_size >= 100e6 && active_uploads >= min_uploads) || upload_files = async files => {
active_uploads >= max_uploads // Reading a large directory takes a while. The files go to the directory
) { // which they were dropped on, also if the user has left it by then
console.debug("Current uploads", active_uploads, "uploads size", uploading_size) const dir = nav.base.path
break try {
} files = await loading_run(async () => files)
} } catch (err) {
alert("Failed to read the files: " + err)
return
}
if (files.length === 0) {
return
} }
if (active_uploads === 0) { if (active.length + queue.length === 0) {
status = "finished" files_total = files_done = size_total = size_done = speed = 0
nav.reload() timer = window.setInterval(() => {
update()
schedule()
}, update_interval)
}
// Empty the queue to free any references to lingering components const limit = $user?.subscription.file_size_limit || 20e9
upload_queue = [] for (const { file, path } of files) {
if (file.size > limit) {
failed.push({ path, message: "This file is too large. Check out " +
"the Pro subscription to increase the file size limit" })
continue
}
queue.push({ file, path, dir, loaded: 0, prev: 0, speed: 0 })
files_total++
size_total += file.size
}
// In ten seconds we close the popup visible = true
schedule()
update()
}
// Starts queued uploads until enough are running: at least two, and as many
// as it takes to have ten seconds of work at the speed of the last ten seconds.
// Small files are sent in large numbers at a time that way, and a slow
// connection is not divided over more large files than it can handle. A new
// connection starts with two uploads, and gets more as its speed shows
const schedule = () => {
let size = 0
for (const job of active) {
size += job.file.size - job.loaded
}
// Empty files don't count towards the speed, a directory full of them
// never gets over a threshold of zero. Hence the <=
while (
!paused && retry === 0 &&
queue.length > 0 && active.length < max_uploads &&
(size <= threshold || active.length < min_uploads)
) {
const job = queue.shift()
size += job.file.size
active.push(job)
// Takes the job off the running uploads. Without an error it's done
const end = (error?: string) => {
job.xhr = undefined
active.splice(active.indexOf(job), 1)
if (error === undefined) {
transferred += job.file.size - job.loaded
files_done++
size_done += job.file.size
} else {
failed.push({ path: job.path, message: error })
files_total--
size_total -= job.file.size
}
schedule()
}
const xhr = job.xhr = new XMLHttpRequest()
xhr.open("PUT", fs_path_url(job.dir + "/" + job.path) + "?make_parents=true")
xhr.upload.onprogress = e => {
transferred += e.loaded - job.loaded
job.loaded = e.loaded
}
xhr.onloadend = () => {
if (job.xhr !== xhr) {
return // Aborted by stop or cancel
} else if (xhr.status >= 400) {
// A proxy in front of the API does not answer with JSON
let error = xhr.statusText
try {
error = JSON.parse(xhr.response).message
} catch {}
end(error)
} else if (xhr.status !== 0) {
end()
} else {
// No response, the connection is gone. The file goes back to
// the front of the queue, and the queue waits. Without the
// wait every file in it would fail within seconds
job.xhr = undefined
active.splice(active.indexOf(job), 1)
job.loaded = job.prev = job.speed = 0
queue.unshift(job)
clearTimeout(retry)
retry = window.setTimeout(() => {
retry = 0
schedule()
}, 10e3)
}
}
// Firefox never ends the request for a file which was removed or
// truncated after it was picked: there is no error, it hangs. Reading
// the last byte of the file first shows if all of it is still there.
// Chromium refuses to read a file which changed in any way, with a
// NotReadableError, but does upload it. That one is not a reason to
// give up
job.file.slice(-1).arrayBuffer().then(
last => last.byteLength === Math.min(1, job.file.size),
err => err.name === "NotReadableError",
).then(readable => {
if (job.xhr !== xhr) {
return
} else if (readable) {
xhr.send(job.file)
} else {
end("The file was changed or removed after it was added")
}
})
}
if (timer !== 0 && active.length + queue.length === 0) {
clearInterval(timer)
timer = 0
paused = false
update()
// Show the new files. The other pages don't use the navigator, there a
// reload would put the directory in the address bar
if (window.location.pathname.startsWith("/d/")) {
nav.reload()
}
// Errors stay until the user has closed them
setTimeout(() => { setTimeout(() => {
if (status === "finished") { if (timer === 0 && failed.length === 0) {
visible = false visible = false
} }
}, 10000) }, 10e3)
} else {
status = "uploading"
} }
} }
const finish_upload = () => { // The request is taken off the job first, that is how its loadend handler knows
// Update the queue so the status updates are properly rendered // that it was aborted on purpose
upload_queue = upload_queue const abort = (job: Job) => {
start_upload() const xhr = job.xhr
job.xhr = undefined
xhr.abort()
}
// A pause only holds the queue, the uploads which are running finish
const toggle_pause = () => {
paused = !paused
schedule()
update()
}
const stop = () => {
clearTimeout(retry)
retry = 0
active.forEach(abort)
active = []
queue = []
failed = []
visible = false
schedule()
}
const cancel = (job: Job) => {
const list = job.xhr === undefined ? queue : active
const index = list.indexOf(job)
if (index === -1) {
return // The row is older than the job, it has finished since
}
if (list === active) {
abort(job)
}
list.splice(index, 1)
files_total--
size_total -= job.file.size
schedule()
update()
}
const update = () => {
const now = performance.now()
const dt = (now - updated) / 1000
updated = now
// Speeds are moving averages over about the last three seconds. A sample
// weighs as much as the time which it covers, because the timer is not
// steady: browsers slow it down in a background tab, to once per minute even
const weight = 1 - Math.exp(-dt / 3)
const average = (speed: number, bytes: number) =>
dt > 0 ? speed + (bytes / dt - speed) * weight : speed
speed = average(speed, transferred - transferred_prev)
transferred_prev = transferred
// Ten seconds at the average speed of the last ten seconds is what was
// sent in that time. One sample from before it stays as the start of the
// period: in a background tab the timer runs as little as once per minute,
// and the period would be empty. It is longer than ten seconds then, so
// the amount is scaled back to ten
samples.push({ time: now, sent: transferred })
while (samples.length > 2 && now - samples[1].time >= 10e3) {
samples.shift()
}
threshold = (transferred - samples[0].sent) * 10e3 /
Math.max(10e3, now - samples[0].time)
let loaded = size_done
const rows = active.map(job => {
job.speed = average(job.speed, job.loaded - job.prev)
job.prev = job.loaded
loaded += job.loaded
return { job, loaded: job.loaded, speed: job.speed, queued: false }
})
// The start of the queue is shown too. With only the running uploads the
// list changes height all the time, and the widget jumps with it
for (const job of queue.slice(0, 50)) {
rows.push({ job, loaded: 0, speed: 0, queued: true })
}
const uploading = active.length + queue.length > 0
const percent = Math.floor(loaded / size_total * 100) || 0
// There is no sensible estimate while nothing is being sent, nor when it
// is over a hundred days, which is what a stalled connection gives.
// ponytail: estimated from bytes alone, which is too low when small files
// with their overhead per request are mixed with large ones. Weigh in the
// files per second if that turns out to matter
const left = (Math.floor((size_total - loaded) / speed) + 1) * 1000
const eta = !paused && active.length > 0 && left < 100 * 86400e3
? formatDuration(left, left < 60e3 ? 1 : 2)
: ""
view = {
uploading, percent, loaded, speed, eta, rows, files_done, files_total,
running: active.length,
total: size_total,
failed: failed.slice(0, 100),
failed_count: failed.length,
}
// Pages set the title when the user navigates. A title which is not the one
// we set is a new one, the progress goes in front of it
if (document.title !== title) {
page_title = document.title
}
document.title = !uploading ? page_title
: (paused ? `Paused ${percent}% ` : `${percent}% ${eta}`) + "/ " + page_title
// The browser collapses the whitespace in a title, so what we get back
// is not always what we set
title = document.title
} }
const leave_confirmation = (e: BeforeUnloadEvent) => { const leave_confirmation = (e: BeforeUnloadEvent) => {
if (status === "uploading") { if (active.length + queue.length > 0) {
e.preventDefault() e.preventDefault()
return "If you close this page your files will stop uploading. Do you want to continue?"
} else {
return null
} }
} }
</script> </script>
@@ -159,33 +365,94 @@ const leave_confirmation = (e: BeforeUnloadEvent) => {
<svelte:window onbeforeunload={leave_confirmation} /> <svelte:window onbeforeunload={leave_confirmation} />
<input <input
bind:this={file_input_field} bind:this={file_input}
onchange={file_input_change} onchange={file_input_change}
class="upload_input" type="file" name="file" multiple class="upload_input" type="file" name="file" multiple
/> />
{#if visible} {#if visible}
<div class="upload_widget"> <div class="upload_widget bubble">
<div class="header"> <div class="header">
{#if status === "idle"} <div class="grow">
Waiting for files <div class="ellipsis">
{:else if status === "uploading"} {#if !view.uploading}
Uploading files... Done{#if view.failed_count > 0}, {view.failed_count} failed{/if}
{:else if status === "finished"} {:else}
Done {retry !== 0 ? "Connection lost" : paused ? "Paused" : "Uploading"}
{view.percent}%{#if view.running > 0}, {formatDataVolume(view.speed, 3)}/s{/if}{#if view.eta !== ""}, {view.eta}left{/if}
{/if}
</div>
<ProgressBar total={view.total} used={view.loaded} speed={update_interval} animation="linear"/>
</div>
{#if view.uploading}
<Button
flat
icon={paused ? "play_arrow" : "pause"}
title={paused
? "Resume: start uploading the files in the queue again"
: "Pause: finish the uploads which are running, but don't start the files in the queue"}
click={toggle_pause}
/>
<Button
flat icon="stop"
title="Stop: abort the uploads which are running and empty the queue"
click={stop}
/>
{:else}
<Button flat icon="close" title="Close" click={stop}/>
{/if} {/if}
<Button
flat
icon={collapsed ? "expand_less" : "expand_more"}
title={collapsed ? "Expand" : "Collapse"}
click={() => collapsed = !collapsed}
/>
</div> </div>
<div class="body">
{#each upload_queue as job, i} {#if !collapsed}
{#if job.status !== "finished"} <div class="stats">
<UploadProgress {view.files_done} / {view.files_total} files,
bind:this={upload_queue[i].component} {formatDataVolume(view.loaded, 3)} /
bind:job={upload_queue[i]} {formatDataVolume(view.total, 3)}
finish={finish_upload} {#if retry !== 0}
/> <br/>
Trying again every 10 seconds
{/if} {/if}
{/each} </div>
</div> <div class="body">
{#each view.rows as row (row.job)}
<div class="row" class:queued={row.queued}>
<div class="grow">
<div class="ellipsis path" title={row.job.path}>
<span dir="ltr">{row.job.path}</span>
</div>
{#if row.queued}
{formatDataVolume(row.job.file.size, 3)}
{:else}
{formatDataVolume(row.loaded, 3)} /
{formatDataVolume(row.job.file.size, 3)},
{formatDataVolume(row.speed, 3)}/s
<ProgressBar total={row.job.file.size} used={row.loaded} speed={update_interval} animation="linear"/>
{/if}
</div>
<Button icon="cancel" title="Cancel" click={() => cancel(row.job)}/>
</div>
{/each}
{#each view.failed as file}
<div class="error">
<div class="ellipsis path" title={file.path}>
<span dir="ltr">{file.path}</span>
</div>
{file.message}
</div>
{/each}
{#if view.failed_count > view.failed.length}
<div class="error">
And {view.failed_count - view.failed.length} more
</div>
{/if}
</div>
{/if}
</div> </div>
{/if} {/if}
@@ -200,34 +467,59 @@ const leave_confirmation = (e: BeforeUnloadEvent) => {
position: fixed; position: fixed;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: auto; width: 600px;
min-width: 400px; max-width: calc(100% - 40px);
max-width: 80%;
height: auto;
max-height: 50%; max-height: 50%;
right: 20px; right: 20px;
bottom: 20px; bottom: 20px;
border-radius: 8px;
overflow: hidden; overflow: hidden;
color: var(--body_text_color);
box-shadow: 1px 1px 8px var(--shadow_color); box-shadow: 1px 1px 8px var(--shadow_color);
} }
.header { .header {
flex: 0 0 auto; flex: 0 0 auto;
background: var(--background_color); display: flex;
color: var(--background_text_color); align-items: center;
text-align: center; padding: 0 0 0 8px;
border-bottom: 1px solid var(--separator);
}
.header .ellipsis {
font-size: 1.2em; font-size: 1.2em;
padding: 4px; }
white-space: nowrap; .stats {
text-overflow: ellipsis; flex: 0 0 auto;
overflow: hidden; padding: 4px 8px;
text-align: center;
} }
.body { .body {
flex: 1 1 auto; flex: 1 1 auto;
background: var(--body_color);
color: var(--body_text_color);
overflow-y: auto; overflow-y: auto;
}
.row {
display: flex;
align-items: center;
padding: 2px 8px;
}
.grow {
flex: 1 1 auto;
min-width: 0;
}
.ellipsis {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* A path which does not fit loses its start, the file name is the part which
matters. Text is cut off where it overflows, which is on the left in a box
which runs from right to left. The path inside it still runs left to right */
.path {
direction: rtl;
text-align: left; text-align: left;
} }
.error {
padding: 2px 8px;
background: var(--red_tint);
color: var(--red_color);
overflow-wrap: break-word;
}
</style> </style>

View File

@@ -1,83 +0,0 @@
// Uploads a file to the logged in user's Nova account. If no user is
// logged in the file is uploaded anonymously.
//
// on_progress reports progress on the file upload, parameter 1 is the uploaded
// file size and parameter 2 is the total file size
//
// on_success is called when the upload is done, the only parameter is the file
// ID
//
// on_error is called when the upload has failed. The parameters are the error
import { fs_path_url, type GenericResponse } from "lib/FilesystemAPI.svelte"
import { get_user } from "lib/NovaAPI"
// code and an error message
export const upload_file = async (
file: Blob,
path: string,
on_progress: (loaded: number, total: number) => void,
on_success: (id: string) => void,
on_error: (code: string, message: string) => void,
) => {
const user = await get_user()
// Check the file size limit. For free accounts it's 20 GB
if (user !== null && user.subscription.file_size_limit === 0) {
user.subscription.file_size_limit = 20e9
}
if (file.size > user.subscription.file_size_limit) {
on_error(
"file_too_large",
"This file is too large. Check out the Pro subscription to increase the file size limit"
)
return
}
console.debug("Uploading file to", fs_path_url(path))
let xhr = new XMLHttpRequest();
xhr.open("PUT", fs_path_url(path) + "?make_parents=true", true);
xhr.timeout = 86400000; // 24 hours, to account for slow connections
xhr.upload.addEventListener("progress", evt => {
if (on_progress && evt.lengthComputable) {
on_progress(evt.loaded, evt.total)
}
});
xhr.onreadystatechange = () => {
// readystate 4 means the upload is done
if (xhr.readyState !== 4) {
return
}
if (xhr.status >= 100 && xhr.status < 400) {
// Request is a success
on_success(JSON.parse(xhr.response).id)
} else if (xhr.status >= 400) {
// Request failed
console.log("Upload error. status: " + xhr.status + " response: " + xhr.response);
let resp: GenericResponse
if (xhr.status === 429) {
resp = {
value: "too_many_requests",
message: "Too many requests. Please wait a few seconds",
}
} else {
resp = JSON.parse(xhr.response) as GenericResponse
}
on_error(resp.value, resp.message)
} else if (xhr.status === 0) {
on_error("request_failed", "Your request did not arrive, check your network connection")
} else {
on_error(xhr.responseText, xhr.responseText)
}
};
xhr.send(file);
return xhr
}

View File

@@ -1,105 +0,0 @@
<script lang="ts">
import { upload_file } from "./UploadFunc";
import ProgressBar from "util/ProgressBar.svelte";
import Button from "layout/Button.svelte"
import type { UploadJob } from "./FSUploadWidget.svelte";
let {
job = $bindable(),
total = $bindable(0),
loaded = $bindable(0),
finish,
}: {
job: UploadJob
total?: number
loaded?: number
finish: () => void
} = $props();
let error_code = $state("")
let error_message = $state("")
let xhr: XMLHttpRequest = null
export const start = () => {
upload_file(
job.file,
job.path,
(prog_loaded, prog_total) => {
loaded = prog_loaded
total = prog_total
},
async () => {
job.status = "finished"
finish()
},
(code, message) => {
if (job.status === "finished") {
return
}
console.log("Upload error", code, message)
error_code = code
error_message = message
job.status = "error"
// We don't send the finished signal so the user can read the error
// messsage and manually dismiss it through the cancel button
},
).then(result => {
xhr = result
job.status = "uploading"
})
}
const cancel = () => {
job.status = "finished"
if (xhr && xhr.abort) {
xhr.abort()
}
xhr = null
finish()
}
</script>
<div class="prog" class:error={job.status === "error"}>
<div class="bar">
{job.file.name}<br/>
{#if error_code !== ""}
{error_message}<br/>
{error_code}<br/>
{/if}
<ProgressBar total={total} used={loaded} speed={500}/>
</div>
<div class="cancel">
<Button icon="cancel" click={cancel}/>
</div>
</div>
<style>
.prog {
display: flex;
flex-direction: row;
width: 100%;
overflow: hidden;
}
.bar {
flex: 1 1 auto;
padding: 2px 4px 1px 4px;
margin: 4px;
border-radius: 4px;
overflow: hidden;
line-break: anywhere;
}
.cancel {
flex: 0 0 content;
display: flex;
justify-content: center;
align-items: center; /* Stop stretching the button */
}
.error {
background: var(--red_tint);
color: var(--red_color);
}
</style>

View File

@@ -14,18 +14,15 @@ import Torrent from "./Torrent.svelte";
import Zip from "./Zip.svelte"; import Zip from "./Zip.svelte";
import CustomBanner from "./CustomBanner.svelte"; import CustomBanner from "./CustomBanner.svelte";
import type { FSNavigator } from "filesystem/FSNavigator"; import type { FSNavigator } from "filesystem/FSNavigator";
import FsUploadWidget from "filesystem/upload_widget/FSUploadWidget.svelte";
import EditWindow from "filesystem/edit_window/EditWindow.svelte"; import EditWindow from "filesystem/edit_window/EditWindow.svelte";
import DetailsWindow from "filesystem/DetailsWindow.svelte"; import DetailsWindow from "filesystem/DetailsWindow.svelte";
let { let {
nav, nav,
upload_widget,
edit_window, edit_window,
details_window, details_window,
}: { }: {
nav: FSNavigator nav: FSNavigator
upload_widget: FsUploadWidget
edit_window: EditWindow edit_window: EditWindow
details_window: DetailsWindow details_window: DetailsWindow
} = $props(); } = $props();
@@ -89,7 +86,7 @@ export const seek = (delta: number) => {
{:else if viewer_type === "dir" && $nav.base.name === ".Trash"} {:else if viewer_type === "dir" && $nav.base.name === ".Trash"}
<TrashBin nav={nav} root_path={$nav.path[$nav.path.length - 2].path}/> <TrashBin nav={nav} root_path={$nav.path[$nav.path.length - 2].path}/>
{:else if viewer_type === "dir"} {:else if viewer_type === "dir"}
<FileManager nav={nav} upload_widget={upload_widget} edit_window={edit_window}> <FileManager nav={nav} edit_window={edit_window}>
<CustomBanner path={$nav.path}/> <CustomBanner path={$nav.path}/>
</FileManager> </FileManager>
{:else if viewer_type === "audio"} {:else if viewer_type === "audio"}

View File

@@ -1,12 +1,9 @@
<script> <script>
import Footer from "layout/Footer.svelte"; import Footer from "layout/Footer.svelte";
import { drop_target } from "lib/DropTarget";
import GetStarted from "./GetStarted.svelte"; import GetStarted from "./GetStarted.svelte";
import Pricing from "./Pricing.svelte"; import Pricing from "./Pricing.svelte";
import SubscriptionPlans from "layout/checkout/SubscriptionPlans.svelte"; import SubscriptionPlans from "layout/checkout/SubscriptionPlans.svelte";
import NovaLogo from "util/NovaLogo.svelte"; import NovaLogo from "util/NovaLogo.svelte";
let upload_widget
</script> </script>
<header class="logo_header"> <header class="logo_header">

View File

@@ -1,7 +1,11 @@
import type { Upload } from "filesystem/upload_widget/FSUploadWidget.svelte"
export const drop_target = ( export const drop_target = (
node: HTMLElement, node: HTMLElement,
args: { args: {
upload: (files: File[]) => void, // Called as soon as the files are dropped, the promise resolves when
// all the dropped directories have been read
upload: (files: Promise<Upload[]>) => void,
shadow: string | undefined, shadow: string | undefined,
}, },
) => { ) => {
@@ -39,7 +43,7 @@ export const drop_target = (
const dragleave = (e: DragEvent) => { const dragleave = (e: DragEvent) => {
node.style.boxShadow = "" node.style.boxShadow = ""
} }
const drop = async (e: DragEvent) => { const drop = (e: DragEvent) => {
node.style.boxShadow = "" node.style.boxShadow = ""
if (can_upload(e)) { if (can_upload(e)) {
@@ -49,39 +53,59 @@ export const drop_target = (
return return
} }
// if directory support is available // The items are only valid until this function returns, so every entry
if (e.dataTransfer && e.dataTransfer.items && e.dataTransfer.items.length > 0) { // has to be taken out of them before the first await
for (let i = 0; i < e.dataTransfer.items.length; i++) { const files: Upload[] = []
const entry: FileSystemEntry | null = e.dataTransfer.items[i].webkitGetAsEntry(); const reads: Promise<void>[] = []
if (entry !== null) { for (let i = 0; i < e.dataTransfer.items.length; i++) {
read_dir_recursive(entry); const entry = e.dataTransfer.items[i].webkitGetAsEntry()
continue if (entry !== null) {
} reads.push(read_entry(entry, files))
const file: File | null = e.dataTransfer.items[i].getAsFile(); continue
if (file !== null) {
args.upload([file]);
continue
}
} }
} else if (e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const files: File[] = [] // Files which are not on the disk have no entry, like an image
for (let i = 0; i < e.dataTransfer.files.length; i++) { // which is dragged out of another web page
files.push(e.dataTransfer.files[i]) const file = e.dataTransfer.items[i].getAsFile()
if (file !== null) {
files.push({ file, path: file.name })
} }
args.upload(files)
} }
args.upload(Promise.all(reads).then(() => files))
} }
const read_dir_recursive = (item: FileSystemEntry) => {
if (item.isDirectory) { // Adds a file, or all the files in a directory, to the list. The path comes
(item as FileSystemDirectoryEntry).createReader().readEntries(entries => { // from the entry. Firefox also puts it in the file's webkitRelativePath,
entries.forEach(entry => { // but Chromium leaves that empty for dropped files
read_dir_recursive(entry); const read_entry = async (entry: FileSystemEntry, files: Upload[]) => {
}); if (entry.isFile) {
}); files.push({
} else { path: entry.fullPath.slice(1),
(item as FileSystemFileEntry).file(file => { file: await new Promise<File>((resolve, reject) =>
args.upload([file]) (entry as FileSystemFileEntry).file(resolve, reject)
}); ),
})
return
}
// A reader returns the directory in parts, Chromium gives 100 entries
// at a time. An empty part marks the end
const reader = (entry as FileSystemDirectoryEntry).createReader()
while (true) {
const entries = await new Promise<FileSystemEntry[]>(
(resolve, reject) => reader.readEntries(resolve, reject)
)
if (entries.length === 0) {
return
}
// One at a time on purpose. Chromium asks another process for
// each file, and handles all the answers which have arrived in
// one go. Asking for a whole tree at once is a third faster, but
// freezes the page for seconds: 7 for 100k files
for (const e of entries) {
await read_entry(e, files)
}
} }
} }

View File

@@ -17,6 +17,7 @@ import { breadcrumbs_store, toolbar_store } from "./HeaderStore";
import ProgressBar from "util/ProgressBar.svelte"; import ProgressBar from "util/ProgressBar.svelte";
import NovaLogo, { update_favicon } from "util/NovaLogo.svelte"; import NovaLogo, { update_favicon } from "util/NovaLogo.svelte";
import SearchBar from "filesystem/SearchBar.svelte"; import SearchBar from "filesystem/SearchBar.svelte";
import FSUploadWidget from "filesystem/upload_widget/FSUploadWidget.svelte";
// The menu swipe will be detected if it was less than this much pixels from the // The menu swipe will be detected if it was less than this much pixels from the
// screen edge // screen edge
@@ -320,6 +321,7 @@ const set_offset = (off: number) => {
</div> </div>
<SearchBar/> <SearchBar/>
<FSUploadWidget/>
<style> <style>
.wrap { .wrap {