diff --git a/kernel/src/Api/BuildNo.hpp b/kernel/src/Api/BuildNo.hpp index 3ca90c7..4adff58 100644 --- a/kernel/src/Api/BuildNo.hpp +++ b/kernel/src/Api/BuildNo.hpp @@ -12,4 +12,4 @@ #pragma once -#define MONTAUK_BUILD_NUMBER 5 +#define MONTAUK_BUILD_NUMBER 7 diff --git a/programs/include/gui/desktop.hpp b/programs/include/gui/desktop.hpp index 59c7168..feaf1a1 100644 --- a/programs/include/gui/desktop.hpp +++ b/programs/include/gui/desktop.hpp @@ -157,6 +157,10 @@ struct DesktopState { SvgIcon icon_folder_new; SvgIcon icon_home_folder; SvgIcon icon_home_folder_lg; + SvgIcon icon_trash; + SvgIcon icon_trash_lg; + SvgIcon icon_trash_full; + SvgIcon icon_trash_full_lg; SvgIcon icon_apps; SvgIcon icon_apps_lg; SvgIcon icon_system_configuration_menu; diff --git a/programs/include/gui/svg.hpp b/programs/include/gui/svg.hpp index 5e43dc1..d3b2e87 100644 --- a/programs/include/gui/svg.hpp +++ b/programs/include/gui/svg.hpp @@ -1241,6 +1241,37 @@ inline void svg_parse_css_table(const char* style_data, int style_len, SvgCssTab } } +// Parse a functional color "rgb(r, g, b)": each channel is 0-255 or a +// percentage, decimals allowed (e.g. "rgb(90.196%,90.196%,90.196%)"). +// Returns false if s is not a well-formed rgb() value. +inline bool svg_parse_rgb_color(const char* s, Color* out) { + if (!svg_strncmp(s, "rgb(", 4)) return false; + s += 4; + int channel[3]; + for (int i = 0; i < 3; i++) { + while (svg_char_is_ws(*s)) ++s; + fixed_t value = 0; + int consumed = svg_parse_fixed(s, &value); + if (consumed <= 0) return false; + s += consumed; + while (svg_char_is_ws(*s)) ++s; + int64_t scaled = value; + if (*s == '%') { + scaled = scaled * 255 / 100; + ++s; + } + int v = (int)((scaled + (1 << 15)) >> 16); + channel[i] = v < 0 ? 0 : v > 255 ? 255 : v; + while (svg_char_is_ws(*s)) ++s; + if (i < 2) { + if (*s != ',') return false; + ++s; + } + } + *out = Color::from_rgb((uint8_t)channel[0], (uint8_t)channel[1], (uint8_t)channel[2]); + return true; +} + // --------------------------------------------------------------------------- // Resolve a fill value that might be url(#id) against gradient table // Returns: 1 = resolved, 0 = not a url() ref, -1 = fill="none" @@ -1248,6 +1279,7 @@ inline void svg_parse_css_table(const char* style_data, int style_len, SvgCssTab inline int svg_resolve_fill_value(const char* val, Color* out_color, const SvgGradientTable* grads) { if (svg_strncmp(val, "none", 4)) return -1; if (*val == '#') { *out_color = svg_parse_hex_color(val); return 1; } + if (svg_parse_rgb_color(val, out_color)) return 1; if (grads && svg_strncmp(val, "url(#", 5)) { // Extract id from url(#id) const char* id_start = val + 5; diff --git a/programs/src/desktop/apps/filemanager/actions.cpp b/programs/src/desktop/apps/filemanager/actions.cpp index 29b0d6a..a398f43 100644 --- a/programs/src/desktop/apps/filemanager/actions.cpp +++ b/programs/src/desktop/apps/filemanager/actions.cpp @@ -38,10 +38,13 @@ void filemanager_do_copy(FileManagerState* fm) { } void filemanager_do_cut(FileManagerState* fm) { + // Moving out of the Trash would orphan its restore info; use Restore. + if (filemanager_in_trash(fm)) return; filemanager_load_clipboard(fm, true); } void filemanager_do_paste(FileManagerState* fm) { + if (filemanager_in_trash(fm)) return; // Copy/move runs on a worker thread with a progress dialog (a small single // file pastes inline). Same-volume moves use rename (instant, no copy). filemanager_start_paste_job(fm); @@ -53,7 +56,7 @@ void filemanager_start_rename(FileManagerState* fm) { // only the focus and renaming it would be a surprise. if (fm->multi_count > 1) return; if (filemanager_is_computer_view(fm) || filemanager_is_virtual_view(fm) || - fm->entry_types[fm->selected] == FM_ENTRY_DRIVE) return; + filemanager_in_trash(fm) || fm->entry_types[fm->selected] == FM_ENTRY_DRIVE) return; fm->rename_active = true; fm->rename_idx = fm->selected; @@ -106,6 +109,7 @@ void filemanager_cancel_rename(FileManagerState* fm) { void filemanager_new_folder(FileManagerState* fm) { if (filemanager_is_computer_view(fm) || filemanager_is_virtual_view(fm)) return; + if (filemanager_in_trash(fm)) return; // Find a unique name char name[64] = "New Folder"; @@ -140,77 +144,179 @@ void filemanager_new_folder(FileManagerState* fm) { } } -void filemanager_delete_selected(FileManagerState* fm) { - if (filemanager_is_computer_view(fm) || filemanager_is_virtual_view(fm)) return; - if (fm->entry_count <= 0) return; +// Selected entries other than drives, as indices. Caller frees *out. +static int filemanager_collect_deletable(FileManagerState* fm, int** out, bool* any_dir) { + *out = nullptr; + if (any_dir) *any_dir = false; + if (fm->entry_count <= 0) return 0; int* sel = (int*)montauk::malloc((uint64_t)fm->entry_count * sizeof(int)); - if (!sel) return; + if (!sel) return 0; int n = filemanager_collect_selection(fm, sel, fm->entry_count); - if (n <= 0) { montauk::mfree(sel); return; } - - // Filter out drives. - int* filtered = (int*)montauk::malloc((uint64_t)n * sizeof(int)); - if (!filtered) { montauk::mfree(sel); return; } int fn = 0; - bool any_dir = false; for (int i = 0; i < n; i++) { int idx = sel[i]; if (fm->entry_types[idx] == FM_ENTRY_DRIVE) continue; - filtered[fn++] = idx; - if (fm->is_dir[idx]) any_dir = true; + sel[fn++] = idx; + if (any_dir && fm->is_dir[idx]) *any_dir = true; } - montauk::mfree(sel); - if (fn == 0) { montauk::mfree(filtered); return; } + if (fn == 0) { montauk::mfree(sel); return 0; } + *out = sel; + return fn; +} - // Anything with a directory or multi-item selection: confirm first. - if (any_dir || fn > 1) { - if (fn == 1 && any_dir) { - filemanager_show_directory_delete_confirmation(fm, filtered[0]); - } else { - filemanager_show_bulk_delete_confirmation(fm, filtered, fn); +// Up to this many items are trashed inline (each is a rename plus a small +// info file); more go to the worker with a progress dialog. +static constexpr int FM_TRASH_INLINE_MAX = 32; + +static void filemanager_trash_selected(FileManagerState* fm, const int* indices, int count) { + char (*paths)[512] = (char(*)[512])montauk::malloc((uint64_t)count * 512); + if (!paths) return; + for (int i = 0; i < count; i++) + filemanager_build_fullpath(paths[i], 512, fm->current_path, fm->entry_names[indices[i]]); + + if (count > FM_TRASH_INLINE_MAX) { + const char** arr = (const char**)montauk::malloc((uint64_t)count * sizeof(const char*)); + bool started = false; + if (arr) { + for (int i = 0; i < count; i++) arr[i] = paths[i]; + started = filemanager_start_trash_job(fm, arr, count); + montauk::mfree(arr); } - montauk::mfree(filtered); - return; - } - - // Single plain file. Freeing a large file's blocks is a slow syscall - // (the fs driver rewrites allocation bitmaps per block), so deleting it - // inline would stall the desktop -- Files runs inside desktop.elf, so a - // blocked main thread freezes the compositor and cursor. Hand anything - // past the inline threshold to the background worker + progress dialog; - // small files still delete inline so there's no dialog flash. - int only = filtered[0]; - char fullpath[512]; - filemanager_build_fullpath(fullpath, 512, - fm->current_path, fm->entry_names[only]); - - if (fm->entry_sizes[only] > FILEMANAGER_DELETE_INLINE_MAX_BYTES) { - const char* one = fullpath; - if (filemanager_start_delete_job(fm, &one, 1)) { - montauk::mfree(filtered); + if (started || fm->active_fileop) { + // Started, or another operation owns the worker (its dialog says so). + montauk::mfree(paths); return; } - if (fm->active_fileop) { - // Another operation owns the worker. Deleting a big file inline - // here would stall the desktop (the exact bug this path avoids), - // so refuse; its progress dialog tells the user we're busy. - montauk::mfree(filtered); - return; - } - // Job allocation failed (OOM): fall through to the inline delete. } - montauk::mfree(filtered); - montauk::StatusCode status = filemanager_status_from_result(montauk::fdelete(fullpath)); + int failed = 0; + int first_failed = -1; + montauk::StatusCode first_error = montauk::Success; + for (int i = 0; i < count; i++) { + montauk::StatusCode status = filemanager_trash_item(fm->desktop, paths[i]); + if (status != montauk::Success && failed++ == 0) { + first_failed = i; + first_error = status; + } + } filemanager_clear_multi_selection(fm); filemanager_read_dir(fm); - if (status != montauk::Success) { + if (failed > 0) { char heading[192]; - snprintf(heading, sizeof(heading), "Couldn't delete \"%s\"", path_basename(fullpath)); - filemanager_show_error(fm->desktop, fm, heading, fullpath, status); + if (failed == 1) + snprintf(heading, sizeof(heading), "Couldn't move \"%s\" to the Trash", + path_basename(paths[first_failed])); + else + snprintf(heading, sizeof(heading), "Couldn't move %d of %d items to the Trash", + failed, count); + filemanager_show_error(fm->desktop, fm, heading, paths[first_failed], first_error); } + montauk::mfree(paths); +} + +void filemanager_delete_selected(FileManagerState* fm, bool permanent) { + if (filemanager_is_computer_view(fm) || filemanager_is_virtual_view(fm)) return; + + bool any_dir = false; + int* sel = nullptr; + int n = filemanager_collect_deletable(fm, &sel, &any_dir); + if (n <= 0) return; + + if (!permanent && filemanager_can_trash(fm)) { + filemanager_trash_selected(fm, sel, n); + } else if (n == 1 && any_dir) { + // Permanent deletes cannot be undone: always confirm. + filemanager_show_directory_delete_confirmation(fm, sel[0]); + } else { + filemanager_show_bulk_delete_confirmation(fm, sel, n); + } + montauk::mfree(sel); +} + +void filemanager_restore_selected(FileManagerState* fm) { + if (!filemanager_is_trash_view(fm)) return; + + int* sel = nullptr; + int n = filemanager_collect_deletable(fm, &sel, nullptr); + if (n <= 0) return; + + int failed = 0; + char first_failed[512] = ""; + montauk::StatusCode first_error = montauk::Success; + for (int i = 0; i < n; i++) { + char trashed[512]; + filemanager_build_fullpath(trashed, sizeof(trashed), fm->current_path, + fm->entry_names[sel[i]]); + montauk::StatusCode status = filemanager_trash_restore(trashed, nullptr, 0); + if (status != montauk::Success && failed++ == 0) { + montauk::strncpy(first_failed, trashed, sizeof(first_failed)); + first_error = status; + } + } + montauk::mfree(sel); + filemanager_clear_multi_selection(fm); + filemanager_read_dir(fm); + + if (failed > 0) { + // NoSuchPath here means the restore info is missing, not the item. + bool unknown_origin = first_error == montauk::NoSuchPath; + char heading[192]; + if (failed == 1 && unknown_origin) + snprintf(heading, sizeof(heading), "Couldn't restore \"%s\": its original location is unknown", + path_basename(first_failed)); + else if (failed == 1) + snprintf(heading, sizeof(heading), "Couldn't restore \"%s\"", path_basename(first_failed)); + else + snprintf(heading, sizeof(heading), "Couldn't restore %d of %d items", failed, n); + filemanager_show_error(fm->desktop, fm, heading, first_failed, + unknown_origin ? montauk::Success : first_error); + } +} + +void filemanager_show_empty_trash_confirmation(FileManagerState* fm) { + if (!fm || !fm->desktop) return; + char files[256]; + if (!filemanager_trash_files_dir(fm->desktop, files, sizeof(files))) return; + + // Snapshot the Trash's top-level items (paged; the Trash may be large). + int cap = 64, count = 0; + char (*paths)[512] = (char(*)[512])montauk::malloc((uint64_t)cap * 512); + if (!paths) return; + static constexpr int BATCH = 128; + const char* names[BATCH]; + for (;;) { + int got = montauk::readdir_at(files, names, BATCH, count); + if (got <= 0) break; + if (count + got > cap) { + int new_cap = cap; + while (new_cap < count + got) new_cap *= 2; + if (new_cap > FM_ENTRY_HARD_CAP) new_cap = FM_ENTRY_HARD_CAP; + auto* grown = (char(*)[512])montauk::realloc(paths, (uint64_t)new_cap * 512); + if (!grown) break; + paths = grown; + cap = new_cap; + } + for (int b = 0; b < got && count < cap; b++) { + char name[256]; + montauk::strncpy(name, path_basename(names[b]), sizeof(name)); + int len = montauk::slen(name); + if (len > 0 && name[len - 1] == '/') name[len - 1] = '\0'; + filemanager_build_fullpath(paths[count++], 512, files, name); + } + if (count >= FM_ENTRY_HARD_CAP) break; + } + + if (count > 0) { + const char** arr = (const char**)montauk::malloc((uint64_t)count * sizeof(const char*)); + if (arr) { + for (int i = 0; i < count; i++) arr[i] = paths[i]; + filemanager_show_paths_delete_confirmation(fm, "Empty Trash?", arr, count); + montauk::mfree(arr); + } + } + montauk::mfree(paths); } void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, int target_idx) { @@ -223,7 +329,20 @@ void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, i if (target_idx >= 0 && target_idx < fm->entry_count) { int tt = fm->entry_types[target_idx]; - if ((filemanager_is_computer_view(fm) + if (filemanager_is_computer_view(fm) && tt == FM_ENTRY_TRASH) { + fm->ctx_items[fm->ctx_item_count++] = CTX_OPEN; + if (filemanager_trash_has_items(fm->desktop)) + fm->ctx_items[fm->ctx_item_count++] = CTX_EMPTY_TRASH; + } else if (filemanager_in_trash(fm)) { + // Trash: restore (top level only), copy out, or delete for good. + if (filemanager_is_trash_view(fm)) + fm->ctx_items[fm->ctx_item_count++] = CTX_RESTORE; + else + fm->ctx_items[fm->ctx_item_count++] = CTX_OPEN; + fm->ctx_items[fm->ctx_item_count++] = CTX_COPY; + fm->ctx_items[fm->ctx_item_count++] = CTX_DELETE; + fm->ctx_items[fm->ctx_item_count++] = CTX_PROPERTIES; + } else if ((filemanager_is_computer_view(fm) && (tt == FM_ENTRY_DRIVE || tt == FM_ENTRY_HOME || tt == FM_ENTRY_APPS_ROOT || tt == FM_ENTRY_SPECIAL_DIR || tt == FM_ENTRY_SYSTEM_CONFIGURATION_ROOT)) || (filemanager_is_virtual_view(fm) && tt == FM_ENTRY_VIRTUAL_ITEM)) { @@ -239,6 +358,8 @@ void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, i fm->ctx_items[fm->ctx_item_count++] = CTX_CUT; if (!several) fm->ctx_items[fm->ctx_item_count++] = CTX_RENAME; + if (filemanager_can_trash(fm)) + fm->ctx_items[fm->ctx_item_count++] = CTX_TRASH; fm->ctx_items[fm->ctx_item_count++] = CTX_DELETE; fm->ctx_items[fm->ctx_item_count++] = CTX_PROPERTIES; } @@ -251,9 +372,14 @@ void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, i if (filemanager_is_computer_view(fm)) { fm->ctx_items[fm->ctx_item_count++] = CTX_REFRESH; } else { - if (fm->clipboard_count > 0) - fm->ctx_items[fm->ctx_item_count++] = CTX_PASTE; - fm->ctx_items[fm->ctx_item_count++] = CTX_NEW_FOLDER; + if (filemanager_is_trash_view(fm)) { + if (fm->entry_count > 0) + fm->ctx_items[fm->ctx_item_count++] = CTX_EMPTY_TRASH; + } else if (!filemanager_in_trash(fm)) { + if (fm->clipboard_count > 0) + fm->ctx_items[fm->ctx_item_count++] = CTX_PASTE; + fm->ctx_items[fm->ctx_item_count++] = CTX_NEW_FOLDER; + } if (fm->entry_count > 0) fm->ctx_items[fm->ctx_item_count++] = CTX_SELECT_ALL; fm->ctx_items[fm->ctx_item_count++] = CTX_SORT_NAME; diff --git a/programs/src/desktop/apps/filemanager/app.cpp b/programs/src/desktop/apps/filemanager/app.cpp index 11f630c..ef30b15 100644 --- a/programs/src/desktop/apps/filemanager/app.cpp +++ b/programs/src/desktop/apps/filemanager/app.cpp @@ -85,6 +85,10 @@ void ensure_filemanager_icons_loaded(DesktopState* ds) { ds->icon_drive_usb_lg = svg_load("0:/os/icons/drive-harddisk-usb.svg", 48, 48, defColor); ds->icon_home_folder = svg_load("0:/os/icons/folder-blue-home.svg", 16, 16, defColor); ds->icon_home_folder_lg = svg_load("0:/os/icons/folder-blue-home.svg", 48, 48, defColor); + ds->icon_trash = svg_load("0:/os/icons/user-trash.svg", 16, 16, defColor); + ds->icon_trash_lg = svg_load("0:/os/icons/user-trash.svg", 48, 48, defColor); + ds->icon_trash_full = svg_load("0:/os/icons/user-trash-full.svg", 16, 16, defColor); + ds->icon_trash_full_lg = svg_load("0:/os/icons/user-trash-full.svg", 48, 48, defColor); ds->icon_apps = svg_load("0:/os/icons/folder-blue-development.svg", 16, 16, defColor); ds->icon_apps_lg = svg_load("0:/os/icons/folder-blue-development.svg", 48, 48, defColor); ds->icon_system_configuration_menu = diff --git a/programs/src/desktop/apps/filemanager/events.cpp b/programs/src/desktop/apps/filemanager/events.cpp index 36339a4..457df5c 100644 --- a/programs/src/desktop/apps/filemanager/events.cpp +++ b/programs/src/desktop/apps/filemanager/events.cpp @@ -78,7 +78,8 @@ void filemanager_on_mouse(Window* win, MouseEvent& ev) { local_x, local_y, ev.buttons, ev.prev_buttons, cw, win->content_h, - FM_CTX_MENU_W); + filemanager_context_menu_width( + items, fm->ctx_item_count)); filemanager_store_context_state(fm, state); if (action >= 0) { @@ -94,7 +95,10 @@ void filemanager_on_mouse(Window* win, MouseEvent& ev) { case CTX_CUT: filemanager_do_cut(fm); break; case CTX_PASTE: filemanager_do_paste(fm); break; case CTX_RENAME: filemanager_start_rename(fm); break; - case CTX_DELETE: filemanager_delete_selected(fm); break; + case CTX_DELETE: filemanager_delete_selected(fm, true); break; + case CTX_TRASH: filemanager_delete_selected(fm); break; + case CTX_RESTORE: filemanager_restore_selected(fm); break; + case CTX_EMPTY_TRASH: filemanager_show_empty_trash_confirmation(fm); break; case CTX_NEW_FOLDER: filemanager_new_folder(fm); break; case CTX_PROPERTIES: filemanager_show_selection_properties(fm, target); break; case CTX_REFRESH: filemanager_refresh(fm); break; @@ -149,7 +153,8 @@ void filemanager_on_mouse(Window* win, MouseEvent& ev) { else if (local_x >= 244 && local_x < 268) filemanager_do_paste(fm); else if (local_x >= 272 && local_x < 296) filemanager_start_rename(fm); else if (local_x >= 300 && local_x < 324) filemanager_new_folder(fm); - else if (local_x >= 328 && local_x < 352) filemanager_delete_selected(fm); + else if (local_x >= 328 && local_x < 352) + filemanager_delete_selected(fm, fm->desktop && fm->desktop->kbd_shift_down); return; } @@ -573,8 +578,8 @@ void filemanager_on_key(Window* win, const montauk::abi::KeyEvent& key) { if (have_focus && filemanager_is_entry_selected(fm, cur)) filemanager_open_entry(fm, cur); } else if (nav == 0x53) { - // Delete key - filemanager_delete_selected(fm); + // Delete key: Move to Trash; Shift+Delete deletes permanently + filemanager_delete_selected(fm, key.shift); } else if (key.alt && montauk::nav_scancode(key) == 0x4B) { // Alt+Left: go back filemanager_go_back(fm); diff --git a/programs/src/desktop/apps/filemanager/filemanager_internal.hpp b/programs/src/desktop/apps/filemanager/filemanager_internal.hpp index 01d03d1..af284fd 100644 --- a/programs/src/desktop/apps/filemanager/filemanager_internal.hpp +++ b/programs/src/desktop/apps/filemanager/filemanager_internal.hpp @@ -25,6 +25,7 @@ enum FileManagerEntryType : int { FM_ENTRY_VIRTUAL_ITEM = 6, FM_ENTRY_SPECIAL_DIR = 7, FM_ENTRY_SYSTEM_CONFIGURATION_ROOT = 8, + FM_ENTRY_TRASH = 9, }; enum FileManagerVirtualViewKind : uint8_t { @@ -161,7 +162,7 @@ inline constexpr int FM_GRID_CELL_H = 80; inline constexpr int FM_GRID_CELL_H_WRAPPED = 92; inline constexpr int FM_GRID_ICON = 48; inline constexpr int FM_GRID_PAD = 4; -inline constexpr int FM_CTX_MENU_W = 184; +inline constexpr int FM_CTX_MENU_MIN_W = 140; // List view column layout. Columns are dropped right to left as the window // narrows (Modified first, then Type, then Size) so the name keeps room. @@ -220,6 +221,9 @@ inline constexpr int CTX_SORT_NAME = 11; inline constexpr int CTX_SORT_SIZE = 12; inline constexpr int CTX_SORT_TYPE = 13; inline constexpr int CTX_SORT_MODIFIED = 14; +inline constexpr int CTX_TRASH = 15; // Move to Trash +inline constexpr int CTX_RESTORE = 16; // Restore from Trash +inline constexpr int CTX_EMPTY_TRASH = 17; inline bool filemanager_is_virtual_view(const FileManagerState* fm) { return fm && fm->virtual_view != FM_VIRTUAL_VIEW_NONE; @@ -269,6 +273,21 @@ inline void filemanager_build_context_items(const FileManagerState* fm, } } +// Menu width that fits the widest label plus its shortcut. draw_context_menu +// puts labels at x+12 and right-aligns shortcuts 12px from the edge. +inline int filemanager_context_menu_width(const mtk::ContextMenuItem* items, int count) { + static constexpr int EDGE = 12; + static constexpr int GAP = 24; // between label and shortcut + int w = FM_CTX_MENU_MIN_W; + for (int i = 0; i < count; i++) { + int need = EDGE + text_width(items[i].label) + EDGE; + if (items[i].shortcut && items[i].shortcut[0]) + need += GAP + text_width(items[i].shortcut); + if (need > w) w = need; + } + return w; +} + bool str_ends_with(const char* s, const char* suffix); int detect_file_type(const char* name, bool is_dir); // Lowercased extension after the last '.', without the dot ("" if none). @@ -278,6 +297,14 @@ void file_extension(const char* name, char* out, int out_max); void filemanager_type_label(const FileManagerState* fm, int idx, char* out, int out_max); void filemanager_build_fullpath(char* out, int out_max, const char* dir, const char* name); +// Copy src into out, cut with "..." to fit max_w pixels. +void filemanager_fit_text(const char* src, char* out, int out_len, int max_w); +bool filemanager_path_exists(const char* path); +bool filemanager_path_is_dir(const char* path); +// First free "/ (N)<.ext>" path, or with copy_style the +// " (copy)" / " (copy N)" form used when duplicating in place. +void filemanager_unique_path(const char* dir, const char* name, bool is_dir, + bool copy_style, char* out, int out_max); const char* path_basename(const char* path); // Grow the per-entry parallel arrays to hold at least `needed` entries. @@ -316,6 +343,9 @@ inline bool filemanager_status_is_access_denied(montauk::StatusCode status) { // These return the first failure encountered (Success when everything worked). montauk::StatusCode filemanager_delete_recursive(const char* path); +// Permanent delete that also drops the Trash info file when path is a +// top-level Trash item. Use this for user-initiated deletes. +montauk::StatusCode filemanager_delete_item(const char* path); montauk::StatusCode filemanager_copy_file(const char* src, const char* dst); montauk::StatusCode filemanager_copy_dir_recursive(const char* src, const char* dst); @@ -327,7 +357,9 @@ void filemanager_start_rename(FileManagerState* fm); void filemanager_finish_rename(FileManagerState* fm); void filemanager_cancel_rename(FileManagerState* fm); void filemanager_new_folder(FileManagerState* fm); -void filemanager_delete_selected(FileManagerState* fm); +// Delete key / toolbar: Move to Trash where possible, otherwise (or with +// permanent set, i.e. Shift+Delete) delete permanently after confirming. +void filemanager_delete_selected(FileManagerState* fm, bool permanent = false); void filemanager_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, int target_idx); void filemanager_close_ctx_menu(FileManagerState* fm); @@ -339,6 +371,9 @@ void filemanager_show_multi_properties(FileManagerState* fm, void filemanager_show_directory_delete_confirmation(FileManagerState* fm, int target_idx); void filemanager_show_bulk_delete_confirmation(FileManagerState* fm, const int* indices, int count); +// Confirm a permanent delete of arbitrary paths (Empty Trash). +void filemanager_show_paths_delete_confirmation(FileManagerState* fm, const char* title, + const char* const* paths, int count); // Graphical error popup for a failed file operation. `heading` says what // failed, `path` (optional) names the item, and a non-Success `status` adds // montauk::GetStatusExplanationText() as the cause. `owner_app_data` is only @@ -362,6 +397,29 @@ void filemanager_commit_pathbar(FileManagerState* fm); void filemanager_open_entry(FileManagerState* fm, int idx); +// ---- Trash (trash.cpp) ---- +// /.trash/files holds trashed items; the view of that folder is the +// Trash. Everything below it (trashed folders' contents) is read-only apart +// from permanent deletion. +bool filemanager_trash_files_dir(const DesktopState* ds, char* out, int out_max); +bool filemanager_trash_ensure(const DesktopState* ds); +bool filemanager_is_trash_path(const DesktopState* ds, const char* path); +bool filemanager_is_trash_view(const FileManagerState* fm); // exactly the Trash +bool filemanager_in_trash(const FileManagerState* fm); // Trash or below it +bool filemanager_trash_has_items(const DesktopState* ds); +// Delete means Move to Trash here: a real folder on the home volume, outside +// the Trash itself. +bool filemanager_can_trash(const FileManagerState* fm); +montauk::StatusCode filemanager_trash_item(const DesktopState* ds, const char* path); +// Move a top-level Trash item back to where it came from (recreating missing +// parent folders, or picking "name (2)" if the original name is taken). +montauk::StatusCode filemanager_trash_restore(const char* trashed, char* restored, int restored_max); +bool filemanager_trash_original_path(const char* trashed, char* out, int out_max); +void filemanager_trash_forget(const char* trashed); +void filemanager_open_trash(FileManagerState* fm); +void filemanager_restore_selected(FileManagerState* fm); +void filemanager_show_empty_trash_confirmation(FileManagerState* fm); + // ---- File associations (associations.cpp, openwith.cpp) ---- // Default app for a file: the user's "Always use" choice from // /config/associations.toml, else the first app (by name) whose manifest @@ -387,6 +445,7 @@ enum FileOpKind : int { FILE_OP_COPY = 0, FILE_OP_MOVE = 1, FILE_OP_DELETE = 2, + FILE_OP_TRASH = 3, }; // Start a background copy/move of clipboard contents into the current dir, @@ -394,6 +453,7 @@ enum FileOpKind : int { // Returns true if a job was started (caller should not also do it inline). bool filemanager_start_paste_job(FileManagerState* fm); bool filemanager_start_delete_job(FileManagerState* fm, const char* const* paths, int count); +bool filemanager_start_trash_job(FileManagerState* fm, const char* const* paths, int count); int filemanager_grid_row_height(FileManagerState* fm, int row, int cols); int filemanager_grid_row_y(FileManagerState* fm, int row, int cols); diff --git a/programs/src/desktop/apps/filemanager/fileop.cpp b/programs/src/desktop/apps/filemanager/fileop.cpp index c0fd2c2..abe620a 100644 --- a/programs/src/desktop/apps/filemanager/fileop.cpp +++ b/programs/src/desktop/apps/filemanager/fileop.cpp @@ -17,6 +17,14 @@ namespace filemanager { // Anything larger -- or any directory / multi-item operation -- runs async. static constexpr uint64_t FILEOP_INLINE_MAX_BYTES = 16ull * 1024 * 1024; +// Answers to a name conflict. 0 means "not answered yet". +enum FileOpChoice : int { + FILEOP_CHOICE_NONE = 0, + FILEOP_CHOICE_REPLACE, // files: replace; folders: merge + FILEOP_CHOICE_SKIP, + FILEOP_CHOICE_KEEP_BOTH, // paste as "name (2)" +}; + struct FileOpJob { int kind; // FileOpKind: copy / move / delete char (*paths)[256]; // owned: source paths (copy/move) or targets (delete) @@ -39,8 +47,21 @@ struct FileOpJob { montauk::StatusCode first_error; char first_error_path[256]; + // Name-conflict prompt. The worker fills in the conflict_* details, then + // publishes conflict_pending (release) and waits; the dialog answers via + // conflict_apply_all + conflict_choice (release). Cancel also wakes it. + volatile int conflict_pending; + volatile int conflict_choice; // FileOpChoice + volatile int conflict_apply_all; + char conflict_name[128]; + bool conflict_src_dir; + bool conflict_dst_dir; + int remembered_choice; // worker only: an "apply to all" answer + bool ui_apply_all; // dialog checkbox state + int tid; int last_drawn_done; // dialog redraw tracking + int last_drawn_pending; int mouse_x, mouse_y; }; @@ -66,16 +87,12 @@ static void fileop_set_current(FileOpJob* job, const char* name) { job->current[i] = '\0'; } -static bool fileop_path_is_dir(const char* path) { - const char* probe[1]; - return montauk::readdir(path, probe, 1) >= 0; -} - static const char* fileop_error_verb(int kind) { switch (kind) { case FILE_OP_COPY: return "copy"; case FILE_OP_MOVE: return "move"; case FILE_OP_DELETE: return "delete"; + case FILE_OP_TRASH: return "move"; default: return "process"; } } @@ -96,12 +113,13 @@ static void fileop_report_errors(FileOpJob* job) { if (!job || job->failed_count <= 0) return; char heading[192]; + const char* tail = job->kind == FILE_OP_TRASH ? " to the Trash" : ""; if (job->failed_count == 1) { - snprintf(heading, sizeof(heading), "Couldn't %s \"%s\"", - fileop_error_verb(job->kind), path_basename(job->first_error_path)); + snprintf(heading, sizeof(heading), "Couldn't %s \"%s\"%s", + fileop_error_verb(job->kind), path_basename(job->first_error_path), tail); } else { - snprintf(heading, sizeof(heading), "Couldn't %s %d of %d items", - fileop_error_verb(job->kind), job->failed_count, job->count); + snprintf(heading, sizeof(heading), "Couldn't %s %d of %d items%s", + fileop_error_verb(job->kind), job->failed_count, job->count, tail); } filemanager_show_error(job->desktop, job->owner, heading, job->first_error_path, job->first_error); @@ -111,6 +129,36 @@ static void fileop_report_errors(FileOpJob* job) { // Worker thread // ============================================================================ +// Ask the dialog what to do about an existing destination and wait for the +// answer. Runs on the worker thread. +static int fileop_resolve_conflict(FileOpJob* job, const char* name, + bool src_dir, bool dst_dir) { + // A file and a folder cannot replace one another. + bool mismatch = src_dir != dst_dir; + if (job->remembered_choice != FILEOP_CHOICE_NONE) { + if (job->remembered_choice == FILEOP_CHOICE_REPLACE && mismatch) + return FILEOP_CHOICE_KEEP_BOTH; + return job->remembered_choice; + } + + montauk::strncpy(job->conflict_name, name, sizeof(job->conflict_name)); + job->conflict_src_dir = src_dir; + job->conflict_dst_dir = dst_dir; + __atomic_store_n(&job->conflict_choice, FILEOP_CHOICE_NONE, __ATOMIC_RELAXED); + __atomic_store_n(&job->conflict_pending, 1, __ATOMIC_RELEASE); + + int choice = FILEOP_CHOICE_NONE; + while (!job->cancel && + (choice = __atomic_load_n(&job->conflict_choice, __ATOMIC_ACQUIRE)) == FILEOP_CHOICE_NONE) + montauk::sleep_ms(10); + __atomic_store_n(&job->conflict_pending, 0, __ATOMIC_RELEASE); + + if (job->cancel) return FILEOP_CHOICE_SKIP; + if (job->conflict_apply_all) job->remembered_choice = choice; + if (choice == FILEOP_CHOICE_REPLACE && mismatch) choice = FILEOP_CHOICE_KEEP_BOTH; + return choice; +} + static int fileop_worker(void* arg) { FileOpJob* job = (FileOpJob*)arg; @@ -120,27 +168,44 @@ static int fileop_worker(void* arg) { fileop_set_current(job, base); if (job->kind == FILE_OP_DELETE) { - fileop_record_status(job, src, filemanager_delete_recursive(src)); + fileop_record_status(job, src, filemanager_delete_item(src)); + job->files_done = i + 1; + continue; + } + if (job->kind == FILE_OP_TRASH) { + fileop_record_status(job, src, filemanager_trash_item(job->desktop, 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)) { + bool is_dir = filemanager_path_is_dir(src); + if (is_dir && fileop_path_same_or_descendant(job->dest_dir, src)) { // Refuse to copy a directory into itself. fileop_record_status(job, src, montauk::InvalidArgument); job->files_done = i + 1; continue; } + char dst[512]; + filemanager_build_fullpath(dst, 512, job->dest_dir, base); + if (montauk::streq(dst, src)) { + // Moving into its own folder is a no-op; copying duplicates it. + if (job->kind == FILE_OP_MOVE) { job->files_done = i + 1; continue; } + filemanager_unique_path(job->dest_dir, base, is_dir, true, dst, sizeof(dst)); + } else if (filemanager_path_exists(dst)) { + int choice = fileop_resolve_conflict(job, base, is_dir, filemanager_path_is_dir(dst)); + if (job->cancel || choice == FILEOP_CHOICE_SKIP) { job->files_done = i + 1; continue; } + if (choice == FILEOP_CHOICE_KEEP_BOTH) + filemanager_unique_path(job->dest_dir, base, is_dir, false, dst, sizeof(dst)); + // Replace: rename and copy_file swap a file in atomically, and a + // folder copy merges into the existing one. + } + montauk::StatusCode status; if (job->kind == FILE_OP_MOVE) { // Try a rename first: an instant same-volume move with no copying. + // It fails onto a non-empty folder, which then merges below. status = filemanager_status_from_result(montauk::frename(src, dst)); if (status != montauk::Success && !filemanager_status_is_access_denied(status)) { // Cross-volume (or driver without rename): fall back to copy + delete. @@ -173,6 +238,7 @@ static const char* fileop_verb(int kind) { case FILE_OP_COPY: return "Copying"; case FILE_OP_MOVE: return "Moving"; case FILE_OP_DELETE: return "Deleting"; + case FILE_OP_TRASH: return "Moving to Trash"; default: return "Working"; } } @@ -182,6 +248,83 @@ static void fileop_cancel_button_rect(int cw, int ch, Rect* out) { *out = { cw - btn_w - 16, ch - btn_h - 14, btn_w, btn_h }; } +// Conflict view layout: title, explanation, optional "apply to all" +// checkbox, then Replace / Skip / Keep Both / Cancel along the bottom. +static constexpr int FILEOP_CONFLICT_BTN_COUNT = 4; + +static int fileop_conflict_checkbox_y(int fh) { + return 18 + (fh + 8) + (fh + 14); +} + +static void fileop_conflict_button_rects(int cw, int ch, Rect* out) { + int btn_w = 88, btn_h = 28, gap = 8; + int x = cw - 16 - FILEOP_CONFLICT_BTN_COUNT * btn_w - (FILEOP_CONFLICT_BTN_COUNT - 1) * gap; + for (int i = 0; i < FILEOP_CONFLICT_BTN_COUNT; i++) + out[i] = { x + i * (btn_w + gap), ch - btn_h - 14, btn_w, btn_h }; +} + +static Rect fileop_conflict_checkbox_rect(int cw) { + return { 16, fileop_conflict_checkbox_y(system_font_height()), cw - 32, 22 }; +} + +static bool fileop_conflict_pending(FileOpJob* job) { + return __atomic_load_n(&job->conflict_pending, __ATOMIC_ACQUIRE) != 0; +} + +// More items may still collide, so "apply to all" is worth offering. +static bool fileop_conflict_offers_apply_all(FileOpJob* job) { + return job->files_done + 1 < job->count; +} + +static void fileop_answer_conflict(FileOpJob* job, int choice) { + job->conflict_apply_all = job->ui_apply_all ? 1 : 0; + __atomic_store_n(&job->conflict_choice, choice, __ATOMIC_RELEASE); +} + +static void fileop_draw_conflict(Canvas& c, FileOpJob* job, const mtk::Theme& theme) { + int pad = 16; + int fh = system_font_height(); + int y = 18; + bool src_dir = job->conflict_src_dir; + bool dst_dir = job->conflict_dst_dir; + bool mismatch = src_dir != dst_dir; + + char title[192]; + snprintf(title, sizeof(title), dst_dir ? "A folder named \"%s\" already exists" + : "\"%s\" already exists", job->conflict_name); + char title_fit[192]; + filemanager_fit_text(title, title_fit, sizeof(title_fit), c.w - pad * 2); + c.text(pad, y, title_fit, theme.text); + y += fh + 8; + + const char* sub = mismatch ? "A file and a folder can't replace each other." + : src_dir ? "Merging replaces files that have the same name." + : "Replace it with the one you're pasting?"; + c.text(pad, y, sub, theme.text_muted); + + if (fileop_conflict_offers_apply_all(job)) { + Rect cb = fileop_conflict_checkbox_rect(c.w); + mtk::draw_checkbox(c, cb, "Do this for all conflicts", mtk::check_state(job->ui_apply_all), + theme, true, cb.contains(job->mouse_x, job->mouse_y)); + } + + Rect btns[FILEOP_CONFLICT_BTN_COUNT]; + fileop_conflict_button_rects(c.w, c.h, btns); + const char* labels[FILEOP_CONFLICT_BTN_COUNT] = { + src_dir && dst_dir ? "Merge" : "Replace", "Skip", "Keep Both", "Cancel" + }; + mtk::ButtonVariant variants[FILEOP_CONFLICT_BTN_COUNT] = { + mtk::BUTTON_SECONDARY, mtk::BUTTON_SECONDARY, mtk::BUTTON_PRIMARY, mtk::BUTTON_SECONDARY + }; + for (int i = 0; i < FILEOP_CONFLICT_BTN_COUNT; i++) { + bool enabled = !(i == 0 && mismatch); + mtk::draw_button(c, btns[i], labels[i], variants[i], + mtk::widget_state(false, enabled && btns[i].contains(job->mouse_x, job->mouse_y), + enabled), + theme); + } +} + 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++) { @@ -211,6 +354,11 @@ static void fileop_dialog_on_draw(Window* win, Framebuffer& fb) { mtk::Theme theme = mtk::make_theme(job->accent); c.fill(theme.window_bg); + if (fileop_conflict_pending(job)) { + fileop_draw_conflict(c, job, theme); + return; + } + int pad = 16; int fh = system_font_height(); int y = 18; @@ -234,9 +382,7 @@ static void fileop_dialog_on_draw(Window* win, Framebuffer& fb) { 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'; + filemanager_fit_text(cur, fit, sizeof(fit), c.w - pad * 2); c.text(pad, y, fit, theme.text_muted); } y += fh + 12; @@ -272,6 +418,29 @@ static void fileop_dialog_on_mouse(Window* win, MouseEvent& ev) { int mx = 0, my = 0; if (!host.map_mouse(ev, &mx, &my)) return; + if (fileop_conflict_pending(job)) { + if (mx != job->mouse_x || my != job->mouse_y) host.invalidate(); + job->mouse_x = mx; + job->mouse_y = my; + if (!ev.left_pressed()) return; + + if (fileop_conflict_offers_apply_all(job) && + fileop_conflict_checkbox_rect(win->content_w).contains(mx, my)) { + job->ui_apply_all = !job->ui_apply_all; + host.invalidate(); + return; + } + Rect btns[FILEOP_CONFLICT_BTN_COUNT]; + fileop_conflict_button_rects(win->content_w, win->content_h, btns); + bool mismatch = job->conflict_src_dir != job->conflict_dst_dir; + if (btns[0].contains(mx, my) && !mismatch) fileop_answer_conflict(job, FILEOP_CHOICE_REPLACE); + else if (btns[1].contains(mx, my)) fileop_answer_conflict(job, FILEOP_CHOICE_SKIP); + else if (btns[2].contains(mx, my)) fileop_answer_conflict(job, FILEOP_CHOICE_KEEP_BOTH); + else if (btns[3].contains(mx, my)) job->cancel = 1; + host.invalidate(); + 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); @@ -290,7 +459,12 @@ static void fileop_dialog_on_mouse(Window* win, MouseEvent& ev) { static void fileop_dialog_on_key(Window* win, const montauk::abi::KeyEvent& key) { FileOpJob* job = (FileOpJob*)win->app_data; if (!job || !key.pressed) return; - if (key.scancode == 0x01) job->cancel = 1; // Escape requests cancel + if (key.scancode == 0x01) { + job->cancel = 1; // Escape requests cancel + } else if ((key.ascii == '\n' || key.ascii == '\r') && fileop_conflict_pending(job)) { + // Enter takes the non-destructive default. + fileop_answer_conflict(job, FILEOP_CHOICE_KEEP_BOTH); + } } static void fileop_dialog_on_poll(Window* win) { @@ -301,6 +475,11 @@ static void fileop_dialog_on_poll(Window* win) { job->last_drawn_done = job->files_done; win->dirty = true; } + int pending = fileop_conflict_pending(job) ? 1 : 0; + if (pending != job->last_drawn_pending) { + job->last_drawn_pending = pending; + win->dirty = true; + } if (job->finished) { // Hand off to on_close, which joins the worker, refreshes the file @@ -347,14 +526,17 @@ static bool fileop_show_dialog(FileOpJob* job) { int bar_bottom = 18 + (fh + 10) + (fh + 12) + 10; // mirrors on_draw layout int btn_h = 28; int content_h = bar_bottom + 16 /*gap*/ + btn_h + 14 /*bottom margin*/; - int w = 380; + // Copies and moves can turn into the conflict prompt, which is taller. + if (job->kind == FILE_OP_COPY || job->kind == FILE_OP_MOVE) { + int conflict_h = fileop_conflict_checkbox_y(fh) + 22 + 16 + btn_h + 14; + if (conflict_h > content_h) content_h = conflict_h; + } + int w = 420; int h = content_h + gui::TITLEBAR_HEIGHT + gui::BORDER_WIDTH; 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"; + const char* dlg_title = fileop_verb(job->kind); int idx = desktop_create_window(ds, dlg_title, wx, wy, w, h); if (idx < 0) return false; @@ -435,17 +617,21 @@ bool filemanager_start_paste_job(FileManagerState* fm) { 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])) { + // snappy (no thread, no dialog flash). A name conflict needs the prompt, + // so that case goes through the job instead. + if (fm->clipboard_count == 1 && !filemanager_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)); + char dst[512]; + filemanager_build_fullpath(dst, 512, fm->current_path, path_basename(src)); + bool same = montauk::streq(dst, src); + if (same && !is_cut) + filemanager_unique_path(fm->current_path, path_basename(src), false, true, dst, sizeof(dst)); + if (sz <= FILEOP_INLINE_MAX_BYTES && (same || !filemanager_path_exists(dst))) { montauk::StatusCode status = montauk::Success; - if (!montauk::streq(dst, src)) { + if (!(same && is_cut)) { if (is_cut) { status = filemanager_status_from_result(montauk::frename(src, dst)); if (status != montauk::Success && @@ -491,12 +677,12 @@ bool filemanager_start_paste_job(FileManagerState* fm) { return fileop_launch(job); } -bool filemanager_start_delete_job(FileManagerState* fm, +static bool fileop_start_path_job(FileManagerState* fm, int kind, 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); + FileOpJob* job = fileop_alloc_job(kind, count); if (!job) return false; for (int i = 0; i < count; i++) { montauk::strncpy(job->paths[i], paths[i] ? paths[i] : "", 255); @@ -510,4 +696,14 @@ bool filemanager_start_delete_job(FileManagerState* fm, return fileop_launch(job); } +bool filemanager_start_delete_job(FileManagerState* fm, + const char* const* paths, int count) { + return fileop_start_path_job(fm, FILE_OP_DELETE, paths, count); +} + +bool filemanager_start_trash_job(FileManagerState* fm, + const char* const* paths, int count) { + return fileop_start_path_job(fm, FILE_OP_TRASH, paths, count); +} + } // namespace filemanager diff --git a/programs/src/desktop/apps/filemanager/filesystem.cpp b/programs/src/desktop/apps/filemanager/filesystem.cpp index a2a61aa..634172b 100644 --- a/programs/src/desktop/apps/filemanager/filesystem.cpp +++ b/programs/src/desktop/apps/filemanager/filesystem.cpp @@ -233,6 +233,12 @@ void filemanager_read_drives(FileManagerState* fm) { // Settings entry filemanager_add_root_entry(fm, "Settings", FM_ENTRY_SYSTEM_CONFIGURATION_ROOT, -1); + // Trash; drive_indices carries "has items" so the icon shows full/empty. + if (ds && ds->home_dir[0] != '\0') { + filemanager_add_root_entry(fm, "Trash", FM_ENTRY_TRASH, + filemanager_trash_has_items(ds) ? 1 : 0); + } + // Drive entries int drives[FM_MAX_DRIVES]; int driveCount = montauk::drivelist(drives, FM_MAX_DRIVES); @@ -610,12 +616,35 @@ montauk::StatusCode filemanager_delete_recursive(const char* path) { montauk::StatusCode filemanager_copy_file(const char* src, const char* dst) { if (!src || !dst || montauk::streq(src, dst)) return montauk::InvalidArgument; + // A folder cannot be replaced by a file (rename would even delete an empty + // one), so refuse rather than guess. + bool replacing = filemanager_path_exists(dst); + if (replacing && filemanager_path_is_dir(dst)) return montauk::InvalidArgument; + int sfd = montauk::open(src); if (sfd < 0) return filemanager_status_from_result(sfd); uint64_t size = montauk::getsize(sfd); - // Create destination - int dfd = montauk::fcreate(dst); + // fcreate() truncates an existing file, and a failed copy deletes what it + // wrote. Writing straight onto an existing destination would therefore + // lose the original on any error, so copy beside it and rename over it + // only once the data is complete (rename replaces the target). + char tmp[512]; + const char* target = dst; + if (replacing) { + char dir[512]; + montauk::strncpy(dir, dst, sizeof(dir)); + int cut = montauk::slen(dir); + while (cut > 0 && dir[cut - 1] != '/') cut--; + dir[cut > 0 ? cut - 1 : 0] = '\0'; + char tmp_name[160]; + snprintf(tmp_name, sizeof(tmp_name), ".%s.part", path_basename(dst)); + filemanager_build_fullpath(tmp, sizeof(tmp), dir, tmp_name); + montauk::fdelete(tmp); // stale leftover from an interrupted copy + target = tmp; + } + + int dfd = montauk::fcreate(target); if (dfd < 0) { montauk::close(sfd); return filemanager_status_from_result(dfd); @@ -629,7 +658,7 @@ montauk::StatusCode filemanager_copy_file(const char* src, const char* dst) { if (!buf) { montauk::close(sfd); montauk::close(dfd); - montauk::fdelete(dst); + montauk::fdelete(target); return montauk::OutOfMemory; } @@ -656,7 +685,10 @@ montauk::StatusCode filemanager_copy_file(const char* src, const char* dst) { montauk::close(sfd); montauk::close(dfd); - if (status != montauk::Success) montauk::fdelete(dst); + if (status == montauk::Success && replacing) { + status = filemanager_status_from_result(montauk::frename(target, dst)); + } + if (status != montauk::Success) montauk::fdelete(target); return status; } diff --git a/programs/src/desktop/apps/filemanager/helpers.cpp b/programs/src/desktop/apps/filemanager/helpers.cpp index c93efbd..ae1df25 100644 --- a/programs/src/desktop/apps/filemanager/helpers.cpp +++ b/programs/src/desktop/apps/filemanager/helpers.cpp @@ -127,7 +127,7 @@ const char* ctx_label(int action) { case CTX_CUT: return "Cut"; case CTX_PASTE: return "Paste"; case CTX_RENAME: return "Rename"; - case CTX_DELETE: return "Delete"; + case CTX_DELETE: return "Delete Permanently"; case CTX_NEW_FOLDER: return "New Folder"; case CTX_PROPERTIES: return "Properties"; case CTX_REFRESH: return "Refresh"; @@ -137,6 +137,9 @@ const char* ctx_label(int action) { case CTX_SORT_SIZE: return "Sort by Size"; case CTX_SORT_TYPE: return "Sort by Type"; case CTX_SORT_MODIFIED: return "Sort by Modified"; + case CTX_TRASH: return "Move to Trash"; + case CTX_RESTORE: return "Restore"; + case CTX_EMPTY_TRASH: return "Empty Trash"; default: return "?"; } } @@ -160,7 +163,10 @@ const char* ctx_shortcut(const FileManagerState* fm, int action) { case CTX_CUT: return "Ctrl+X"; case CTX_PASTE: return "Ctrl+V"; case CTX_RENAME: return "F2"; - case CTX_DELETE: return "Del"; + case CTX_TRASH: return "Del"; + // Delete is permanent; Del alone means that only where nothing can be + // trashed (inside the Trash, other volumes). + case CTX_DELETE: return filemanager_can_trash(fm) ? "Shift+Del" : "Del"; case CTX_NEW_FOLDER: return "Ctrl+Shift+N"; case CTX_PROPERTIES: return "Alt+Enter"; case CTX_REFRESH: return "F5"; @@ -172,12 +178,15 @@ const char* ctx_shortcut(const FileManagerState* fm, int action) { int ctx_group(int action) { switch (action) { case CTX_OPEN: - case CTX_OPEN_WITH: return 0; + case CTX_OPEN_WITH: + case CTX_RESTORE: return 0; case CTX_COPY: case CTX_CUT: case CTX_PASTE: return 1; case CTX_RENAME: + case CTX_TRASH: case CTX_DELETE: + case CTX_EMPTY_TRASH: case CTX_NEW_FOLDER: return 2; case CTX_SELECT_ALL: return 3; case CTX_SORT_NAME: @@ -239,6 +248,7 @@ void filemanager_type_label(const FileManagerState* fm, int idx, char* out, int case FM_ENTRY_SYSTEM_CONFIGURATION_ROOT: fixed = "Settings"; break; case FM_ENTRY_HOME: fixed = "Home"; break; case FM_ENTRY_DRIVE: fixed = "Drive"; break; + case FM_ENTRY_TRASH: fixed = "Trash"; break; case FM_ENTRY_SPECIAL_DIR: case FM_ENTRY_DIR: fixed = "Folder"; break; case FM_ENTRY_EXEC: fixed = "Program"; break; @@ -268,6 +278,85 @@ void filemanager_build_fullpath(char* out, int out_max, const char* dir, const c str_append(out, name, out_max); } +bool filemanager_path_exists(const char* path) { + // open() succeeds for files and directories alike on every driver, which + // stat() does not guarantee. + int fd = montauk::open(path); + if (fd < 0) return false; + montauk::close(fd); + return true; +} + +bool filemanager_path_is_dir(const char* path) { + const char* probe[1]; + return montauk::readdir(path, probe, 1) >= 0; +} + +void filemanager_unique_path(const char* dir, const char* name, bool is_dir, + bool copy_style, char* out, int out_max) { + // Split "report.txt" into stem "report" + ext ".txt". Folders and + // leading-dot names keep the whole name as the stem. + int nlen = montauk::slen(name); + int ext_at = nlen; + if (!is_dir) { + for (int i = nlen - 1; i > 0; i--) { + if (name[i] == '.') { ext_at = i; break; } + } + } + + for (int n = copy_style ? 1 : 2; n < 10000; n++) { + char suffix[24]; + if (copy_style) { + if (n == 1) snprintf(suffix, sizeof(suffix), " (copy)"); + else snprintf(suffix, sizeof(suffix), " (copy %d)", n); + } else { + snprintf(suffix, sizeof(suffix), " (%d)", n); + } + + // Entry names hold 127 bytes; shorten the stem (on a UTF-8 boundary) + // so the suffix and extension always fit. + int ext_len = nlen - ext_at; + int suf_len = montauk::slen(suffix); + int stem_len = ext_at; + if (stem_len + suf_len + ext_len > 127) { + int max_stem = 127 - suf_len - ext_len; + if (max_stem < 1) max_stem = 1; + stem_len = montauk::utf8::floor_boundary(name, ext_at, max_stem); + } + + char candidate[128]; + int p = 0; + for (int i = 0; i < stem_len; i++) candidate[p++] = name[i]; + for (int i = 0; i < suf_len; i++) candidate[p++] = suffix[i]; + for (int i = ext_at; i < nlen && p < 127; i++) candidate[p++] = name[i]; + candidate[p] = '\0'; + + filemanager_build_fullpath(out, out_max, dir, candidate); + if (!filemanager_path_exists(out)) return; + } +} + +void filemanager_fit_text(const char* src, char* out, int out_len, int max_w) { + if (!out || out_len <= 0) return; + montauk::strncpy(out, src ? src : "", out_len); + if (out_len <= 4 || text_width(out) <= max_w) return; + + int ellipsis_w = text_width("..."); + int len = montauk::slen(out); + while (len > 0) { + out[len] = '\0'; + if (text_width(out) + ellipsis_w <= max_w) break; + len = montauk::utf8::prev_boundary(out, len); + } + + if (len > out_len - 4) len = montauk::utf8::floor_boundary(out, len, out_len - 4); + if (len < 0) len = 0; + out[len] = '.'; + out[len + 1] = '.'; + out[len + 2] = '.'; + out[len + 3] = '\0'; +} + // Extract basename from a path (pointer into the path string) const char* path_basename(const char* path) { const char* last = path; diff --git a/programs/src/desktop/apps/filemanager/navigation.cpp b/programs/src/desktop/apps/filemanager/navigation.cpp index f8a24ea..5fa45dd 100644 --- a/programs/src/desktop/apps/filemanager/navigation.cpp +++ b/programs/src/desktop/apps/filemanager/navigation.cpp @@ -72,7 +72,9 @@ void filemanager_navigate(FileManagerState* fm, const char* name) { void filemanager_go_up(FileManagerState* fm) { if (filemanager_is_computer_view(fm)) return; - if (filemanager_is_virtual_view(fm)) { + // The Trash lives under a hidden folder of home; its parent is Computer, + // where its icon is. + if (filemanager_is_virtual_view(fm) || filemanager_is_trash_view(fm)) { filemanager_read_drives(fm); filemanager_push_history(fm); return; @@ -132,6 +134,14 @@ void filemanager_go_forward(FileManagerState* fm) { filemanager_navigate_to_history(fm); } +void filemanager_open_trash(FileManagerState* fm) { + if (!fm || !filemanager_trash_ensure(fm->desktop)) return; + if (!filemanager_trash_files_dir(fm->desktop, fm->current_path, sizeof(fm->current_path))) + return; + filemanager_read_dir(fm); + filemanager_push_history(fm); +} + void filemanager_go_home(FileManagerState* fm) { filemanager_read_drives(fm); filemanager_push_history(fm); @@ -262,6 +272,11 @@ void filemanager_open_entry(FileManagerState* fm, int idx) { return; } + if (filemanager_is_computer_view(fm) && fm->entry_types[idx] == FM_ENTRY_TRASH) { + filemanager_open_trash(fm); + return; + } + if (filemanager_is_computer_view(fm) && fm->entry_types[idx] == FM_ENTRY_DRIVE) { int d = fm->drive_indices[idx]; char dpath[8]; diff --git a/programs/src/desktop/apps/filemanager/properties.cpp b/programs/src/desktop/apps/filemanager/properties.cpp index 3eb3e0f..140885f 100644 --- a/programs/src/desktop/apps/filemanager/properties.cpp +++ b/programs/src/desktop/apps/filemanager/properties.cpp @@ -41,6 +41,8 @@ struct FileDeleteConfirmDialogState { char name[128]; // display name (single item) or summary ("3 items") char path[512]; // primary path (used for single-item subtitle) + char title[96]; // custom heading (Empty Trash); empty = derived + bool is_dir; // single item: folder or file wording SvgIcon icon; // Multi-item targets (dynamically allocated). count == 1 keeps the original @@ -56,26 +58,6 @@ static void props_copy(char* dst, int dst_len, const char* src) { dst[dst_len - 1] = '\0'; } -static void props_fit_text(const char* src, char* out, int out_len, int max_w) { - props_copy(out, out_len, src); - if (out_len <= 4 || text_width(out) <= max_w) return; - - int ellipsis_w = text_width("..."); - int len = montauk::slen(out); - while (len > 0) { - out[len] = '\0'; - if (text_width(out) + ellipsis_w <= max_w) break; - len = montauk::utf8::prev_boundary(out, len); - } - - if (len > out_len - 4) len = montauk::utf8::floor_boundary(out, len, out_len - 4); - if (len < 0) len = 0; - out[len] = '.'; - out[len + 1] = '.'; - out[len + 2] = '.'; - out[len + 3] = '\0'; -} - static Rect props_close_button_rect(int content_w, int content_h) { return {content_w - 96, content_h - 42, 80, 28}; } @@ -342,7 +324,7 @@ static int props_draw_row(Canvas& c, c.text(x, y, label, theme.text_muted); char fit[512]; - props_fit_text(value, fit, sizeof(fit), c.w - x - label_w - 16); + filemanager_fit_text(value, fit, sizeof(fit), c.w - x - label_w - 16); c.text(x + label_w, y, fit, theme.text); return y + fh + 8; } @@ -361,7 +343,7 @@ static void props_on_draw(Window* win, Framebuffer& fb) { props_draw_icon(c, st, pad, pad + 2); char title[128]; - props_fit_text(st->name, title, sizeof(title), c.w - 88); + filemanager_fit_text(st->name, title, sizeof(title), c.w - 88); c.text(80, pad + 4, title, theme.text); c.text(80, pad + 4 + fh + 4, st->type, theme.text_muted); @@ -422,9 +404,16 @@ static void props_on_close(Window* win) { // Subtitle wording, shared between the width calculation at creation time and // the draw path so the two never drift out of sync. static const char* const kDeleteSubMulti = - "This will delete all selected items and their contents."; + "They will be deleted for good, folders with their contents."; static const char* const kDeleteSubSingle = - "This will delete all of its contents."; + "The folder and all of its contents will be deleted for good."; +static const char* const kDeleteSubFile = + "It will be deleted for good. This can't be undone."; + +static const char* delete_confirm_subtitle(const FileDeleteConfirmDialogState* st) { + if (st->count > 1) return kDeleteSubMulti; + return st->is_dir ? kDeleteSubSingle : kDeleteSubFile; +} // Pick a dialog width wide enough that the subtitle (drawn at x=80 with a 16px // right margin) fits without being clipped. Falls back to the original 380px @@ -474,10 +463,19 @@ static void delete_confirm_apply(FileDeleteConfirmDialogState* st) { FileManagerState* owner = delete_confirm_live_owner(st); int n = st->count > 0 ? st->count : (st->path[0] ? 1 : 0); + // A single small file deletes inline, without a progress dialog flash. + // Anything bigger frees many blocks, which can stall the desktop. + bool inline_ok = false; + if (n == 1 && !st->is_dir) { + montauk::abi::FileStat stt; + const char* p = st->count > 0 ? st->paths[0] : st->path; + inline_ok = montauk::stat(p, &stt) == 0 && stt.size <= FILEMANAGER_DELETE_INLINE_MAX_BYTES; + } + // Delete on a worker thread with a progress dialog. The job copies the path // list, so it stays valid after this confirm dialog is freed. bool started = false; - if (owner && n > 0) { + if (owner && n > 0 && !inline_ok) { const char** arr = (const char**)montauk::malloc((uint64_t)n * sizeof(const char*)); if (arr) { for (int i = 0; i < n; i++) @@ -496,7 +494,7 @@ static void delete_confirm_apply(FileDeleteConfirmDialogState* st) { for (int i = 0; i < n; i++) { const char* p = (st->count > 0) ? st->paths[i] : st->path; if (!p || !p[0]) continue; - montauk::StatusCode status = filemanager_delete_recursive(p); + montauk::StatusCode status = filemanager_delete_item(p); if (status == montauk::Success) { any = true; } else if (failed++ == 0) { @@ -546,22 +544,26 @@ static void delete_confirm_on_draw(Window* win, Framebuffer& fb) { } char title[160]; - if (st->count > 1) - snprintf(title, sizeof(title), "Delete %d items?", st->count); + if (st->title[0]) + snprintf(title, sizeof(title), "%s", st->title); + else if (st->count > 1) + snprintf(title, sizeof(title), "Permanently delete %d items?", st->count); + else if (st->is_dir) + snprintf(title, sizeof(title), "Permanently delete folder \"%s\"?", st->name); else - snprintf(title, sizeof(title), "Delete folder \"%s\"?", st->name); + snprintf(title, sizeof(title), "Permanently delete \"%s\"?", st->name); char title_fit[160]; - props_fit_text(title, title_fit, sizeof(title_fit), c.w - 88); + filemanager_fit_text(title, title_fit, sizeof(title_fit), c.w - 88); c.text(80, 22, title_fit, theme.text); - const char* sub = (st->count > 1) ? kDeleteSubMulti : kDeleteSubSingle; + const char* sub = delete_confirm_subtitle(st); char sub_fit[160]; - props_fit_text(sub, sub_fit, sizeof(sub_fit), c.w - 96); + filemanager_fit_text(sub, sub_fit, sizeof(sub_fit), c.w - 96); c.text(80, 22 + fh + 8, sub_fit, theme.text_muted); if (st->count <= 1) { char path_fit[512]; - props_fit_text(st->path, path_fit, sizeof(path_fit), c.w - 96); + filemanager_fit_text(st->path, path_fit, sizeof(path_fit), c.w - 96); c.text(80, 22 + fh * 2 + 16, path_fit, theme.text_muted); } @@ -659,6 +661,7 @@ void filemanager_show_directory_delete_confirmation(FileManagerState* fm, int ta props_copy(st->name, sizeof(st->name), fm->entry_names[target_idx]); filemanager_build_fullpath(st->path, sizeof(st->path), fm->current_path, fm->entry_names[target_idx]); + st->is_dir = true; int sfi = -1; if (fm->entry_types[target_idx] == FM_ENTRY_SPECIAL_DIR) @@ -745,20 +748,82 @@ void filemanager_show_bulk_delete_confirmation(FileManagerState* fm, int idx = indices[0]; props_copy(st->name, sizeof(st->name), fm->entry_names[idx]); montauk::strncpy(st->path, st->paths[0], sizeof(st->path) - 1); + st->is_dir = fm->is_dir[idx]; } else { snprintf(st->name, sizeof(st->name), "%d items", out); } - if (ds->icon_folder_lg.pixels) st->icon = ds->icon_folder_lg; + const SvgIcon& icon = (out == 1 && !st->is_dir) ? ds->icon_file_lg : ds->icon_folder_lg; + if (icon.pixels) st->icon = icon; - int w = delete_confirm_dialog_width(out > 1 ? kDeleteSubMulti : kDeleteSubSingle); + int w = delete_confirm_dialog_width(delete_confirm_subtitle(st)); int h = 180; int wx = 0; int wy = 0; props_center_dialog(ds, fm, w, h, &wx, &wy); - int idx = desktop_create_window(ds, "Delete Items", wx, wy, w, h); + const char* win_title = out > 1 ? "Delete Items" : st->is_dir ? "Delete Folder" : "Delete File"; + int idx = desktop_create_window(ds, win_title, wx, wy, w, h); if (idx < 0) { + montauk::mfree(st->paths); + montauk::mfree(st); + return; + } + + Window* win = &ds->windows[idx]; + fm->delete_confirm_dialog = st; + win->app_data = st; + win->on_draw = delete_confirm_on_draw; + win->on_mouse = delete_confirm_on_mouse; + win->on_key = delete_confirm_on_key; + win->on_close = delete_confirm_on_close; +} + +void filemanager_show_paths_delete_confirmation(FileManagerState* fm, const char* title, + const char* const* paths, int count) { + if (!fm || !fm->desktop || !paths || count <= 0) return; + + DesktopState* ds = fm->desktop; + if (fm->delete_confirm_dialog) { + for (int i = 0; i < ds->window_count; i++) { + if (ds->windows[i].app_data == fm->delete_confirm_dialog) { + desktop_raise_window(ds, i); + return; + } + } + fm->delete_confirm_dialog = nullptr; + } + + FileDeleteConfirmDialogState* st = + (FileDeleteConfirmDialogState*)montauk::malloc(sizeof(FileDeleteConfirmDialogState)); + if (!st) return; + montauk::memset(st, 0, sizeof(FileDeleteConfirmDialogState)); + st->desktop = ds; + st->owner = fm; + st->accent = ds->settings.accent_color; + st->paths = (char(*)[512])montauk::malloc((uint64_t)count * 512); + if (!st->paths) { + montauk::mfree(st); + return; + } + for (int i = 0; i < count; i++) montauk::strncpy(st->paths[i], paths[i], 512); + st->count = count; + props_copy(st->title, sizeof(st->title), title); + props_copy(st->name, sizeof(st->name), path_basename(paths[0])); + montauk::strncpy(st->path, paths[0], sizeof(st->path)); + // One item: describe it as a folder or a file. + st->is_dir = count == 1 && filemanager_path_is_dir(paths[0]); + if (ds->icon_trash_full_lg.pixels) st->icon = ds->icon_trash_full_lg; + + int w = delete_confirm_dialog_width(delete_confirm_subtitle(st)); + int h = 180; + int wx = 0; + int wy = 0; + props_center_dialog(ds, fm, w, h, &wx, &wy); + + int idx = desktop_create_window(ds, title, wx, wy, w, h); + if (idx < 0) { + montauk::mfree(st->paths); montauk::mfree(st); return; } @@ -802,7 +867,7 @@ static int error_wrap_text(const char* src, char (*lines)[160], int max_lines, i if (!src[pos]) break; if (count == max_lines - 1) { - props_fit_text(src + pos, lines[count++], 160, max_w); + filemanager_fit_text(src + pos, lines[count++], 160, max_w); break; } @@ -830,7 +895,7 @@ static int error_wrap_text(const char* src, char (*lines)[160], int max_lines, i if (n > 159) n = 159; montauk::memcpy(buf, src + pos, n); buf[n] = '\0'; - props_fit_text(buf, lines[count++], 160, max_w); + filemanager_fit_text(buf, lines[count++], 160, max_w); pos = word_end; } else { int n = best - pos; @@ -877,11 +942,11 @@ static void error_on_draw(Window* win, Framebuffer& fb) { } char fit[192]; - props_fit_text(st->heading, fit, sizeof(fit), text_w); + filemanager_fit_text(st->heading, fit, sizeof(fit), text_w); c.text(kErrorTextX, kErrorTop, fit, theme.text); if (st->path[0]) { - props_fit_text(st->path, fit, sizeof(fit), text_w); + filemanager_fit_text(st->path, fit, sizeof(fit), text_w); c.text(kErrorTextX, error_path_y(fh), fit, theme.text_muted); } diff --git a/programs/src/desktop/apps/filemanager/render.cpp b/programs/src/desktop/apps/filemanager/render.cpp index 256c860..dffae95 100644 --- a/programs/src/desktop/apps/filemanager/render.cpp +++ b/programs/src/desktop/apps/filemanager/render.cpp @@ -257,21 +257,23 @@ void filemanager_draw_header(Canvas& c, FileManagerState* fm, Color toolbar_colo bool multi_sel = fm->multi_count > 0 && !filemanager_is_computer_view(fm) && !filemanager_is_virtual_view(fm); bool has_sel = single_sel || multi_sel; + // The Trash is read-only apart from Copy, Restore and permanent delete. + bool in_trash = filemanager_in_trash(fm); // Rename is single-target only. - bool has_rename = single_sel && !multi_sel; + bool has_rename = single_sel && !multi_sel && !in_trash; bool has_clip = fm->clipboard_count > 0 && !filemanager_is_computer_view(fm) - && !filemanager_is_virtual_view(fm); + && !filemanager_is_virtual_view(fm) && !in_trash; Color dim_bg = Color::from_rgb(0xF0, 0xF0, 0xF0); // Icon toolbar buttons: Copy, Cut, Paste, Rename, New Folder, Delete struct ActionBtn { int x; SvgIcon* icon; bool enabled; }; ActionBtn action_btns[] = { { 188, ds ? &ds->icon_copy : nullptr, has_sel }, - { 216, ds ? &ds->icon_cut : nullptr, has_sel }, + { 216, ds ? &ds->icon_cut : nullptr, has_sel && !in_trash }, { 244, ds ? &ds->icon_paste : nullptr, has_clip }, { 272, ds ? &ds->icon_rename : nullptr, has_rename }, { 300, ds ? &ds->icon_folder_new : nullptr, - !filemanager_is_computer_view(fm) && !filemanager_is_virtual_view(fm) }, + !filemanager_is_computer_view(fm) && !filemanager_is_virtual_view(fm) && !in_trash }, { 328, ds ? &ds->icon_delete : nullptr, has_sel }, }; @@ -309,8 +311,17 @@ void filemanager_draw_header(Canvas& c, FileManagerState* fm, Color toolbar_colo } else { c.fill_rect(0, pathbar_y, c.w, FM_PATHBAR_H, Color::from_rgb(0xF0, 0xF0, 0xF0)); const char* pathbar_text = fm->current_path; + char trash_label[256]; if (filemanager_is_computer_view(fm)) pathbar_text = "Computer"; else if (filemanager_is_virtual_view(fm)) pathbar_text = filemanager_virtual_view_label(fm->virtual_view); + else if (in_trash) { + // "Trash", or "Trash/" inside a trashed folder. + char files[256]; + filemanager_trash_files_dir(ds, files, sizeof(files)); + snprintf(trash_label, sizeof(trash_label), "Trash%s", + fm->current_path + montauk::slen(files)); + pathbar_text = trash_label; + } c.text(8, pathbar_y + 4, pathbar_text, colors::TEXT_COLOR); } @@ -387,6 +398,9 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) { c.icon(icon_x, icon_y, ds->icon_apps_lg); } else if (ds && fm->entry_types[i] == FM_ENTRY_HOME && ds->icon_home_folder_lg.pixels) { c.icon(icon_x, icon_y, ds->icon_home_folder_lg); + } else if (ds && fm->entry_types[i] == FM_ENTRY_TRASH && ds->icon_trash_lg.pixels) { + c.icon(icon_x, icon_y, (fm->drive_indices[i] && ds->icon_trash_full_lg.pixels) + ? ds->icon_trash_full_lg : ds->icon_trash_lg); } else if (ds && fm->entry_types[i] == FM_ENTRY_DRIVE && ds->icon_drive_lg.pixels) { // Kind 4 = USB MSC (kernel BlockDeviceKind enum) const SvgIcon& di = (fm->drive_kinds[i] == 4 && ds->icon_drive_usb_lg.pixels) @@ -535,6 +549,9 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) { c.icon(ico_x, ico_y, ds->icon_apps); } else if (ds && fm->entry_types[i] == FM_ENTRY_HOME && ds->icon_home_folder.pixels) { c.icon(ico_x, ico_y, ds->icon_home_folder); + } else if (ds && fm->entry_types[i] == FM_ENTRY_TRASH && ds->icon_trash.pixels) { + c.icon(ico_x, ico_y, (fm->drive_indices[i] && ds->icon_trash_full.pixels) + ? ds->icon_trash_full : ds->icon_trash); } else if (ds && fm->entry_types[i] == FM_ENTRY_DRIVE && ds->icon_drive.pixels) { const SvgIcon& di = (fm->drive_kinds[i] == 4 && ds->icon_drive_usb.pixels) ? ds->icon_drive_usb : ds->icon_drive; @@ -663,7 +680,8 @@ void filemanager_on_draw(Window* win, Framebuffer& fb) { mtk::ContextMenuItem items[FM_CTX_MAX_ITEMS]; filemanager_build_context_items(fm, items, FM_CTX_MAX_ITEMS); mtk::draw_context_menu(c, filemanager_context_state(fm), items, - fm->ctx_item_count, theme, FM_CTX_MENU_W); + fm->ctx_item_count, theme, + filemanager_context_menu_width(items, fm->ctx_item_count)); } } diff --git a/programs/src/desktop/apps/filemanager/trash.cpp b/programs/src/desktop/apps/filemanager/trash.cpp new file mode 100644 index 0000000..5b07f0b --- /dev/null +++ b/programs/src/desktop/apps/filemanager/trash.cpp @@ -0,0 +1,246 @@ +/* + * trash.cpp + * Per-user Trash for the embedded desktop file manager + * Copyright (c) 2026 Daniel Hammer +*/ + +#include "filemanager_internal.hpp" +#include + +namespace filemanager { + +// Layout, modelled on the freedesktop.org trash: +// /.trash/files/ the trashed item itself +// /.trash/info/.trashinfo TOML: [trash] path = "" +// Items are moved in with rename(), so only items on the home volume can be +// trashed; anything else is deleted permanently after a confirmation. + +static constexpr const char* TRASH_INFO_SUFFIX = ".trashinfo"; + +static bool trash_root(const DesktopState* ds, char* out, int out_max) { + if (!ds || !ds->home_dir[0]) return false; + filemanager_build_fullpath(out, out_max, ds->home_dir, ".trash"); + return true; +} + +bool filemanager_trash_files_dir(const DesktopState* ds, char* out, int out_max) { + char root[256]; + if (!trash_root(ds, root, sizeof(root))) return false; + filemanager_build_fullpath(out, out_max, root, "files"); + return true; +} + +static bool trash_info_dir(const DesktopState* ds, char* out, int out_max) { + char root[256]; + if (!trash_root(ds, root, sizeof(root))) return false; + filemanager_build_fullpath(out, out_max, root, "info"); + return true; +} + +bool filemanager_trash_ensure(const DesktopState* ds) { + char path[256]; + if (!trash_root(ds, path, sizeof(path))) return false; + montauk::fmkdir(path); + if (!filemanager_trash_files_dir(ds, path, sizeof(path))) return false; + montauk::fmkdir(path); + if (!trash_info_dir(ds, path, sizeof(path))) return false; + montauk::fmkdir(path); + return filemanager_path_is_dir(path); +} + +// Same leading-path test as fileop's, without trailing-slash surprises. +static bool trash_path_within(const char* path, const char* root) { + int n = montauk::slen(root); + while (n > 0 && root[n - 1] == '/') n--; + if (n <= 0) return false; + for (int i = 0; i < n; i++) { + if (path[i] != root[i]) return false; + } + return path[n] == '\0' || path[n] == '/'; +} + +bool filemanager_is_trash_path(const DesktopState* ds, const char* path) { + char files[256]; + return path && filemanager_trash_files_dir(ds, files, sizeof(files)) + && trash_path_within(path, files); +} + +bool filemanager_is_trash_view(const FileManagerState* fm) { + char files[256]; + if (!fm || filemanager_is_virtual_view(fm) || filemanager_is_computer_view(fm)) return false; + if (!filemanager_trash_files_dir(fm->desktop, files, sizeof(files))) return false; + int n = montauk::slen(files); + int m = montauk::slen(fm->current_path); + while (m > n && fm->current_path[m - 1] == '/') m--; + if (m != n) return false; + for (int i = 0; i < n; i++) { + if (fm->current_path[i] != files[i]) return false; + } + return true; +} + +bool filemanager_in_trash(const FileManagerState* fm) { + return fm && !filemanager_is_virtual_view(fm) && !filemanager_is_computer_view(fm) + && filemanager_is_trash_path(fm->desktop, fm->current_path); +} + +bool filemanager_trash_has_items(const DesktopState* ds) { + char files[256]; + if (!filemanager_trash_files_dir(ds, files, sizeof(files))) return false; + const char* names[1]; + return montauk::readdir_at(files, names, 1, 0) > 0; +} + +// "N:" prefix comparison: rename() only works within one volume. +static bool trash_same_volume(const char* a, const char* b) { + for (int i = 0; a[i] && b[i]; i++) { + if (a[i] != b[i]) return false; + if (a[i] == ':') return true; + } + return false; +} + +bool filemanager_can_trash(const FileManagerState* fm) { + if (!fm || !fm->desktop || !fm->desktop->home_dir[0]) return false; + if (filemanager_is_virtual_view(fm) || filemanager_is_computer_view(fm)) return false; + if (filemanager_in_trash(fm)) return false; + return trash_same_volume(fm->current_path, fm->desktop->home_dir); +} + +static void trash_info_path_for(const char* info_dir, const char* trashed_name, + char* out, int out_max) { + char name[160]; + snprintf(name, sizeof(name), "%s%s", trashed_name, TRASH_INFO_SUFFIX); + filemanager_build_fullpath(out, out_max, info_dir, name); +} + +// Info file of an item directly inside a .../.trash/files directory; false +// for anything else (including entries nested deeper in a trashed folder). +static bool trash_info_path_of(const char* trashed, char* out, int out_max) { + int len = montauk::slen(trashed); + int slash = len - 1; + while (slash > 0 && trashed[slash] != '/') slash--; + if (slash <= 0) return false; + + static constexpr const char* FILES_TAIL = "/.trash/files"; + int tail_len = montauk::slen(FILES_TAIL); + if (slash < tail_len) return false; + for (int i = 0; i < tail_len; i++) { + if (trashed[slash - tail_len + i] != FILES_TAIL[i]) return false; + } + + char info_dir[512]; + int root_len = slash - tail_len + (int)montauk::slen("/.trash"); + if (root_len >= (int)sizeof(info_dir) - 6) return false; + for (int i = 0; i < root_len; i++) info_dir[i] = trashed[i]; + info_dir[root_len] = '\0'; + str_append(info_dir, "/info", sizeof(info_dir)); + + trash_info_path_for(info_dir, trashed + slash + 1, out, out_max); + return true; +} + +bool filemanager_trash_original_path(const char* trashed, char* out, int out_max) { + char info[512]; + if (!out || out_max <= 0 || !trash_info_path_of(trashed, info, sizeof(info))) return false; + auto doc = montauk::config::load_path(info); + const char* orig = doc.get_string("trash.path", ""); + bool ok = orig[0] != '\0'; + if (ok) montauk::strncpy(out, orig, out_max); + doc.destroy(); + return ok; +} + +void filemanager_trash_forget(const char* trashed) { + char info[512]; + if (trash_info_path_of(trashed, info, sizeof(info))) montauk::fdelete(info); +} + +montauk::StatusCode filemanager_trash_item(const DesktopState* ds, const char* path) { + char files[256], info_dir[256]; + if (!path || !path[0] || !filemanager_trash_files_dir(ds, files, sizeof(files)) || + !trash_info_dir(ds, info_dir, sizeof(info_dir))) + return montauk::InvalidArgument; + if (!trash_same_volume(path, files)) return montauk::UnsupportedOp; + // Trashing the trash (or the home folder holding it) would move it into + // itself. + if (trash_path_within(files, path)) return montauk::InvalidArgument; + if (!filemanager_trash_ensure(ds)) return montauk::IoFailure; + + const char* base = path_basename(path); + char dest[512]; + filemanager_build_fullpath(dest, sizeof(dest), files, base); + if (filemanager_path_exists(dest)) + filemanager_unique_path(files, base, filemanager_path_is_dir(path), false, + dest, sizeof(dest)); + + // Write the info file first: an item in files/ without one could not be + // restored. An orphaned info file under the same name is simply replaced. + char info[512]; + trash_info_path_for(info_dir, path_basename(dest), info, sizeof(info)); + montauk::toml::Doc doc; + doc.init(); + montauk::config::set_string(&doc, "trash.path", path); + int rc = montauk::config::save_path(info, &doc); + doc.destroy(); + if (rc < 0) return montauk::IoFailure; + + montauk::StatusCode status = filemanager_status_from_result(montauk::frename(path, dest)); + if (status != montauk::Success) montauk::fdelete(info); + return status; +} + +// mkdir -p for the parent of path, so a restore can recreate a deleted +// original folder. +static void trash_make_parents(const char* path) { + char buf[512]; + montauk::strncpy(buf, path, sizeof(buf)); + int len = montauk::slen(buf); + int start = 0; + for (int i = 0; i + 1 < len; i++) { + if (buf[i] == ':' && buf[i + 1] == '/') { start = i + 2; break; } + } + int last = len - 1; + while (last > 0 && buf[last] != '/') last--; + for (int i = start; i <= last; i++) { + if (buf[i] != '/') continue; + buf[i] = '\0'; + if (i > start) montauk::fmkdir(buf); + buf[i] = '/'; + } +} + +montauk::StatusCode filemanager_trash_restore(const char* trashed, char* restored, int restored_max) { + char orig[512]; + if (!filemanager_trash_original_path(trashed, orig, sizeof(orig))) return montauk::NoSuchPath; + + trash_make_parents(orig); + + // Something new took the original name meanwhile: restore beside it. + char target[512]; + montauk::strncpy(target, orig, sizeof(target)); + if (filemanager_path_exists(target)) { + char dir[512]; + montauk::strncpy(dir, orig, sizeof(dir)); + int cut = montauk::slen(dir); + while (cut > 0 && dir[cut - 1] != '/') cut--; + dir[cut > 0 ? cut - 1 : 0] = '\0'; + filemanager_unique_path(dir, path_basename(orig), filemanager_path_is_dir(trashed), + false, target, sizeof(target)); + } + + montauk::StatusCode status = filemanager_status_from_result(montauk::frename(trashed, target)); + if (status == montauk::Success) { + filemanager_trash_forget(trashed); + if (restored && restored_max > 0) montauk::strncpy(restored, target, restored_max); + } + return status; +} + +montauk::StatusCode filemanager_delete_item(const char* path) { + montauk::StatusCode status = filemanager_delete_recursive(path); + if (status == montauk::Success) filemanager_trash_forget(path); + return status; +} + +} // namespace filemanager diff --git a/scripts/copy_icons.sh b/scripts/copy_icons.sh index 6e9ce8d..d6baab1 100755 --- a/scripts/copy_icons.sh +++ b/scripts/copy_icons.sh @@ -163,6 +163,8 @@ ICONS=( "apps/scalable/sleep.svg" "apps/scalable/kolourpaint.svg" # paint app "apps/scalable/dialog-error.svg" # Files error popup + "apps/scalable/user-trash.svg" # Files Trash (empty) + "apps/scalable/user-trash-full.svg" # Files Trash (has items) "panel/sensors-temperature-symbolic.svg" # temperature panel icon "actions/symbolic/view-paged-symbolic.svg" # page view toggle (word processor) )