feat: filesystem and Files app support for >64 file entries, 64-bit sizes, async ops, GUI toolkit improvements to devexplorer app
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
/*
|
||||
* 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 <gui/mtk/hosts.hpp>
|
||||
#include <montauk/thread.h>
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user