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)
)