/* * fileop.cpp * Background copy/move/delete worker + progress dialog for the embedded * desktop file manager. Long file operations run on a worker thread so the * desktop UI never freezes; a modal progress dialog shows the current item, * a determinate bar, and a Cancel button. * Copyright (c) 2026 Daniel Hammer */ #include "filemanager_internal.hpp" #include #include namespace filemanager { // Inline (synchronous, no dialog) threshold for a single plain file paste. // Anything larger -- or any directory / multi-item operation -- runs async. static constexpr uint64_t FILEOP_INLINE_MAX_BYTES = 16ull * 1024 * 1024; struct FileOpJob { int kind; // FileOpKind: copy / move / delete char (*paths)[256]; // owned: source paths (copy/move) or targets (delete) int count; char dest_dir[256]; // destination directory (copy/move); empty for delete DesktopState* desktop; FileManagerState* owner; // for the post-op refresh (liveness-checked) Color accent; volatile int files_total; volatile int files_done; volatile int cancel; // UI -> worker volatile int finished; // worker -> UI volatile char current[128]; // basename of the item in progress (worker writes) int tid; int last_drawn_done; // dialog redraw tracking int mouse_x, mouse_y; }; // ============================================================================ // Helpers // ============================================================================ static bool fileop_path_same_or_descendant(const char* path, const char* root) { if (!path || !root) return false; int root_len = montauk::slen(root); while (root_len > 0 && root[root_len - 1] == '/') root_len--; if (root_len <= 0) return false; for (int i = 0; i < root_len; i++) { if (path[i] == '\0' || path[i] != root[i]) return false; } return path[root_len] == '\0' || path[root_len] == '/'; } static void fileop_set_current(FileOpJob* job, const char* name) { if (!job || !name) return; int i = 0; for (; name[i] && i < 127; i++) job->current[i] = name[i]; job->current[i] = '\0'; } static bool fileop_path_is_dir(const char* path) { const char* probe[1]; return montauk::readdir(path, probe, 1) >= 0; } // ============================================================================ // Worker thread // ============================================================================ static int fileop_worker(void* arg) { FileOpJob* job = (FileOpJob*)arg; for (int i = 0; i < job->count && !job->cancel; i++) { const char* src = job->paths[i]; const char* base = path_basename(src); fileop_set_current(job, base); if (job->kind == FILE_OP_DELETE) { filemanager_delete_recursive(src); job->files_done = i + 1; continue; } // Copy or move into dest_dir. char dst[512]; filemanager_build_fullpath(dst, 512, job->dest_dir, base); if (montauk::streq(dst, src)) { job->files_done = i + 1; continue; } bool is_dir = fileop_path_is_dir(src); if (is_dir && fileop_path_same_or_descendant(dst, src)) { job->files_done = i + 1; continue; // refuse to copy a directory into itself } if (job->kind == FILE_OP_MOVE) { // Try a rename first: an instant same-volume move with no copying. if (montauk::frename(src, dst) == 0) { job->files_done = i + 1; continue; } // Cross-volume (or driver without rename): fall back to copy + delete. bool ok = is_dir ? filemanager_copy_dir_recursive(src, dst) : filemanager_copy_file(src, dst); if (ok) { if (is_dir) filemanager_delete_recursive(src); else montauk::fdelete(src); } } else { // FILE_OP_COPY if (is_dir) filemanager_copy_dir_recursive(src, dst); else filemanager_copy_file(src, dst); } job->files_done = i + 1; } job->finished = 1; return 0; } // ============================================================================ // Progress dialog // ============================================================================ static const char* fileop_verb(int kind) { switch (kind) { case FILE_OP_COPY: return "Copying"; case FILE_OP_MOVE: return "Moving"; case FILE_OP_DELETE: return "Deleting"; default: return "Working"; } } static void fileop_cancel_button_rect(int cw, int ch, Rect* out) { int btn_w = 88, btn_h = 28; *out = { cw - btn_w - 16, ch - btn_h - 14, btn_w, btn_h }; } static FileManagerState* fileop_live_owner(FileOpJob* job) { if (!job || !job->desktop || !job->owner) return nullptr; for (int i = 0; i < job->desktop->window_count; i++) { Window& win = job->desktop->windows[i]; if (win.app_data == job->owner && win.on_draw == filemanager_on_draw) return job->owner; } return nullptr; } static void fileop_close_self(DesktopState* ds, FileOpJob* job) { if (!ds) return; for (int i = 0; i < ds->window_count; i++) { if (ds->windows[i].app_data == job) { desktop_close_window(ds, i); return; } } } static void fileop_dialog_on_draw(Window* win, Framebuffer& fb) { (void)fb; FileOpJob* job = (FileOpJob*)win->app_data; if (!job) return; Canvas c(win); mtk::Theme theme = mtk::make_theme(job->accent); c.fill(theme.window_bg); int pad = 16; int fh = system_font_height(); int y = 18; // Title: "Copying 3 of 12" char title[96]; int done = job->files_done; int total = job->files_total; if (total > 1) snprintf(title, sizeof(title), "%s %d of %d", fileop_verb(job->kind), done < total ? done + 1 : total, total); else snprintf(title, sizeof(title), "%s...", fileop_verb(job->kind)); c.text(pad, y, title, theme.text); y += fh + 10; // Current item name (copied out of the volatile buffer). char cur[128]; int k = 0; for (; k < 127 && job->current[k]; k++) cur[k] = job->current[k]; cur[k] = '\0'; if (cur[0]) { char fit[128]; // Trim to width using the shared helper if available; otherwise show raw. montauk::strncpy(fit, cur, sizeof(fit) - 1); fit[sizeof(fit) - 1] = '\0'; c.text(pad, y, fit, theme.text_muted); } y += fh + 12; // Progress bar. int bar_x = pad; int bar_w = c.w - pad * 2; int bar_h = 10; c.fill_rounded_rect(bar_x, y, bar_w, bar_h, 5, Color::from_rgb(0xE0, 0xE0, 0xE0)); int frac_w = 0; if (total > 0) { if (done > total) done = total; frac_w = (int)(((int64_t)bar_w * done) / total); } if (frac_w > 0) c.fill_rounded_rect(bar_x, y, frac_w, bar_h, 5, job->accent); // Cancel button. Rect cancel_btn; fileop_cancel_button_rect(c.w, c.h, &cancel_btn); bool hover = cancel_btn.contains(job->mouse_x, job->mouse_y); mtk::draw_button(c, cancel_btn, job->finished ? "Close" : "Cancel", mtk::BUTTON_SECONDARY, mtk::widget_state(false, hover, true), theme); } static void fileop_dialog_on_mouse(Window* win, MouseEvent& ev) { FileOpJob* job = (FileOpJob*)win->app_data; if (!job) return; mtk::DesktopHost host(win); int mx = 0, my = 0; if (!host.map_mouse(ev, &mx, &my)) return; Rect cancel_btn; fileop_cancel_button_rect(win->content_w, win->content_h, &cancel_btn); bool old_hover = cancel_btn.contains(job->mouse_x, job->mouse_y); bool new_hover = cancel_btn.contains(mx, my); job->mouse_x = mx; job->mouse_y = my; if (old_hover != new_hover) host.invalidate(); if (ev.left_pressed() && new_hover) { // Request cancel; the dialog closes once the worker observes it. job->cancel = 1; if (job->finished) fileop_close_self(job->desktop, job); } } static void fileop_dialog_on_key(Window* win, const Montauk::KeyEvent& key) { FileOpJob* job = (FileOpJob*)win->app_data; if (!job || !key.pressed) return; if (key.scancode == 0x01) job->cancel = 1; // Escape requests cancel } static void fileop_dialog_on_poll(Window* win) { FileOpJob* job = (FileOpJob*)win->app_data; if (!job) return; if (job->files_done != job->last_drawn_done) { job->last_drawn_done = job->files_done; win->dirty = true; } if (job->finished) { // Hand off to on_close, which joins the worker, refreshes the file // manager, and frees the job exactly once. fileop_close_self(job->desktop, job); } } static void fileop_dialog_on_close(Window* win) { FileOpJob* job = (FileOpJob*)win->app_data; if (!job) return; // Make sure the worker has stopped before we free anything it touches. if (job->tid > 0) { if (!job->finished) job->cancel = 1; montauk::thread_join(job->tid, nullptr); job->tid = 0; } FileManagerState* owner = fileop_live_owner(job); if (owner) { if (owner->active_fileop == job) owner->active_fileop = nullptr; filemanager_clear_multi_selection(owner); filemanager_read_dir(owner); } if (job->paths) montauk::mfree(job->paths); montauk::mfree(job); win->app_data = nullptr; } static bool fileop_show_dialog(FileOpJob* job) { DesktopState* ds = job->desktop; int w = 380, h = 150; int wx = 0, wy = 0; props_center_dialog(ds, job->owner, w, h, &wx, &wy); const char* dlg_title = (job->kind == FILE_OP_DELETE) ? "Deleting" : (job->kind == FILE_OP_MOVE) ? "Moving" : "Copying"; int idx = desktop_create_window(ds, dlg_title, wx, wy, w, h); if (idx < 0) return false; Window* win = &ds->windows[idx]; win->app_data = job; win->on_draw = fileop_dialog_on_draw; win->on_mouse = fileop_dialog_on_mouse; win->on_key = fileop_dialog_on_key; win->on_close = fileop_dialog_on_close; win->on_poll = fileop_dialog_on_poll; return true; } // Spawn the worker + dialog. Takes ownership of `job` on success. On failure // (no thread / no window) runs the work synchronously and frees the job. static bool fileop_launch(FileOpJob* job) { if (job->owner) job->owner->active_fileop = job; // Generous stack: copy/delete recurse with ~2.5 KiB of buffers per directory // level, so this comfortably handles deeply nested trees. int tid = montauk::thread_spawn(fileop_worker, job, 256 * 1024); if (tid <= 0) { // Degraded path: do it inline (UI blocks), then refresh. fileop_worker(job); FileManagerState* owner = fileop_live_owner(job); if (owner) { owner->active_fileop = nullptr; filemanager_clear_multi_selection(owner); filemanager_read_dir(owner); } if (job->paths) montauk::mfree(job->paths); montauk::mfree(job); return true; } job->tid = tid; if (!fileop_show_dialog(job)) { // Window creation failed: let the worker finish, then reap + refresh // without a dialog. montauk::thread_join(job->tid, nullptr); FileManagerState* owner = fileop_live_owner(job); if (owner) { owner->active_fileop = nullptr; filemanager_clear_multi_selection(owner); filemanager_read_dir(owner); } if (job->paths) montauk::mfree(job->paths); montauk::mfree(job); return true; } return true; } static FileOpJob* fileop_alloc_job(int kind, int count) { if (count <= 0) return nullptr; FileOpJob* job = (FileOpJob*)montauk::malloc(sizeof(FileOpJob)); if (!job) return nullptr; montauk::memset(job, 0, sizeof(FileOpJob)); job->paths = (char(*)[256])montauk::malloc((uint64_t)count * 256); if (!job->paths) { montauk::mfree(job); return nullptr; } job->kind = kind; job->count = count; job->files_total = count; return job; } // ============================================================================ // Public entry points // ============================================================================ bool filemanager_start_paste_job(FileManagerState* fm) { if (!fm || fm->active_fileop) return false; if (fm->clipboard_count <= 0) return false; if (filemanager_is_computer_view(fm) || filemanager_is_virtual_view(fm)) return false; bool is_cut = fm->clipboard_is_cut; // Fast path: a single small plain file pastes inline so quick copies stay // snappy (no thread, no dialog flash). if (fm->clipboard_count == 1 && !fileop_path_is_dir(fm->clipboard_paths[0])) { const char* src = fm->clipboard_paths[0]; uint64_t sz = 0; int fd = montauk::open(src); if (fd >= 0) { sz = montauk::getsize(fd); montauk::close(fd); } if (sz <= FILEOP_INLINE_MAX_BYTES) { char dst[512]; filemanager_build_fullpath(dst, 512, fm->current_path, path_basename(src)); if (!montauk::streq(dst, src)) { if (is_cut) { if (montauk::frename(src, dst) != 0) { if (filemanager_copy_file(src, dst)) montauk::fdelete(src); } } else { filemanager_copy_file(src, dst); } } if (is_cut) fm->clipboard_count = 0; filemanager_read_dir(fm); return true; } } FileOpJob* job = fileop_alloc_job(is_cut ? FILE_OP_MOVE : FILE_OP_COPY, fm->clipboard_count); if (!job) return false; for (int i = 0; i < fm->clipboard_count; i++) { montauk::strncpy(job->paths[i], fm->clipboard_paths[i], 255); job->paths[i][255] = '\0'; } montauk::strncpy(job->dest_dir, fm->current_path, sizeof(job->dest_dir) - 1); job->dest_dir[sizeof(job->dest_dir) - 1] = '\0'; job->desktop = fm->desktop; job->owner = fm; job->accent = fm->desktop ? fm->desktop->settings.accent_color : Color{}; // A cut is consumed when the paste starts (paths are already copied in). if (is_cut) fm->clipboard_count = 0; return fileop_launch(job); } bool filemanager_start_delete_job(FileManagerState* fm, const char* const* paths, int count) { if (!fm || fm->active_fileop) return false; if (!paths || count <= 0) return false; FileOpJob* job = fileop_alloc_job(FILE_OP_DELETE, count); if (!job) return false; for (int i = 0; i < count; i++) { montauk::strncpy(job->paths[i], paths[i] ? paths[i] : "", 255); job->paths[i][255] = '\0'; } job->dest_dir[0] = '\0'; job->desktop = fm->desktop; job->owner = fm; job->accent = fm->desktop ? fm->desktop->settings.accent_color : Color{}; return fileop_launch(job); } } // namespace filemanager