Add download progress bar

This commit is contained in:
2026-07-18 20:08:02 -04:00
parent be17ff79f8
commit 91c595bb42
4 changed files with 119 additions and 17 deletions
+7 -10
View File
@@ -228,7 +228,6 @@ fn delete_entry(app: AppHandle, store: State<'_, Store>, path: String) -> Result
} }
} }
/// Moves an entry into another folder, keeping its name.
#[tauri::command] #[tauri::command]
fn move_entry( fn move_entry(
app: AppHandle, app: AppHandle,
@@ -308,7 +307,6 @@ fn duplicate_entry(
Ok(candidate) Ok(candidate)
} }
/// Absolute path on disk, used to reveal an entry in the system file manager.
#[tauri::command] #[tauri::command]
fn absolute_path( fn absolute_path(
app: AppHandle, app: AppHandle,
@@ -761,8 +759,6 @@ fn cloud_list_files(
sync::list_account_files(&server_url, &token, folder_id.as_deref()) sync::list_account_files(&server_url, &token, folder_id.as_deref())
} }
/// Downloads an account file into the shared asset library, where every
/// project can reference it by name.
#[tauri::command] #[tauri::command]
fn cloud_download_file( fn cloud_download_file(
app: AppHandle, app: AppHandle,
@@ -770,7 +766,10 @@ fn cloud_download_file(
file_id: String, file_id: String,
) -> Result<String, String> { ) -> Result<String, String> {
let (server_url, token) = cloud_credentials(&app, &store)?; let (server_url, token) = cloud_credentials(&app, &store)?;
sync::report_progress(&app, "file", 0, 1, false);
let file = sync::pull_account_file(&server_url, &token, &file_id)?; let file = sync::pull_account_file(&server_url, &token, &file_id)?;
sync::report_progress(&app, &file.name, 1, 1, true);
let bytes = BASE64 let bytes = BASE64
.decode(file.content.as_bytes()) .decode(file.content.as_bytes())
@@ -791,8 +790,6 @@ fn cloud_list_shared(
sync::list_shared(&server_url, &token) sync::list_shared(&server_url, &token)
} }
/// Downloads a cloud document into the workspace and remembers where it came
/// from so it can be synced back.
#[tauri::command] #[tauri::command]
fn cloud_download_document( fn cloud_download_document(
app: AppHandle, app: AppHandle,
@@ -801,7 +798,10 @@ fn cloud_download_document(
parent: String, parent: String,
) -> Result<String, String> { ) -> Result<String, String> {
let (server_url, token) = cloud_credentials(&app, &store)?; let (server_url, token) = cloud_credentials(&app, &store)?;
sync::report_progress(&app, "document", 0, 1, false);
let document = sync::pull_document(&server_url, &token, &document_id)?; let document = sync::pull_document(&server_url, &token, &document_id)?;
sync::report_progress(&app, &document.title, 1, 1, true);
let mut name = document.title.replace('/', "-").trim().to_string(); let mut name = document.title.replace('/', "-").trim().to_string();
if name.is_empty() { if name.is_empty() {
@@ -872,8 +872,6 @@ pub struct LinkedDocument {
pub sync_state: Option<String>, pub sync_state: Option<String>,
} }
/// Every cloud document that has been downloaded, so the cloud view can show
/// which ones live on this device and whether they are up to date.
#[tauri::command] #[tauri::command]
fn cloud_linked_documents( fn cloud_linked_documents(
app: AppHandle, app: AppHandle,
@@ -908,7 +906,6 @@ pub struct LinkedSpace {
pub sync_state: Option<String>, pub sync_state: Option<String>,
} }
/// Cloud spaces that have been downloaded, wherever they sit in the workspace.
#[tauri::command] #[tauri::command]
fn cloud_linked_spaces( fn cloud_linked_spaces(
app: AppHandle, app: AppHandle,
@@ -968,7 +965,7 @@ fn cloud_clone_space(
if dir.exists() { if dir.exists() {
return Err(format!("A project named '{}' already exists", project_name)); return Err(format!("A project named '{}' already exists", project_name));
} }
sync::clone_space(&server_url, &token, &store, &project, &dir, &space_id) sync::clone_space(&server_url, &token, &app, &store, &project, &dir, &space_id)
} }
#[tauri::command] #[tauri::command]
+36 -4
View File
@@ -493,9 +493,39 @@ pub fn push_project(
Ok(report) Ok(report)
} }
#[derive(Serialize, Clone)]
pub struct DownloadProgress {
pub label: String,
pub current: usize,
pub total: usize,
pub done: bool,
}
pub const PROGRESS_EVENT: &str = "download://progress";
pub fn report_progress(
app: &tauri::AppHandle,
label: &str,
current: usize,
total: usize,
done: bool,
) {
use tauri::Emitter;
let _ = app.emit(
PROGRESS_EVENT,
DownloadProgress {
label: label.to_string(),
current,
total,
done,
},
);
}
pub fn clone_space( pub fn clone_space(
server_url: &str, server_url: &str,
token: &str, token: &str,
app: &tauri::AppHandle,
store: &Store, store: &Store,
project: &str, project: &str,
project_dir: &Path, project_dir: &Path,
@@ -509,14 +539,19 @@ pub fn clone_space(
meta.entrypoint = manifest.entrypoint.clone(); meta.entrypoint = manifest.entrypoint.clone();
let mut report = SyncReport::default(); let mut report = SyncReport::default();
let total = manifest.files.len();
for (index, entry) in manifest.files.iter().enumerate() {
report_progress(app, project, index, total, false);
for entry in &manifest.files {
let remote = pull_file(server_url, token, space_id, &entry.path)?; let remote = pull_file(server_url, token, space_id, &entry.path)?;
write_local(project_dir, &entry.path, &remote.bytes()?)?; write_local(project_dir, &entry.path, &remote.bytes()?)?;
meta.base_hashes.insert(entry.path.clone(), remote.hash); meta.base_hashes.insert(entry.path.clone(), remote.hash);
report.pulled.push(entry.path.clone()); report.pulled.push(entry.path.clone());
} }
report_progress(app, project, total, total, true);
meta.last_synced_at = Some(chrono::Utc::now().to_rfc3339()); meta.last_synced_at = Some(chrono::Utc::now().to_rfc3339());
store.save_meta(project, &meta)?; store.save_meta(project, &meta)?;
save_base_snapshots(store, project, project_dir, &meta)?; save_base_snapshots(store, project, project_dir, &meta)?;
@@ -652,8 +687,6 @@ pub fn pull_document(
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
} }
/// Syncs one downloaded cloud document. The merge base is the copy stored when
/// the document was last exchanged with the server.
pub fn sync_document( pub fn sync_document(
server_url: &str, server_url: &str,
token: &str, token: &str,
@@ -788,7 +821,6 @@ pub fn sync_document(
Ok(report) Ok(report)
} }
/// Applies a resolved cloud document and uploads it.
pub fn resolve_document_conflict( pub fn resolve_document_conflict(
server_url: &str, server_url: &str,
token: &str, token: &str,
+27 -1
View File
@@ -48,6 +48,7 @@ interface AppState {
compiling: boolean; compiling: boolean;
lspStatus: LspStatus; lspStatus: LspStatus;
download: DownloadProgress | null;
syncing: boolean; syncing: boolean;
conflicts: Conflict[]; conflicts: Conflict[];
status: string; status: string;
@@ -83,6 +84,7 @@ export const app = $state<AppState>({
compiling: false, compiling: false,
lspStatus: "off", lspStatus: "off",
download: null,
syncing: false, syncing: false,
conflicts: [], conflicts: [],
status: "", status: "",
@@ -90,6 +92,31 @@ export const app = $state<AppState>({
theme: "light", theme: "light",
}); });
export interface DownloadProgress {
label: string;
current: number;
total: number;
done: boolean;
}
let downloadClearTimer: ReturnType<typeof setTimeout> | null = null;
export function trackDownload(progress: DownloadProgress) {
if (downloadClearTimer) {
clearTimeout(downloadClearTimer);
downloadClearTimer = null;
}
app.download = progress;
if (progress.done) {
downloadClearTimer = setTimeout(() => {
app.download = null;
downloadClearTimer = null;
}, 1200);
}
}
export function setError(error: unknown) { export function setError(error: unknown) {
app.error = api.errorMessage(error); app.error = api.errorMessage(error);
app.status = ""; app.status = "";
@@ -205,7 +232,6 @@ export async function refreshCloud() {
} }
} }
/** Trail from the drive root down to the folder being viewed. */
export function cloudBreadcrumbs(): CloudFolder[] { export function cloudBreadcrumbs(): CloudFolder[] {
if (app.cloudFolder === "shared" || app.cloudFolder === null) return []; if (app.cloudFolder === "shared" || app.cloudFolder === null) return [];
+49 -2
View File
@@ -4,6 +4,7 @@
import { save } from "@tauri-apps/plugin-dialog"; import { save } from "@tauri-apps/plugin-dialog";
import { revealItemInDir } from "@tauri-apps/plugin-opener"; import { revealItemInDir } from "@tauri-apps/plugin-opener";
import { getCurrentWebview } from "@tauri-apps/api/webview"; import { getCurrentWebview } from "@tauri-apps/api/webview";
import { listen } from "@tauri-apps/api/event";
import FileViewer from "$lib/components/FileViewer.svelte"; import FileViewer from "$lib/components/FileViewer.svelte";
import FileTree from "$lib/components/FileTree.svelte"; import FileTree from "$lib/components/FileTree.svelte";
@@ -47,7 +48,9 @@
scheduleCompile, scheduleCompile,
setError, setError,
setStatus, setStatus,
trackDownload,
} from "$lib/ts/state.svelte"; } from "$lib/ts/state.svelte";
import type { DownloadProgress } from "$lib/ts/state.svelte";
type Dialog = type Dialog =
| { kind: "none" } | { kind: "none" }
@@ -77,7 +80,6 @@
let selectedIsDir = $state(false); let selectedIsDir = $state(false);
let treeDropTarget = $state<string | null>(null); let treeDropTarget = $state<string | null>(null);
/** Folder that new files, folders, and imports go into. */
const selectedFolder = $derived( const selectedFolder = $derived(
!selectedEntry !selectedEntry
? "" ? ""
@@ -337,7 +339,6 @@
return null; return null;
} }
/** Folder row under the pointer, so an OS drop lands where it is aimed. */
function folderUnderPointer(x: number, y: number): string | null { function folderUnderPointer(x: number, y: number): string | null {
const element = document const element = document
.elementFromPoint(x, y) .elementFromPoint(x, y)
@@ -373,6 +374,10 @@
} }
onMount(() => { onMount(() => {
const downloads = listen<DownloadProgress>("download://progress", (event) =>
trackDownload(event.payload),
);
const pending = getCurrentWebview().onDragDropEvent((event) => { const pending = getCurrentWebview().onDragDropEvent((event) => {
if (event.payload.type === "over") { if (event.payload.type === "over") {
dropActive = dropDestination() !== null; dropActive = dropDestination() !== null;
@@ -396,6 +401,7 @@
return () => { return () => {
pending.then((unlisten) => unlisten()); pending.then((unlisten) => unlisten());
downloads.then((unlisten) => unlisten());
}; };
}); });
@@ -517,6 +523,47 @@
<WindowControls /> <WindowControls />
</header> </header>
{#if app.download}
{@const progress = app.download}
<div
class="flex shrink-0 items-center gap-3 border-b border-[var(--color-line)] bg-[var(--color-surface)] px-3 py-2"
>
<Icon
icon={progress.done ? "ph:check-circle" : "ph:cloud-arrow-down"}
class="shrink-0 text-base {progress.done
? 'text-[var(--color-success)]'
: 'text-[var(--color-accent)]'}"
/>
<span class="shrink-0 text-xs">
{progress.done ? "Downloaded" : "Downloading"}
<span class="font-medium">{progress.label}</span>
</span>
<div
class="h-1.5 min-w-0 flex-1 overflow-hidden rounded-full bg-[var(--color-surface-sunken)]"
>
<div
class="h-full rounded-full transition-all duration-200
{progress.done
? 'bg-[var(--color-success)]'
: 'bg-[var(--color-accent)]'}"
style="width: {progress.total > 0
? Math.round((progress.current / progress.total) * 100)
: 0}%"
></div>
</div>
{#if progress.total > 1}
<span
class="shrink-0 tabular-nums text-[10px] text-[var(--color-ink-muted)]"
>
{progress.current} of {progress.total} files
</span>
{/if}
</div>
{/if}
{#if app.status || app.error} {#if app.status || app.error}
<div <div
class="flex shrink-0 items-center gap-2 border-b px-3 py-1.5 text-xs class="flex shrink-0 items-center gap-2 border-b px-3 py-1.5 text-xs