feat: Files app - add Trash

This commit is contained in:
daniel committed 2026-10-24 08:20:18 +02:00
1 parent e047c1e0c0
commit 07191b1286
15 files changed
+1039 -145

No files matched your search

+1 -1
View File
@@ -12,4 +12,4 @@
#pragma once #pragma once
#define MONTAUK_BUILD_NUMBER 5 #define MONTAUK_BUILD_NUMBER 7
+4
View File
@@ -157,6 +157,10 @@ struct DesktopState {
SvgIcon icon_folder_new; SvgIcon icon_folder_new;
SvgIcon icon_home_folder; SvgIcon icon_home_folder;
SvgIcon icon_home_folder_lg; 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;
SvgIcon icon_apps_lg; SvgIcon icon_apps_lg;
SvgIcon icon_system_configuration_menu; SvgIcon icon_system_configuration_menu;
+32
View File
@@ -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 // Resolve a fill value that might be url(#id) against gradient table
// Returns: 1 = resolved, 0 = not a url() ref, -1 = fill="none" // 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) { inline int svg_resolve_fill_value(const char* val, Color* out_color, const SvgGradientTable* grads) {
if (svg_strncmp(val, "none", 4)) return -1; if (svg_strncmp(val, "none", 4)) return -1;
if (*val == '#') { *out_color = svg_parse_hex_color(val); 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)) { if (grads && svg_strncmp(val, "url(#", 5)) {
// Extract id from url(#id) // Extract id from url(#id)
const char* id_start = val + 5; const char* id_start = val + 5;
+180 -54
View File
@@ -38,10 +38,13 @@ void filemanager_do_copy(FileManagerState* fm) {
} }
void filemanager_do_cut(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); filemanager_load_clipboard(fm, true);
} }
void filemanager_do_paste(FileManagerState* fm) { 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 // 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). // file pastes inline). Same-volume moves use rename (instant, no copy).
filemanager_start_paste_job(fm); 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. // only the focus and renaming it would be a surprise.
if (fm->multi_count > 1) return; if (fm->multi_count > 1) return;
if (filemanager_is_computer_view(fm) || filemanager_is_virtual_view(fm) || 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_active = true;
fm->rename_idx = fm->selected; fm->rename_idx = fm->selected;
@@ -106,6 +109,7 @@ void filemanager_cancel_rename(FileManagerState* fm) {
void filemanager_new_folder(FileManagerState* fm) { void filemanager_new_folder(FileManagerState* fm) {
if (filemanager_is_computer_view(fm) || filemanager_is_virtual_view(fm)) return; if (filemanager_is_computer_view(fm) || filemanager_is_virtual_view(fm)) return;
if (filemanager_in_trash(fm)) return;
// Find a unique name // Find a unique name
char name[64] = "New Folder"; char name[64] = "New Folder";
@@ -140,77 +144,179 @@ void filemanager_new_folder(FileManagerState* fm) {
} }
} }
void filemanager_delete_selected(FileManagerState* fm) { // Selected entries other than drives, as indices. Caller frees *out.
if (filemanager_is_computer_view(fm) || filemanager_is_virtual_view(fm)) return; static int filemanager_collect_deletable(FileManagerState* fm, int** out, bool* any_dir) {
if (fm->entry_count <= 0) return; *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)); 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); 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; int fn = 0;
bool any_dir = false;
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
int idx = sel[i]; int idx = sel[i];
if (fm->entry_types[idx] == FM_ENTRY_DRIVE) continue; if (fm->entry_types[idx] == FM_ENTRY_DRIVE) continue;
filtered[fn++] = idx; sel[fn++] = idx;
if (fm->is_dir[idx]) any_dir = true; if (any_dir && fm->is_dir[idx]) *any_dir = true;
}
if (fn == 0) { montauk::mfree(sel); return 0; }
*out = sel;
return fn;
} }
montauk::mfree(sel);
if (fn == 0) { montauk::mfree(filtered); return; }
// Anything with a directory or multi-item selection: confirm first. // Up to this many items are trashed inline (each is a rename plus a small
if (any_dir || fn > 1) { // info file); more go to the worker with a progress dialog.
if (fn == 1 && any_dir) { static constexpr int FM_TRASH_INLINE_MAX = 32;
filemanager_show_directory_delete_confirmation(fm, filtered[0]);
} else { static void filemanager_trash_selected(FileManagerState* fm, const int* indices, int count) {
filemanager_show_bulk_delete_confirmation(fm, filtered, fn); 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); if (started || fm->active_fileop) {
// Started, or another operation owns the worker (its dialog says so).
montauk::mfree(paths);
return; 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);
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); int failed = 0;
montauk::StatusCode status = filemanager_status_from_result(montauk::fdelete(fullpath)); 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_clear_multi_selection(fm);
filemanager_read_dir(fm); filemanager_read_dir(fm);
if (status != montauk::Success) { if (failed > 0) {
char heading[192]; char heading[192];
snprintf(heading, sizeof(heading), "Couldn't delete \"%s\"", path_basename(fullpath)); if (failed == 1)
filemanager_show_error(fm->desktop, fm, heading, fullpath, status); 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) { 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) { if (target_idx >= 0 && target_idx < fm->entry_count) {
int tt = fm->entry_types[target_idx]; 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_DRIVE || tt == FM_ENTRY_HOME || tt == FM_ENTRY_APPS_ROOT
|| tt == FM_ENTRY_SPECIAL_DIR || tt == FM_ENTRY_SYSTEM_CONFIGURATION_ROOT)) || tt == FM_ENTRY_SPECIAL_DIR || tt == FM_ENTRY_SYSTEM_CONFIGURATION_ROOT))
|| (filemanager_is_virtual_view(fm) && tt == FM_ENTRY_VIRTUAL_ITEM)) { || (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; fm->ctx_items[fm->ctx_item_count++] = CTX_CUT;
if (!several) if (!several)
fm->ctx_items[fm->ctx_item_count++] = CTX_RENAME; 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_DELETE;
fm->ctx_items[fm->ctx_item_count++] = CTX_PROPERTIES; 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)) { if (filemanager_is_computer_view(fm)) {
fm->ctx_items[fm->ctx_item_count++] = CTX_REFRESH; fm->ctx_items[fm->ctx_item_count++] = CTX_REFRESH;
} else { } else {
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) if (fm->clipboard_count > 0)
fm->ctx_items[fm->ctx_item_count++] = CTX_PASTE; fm->ctx_items[fm->ctx_item_count++] = CTX_PASTE;
fm->ctx_items[fm->ctx_item_count++] = CTX_NEW_FOLDER; fm->ctx_items[fm->ctx_item_count++] = CTX_NEW_FOLDER;
}
if (fm->entry_count > 0) if (fm->entry_count > 0)
fm->ctx_items[fm->ctx_item_count++] = CTX_SELECT_ALL; fm->ctx_items[fm->ctx_item_count++] = CTX_SELECT_ALL;
fm->ctx_items[fm->ctx_item_count++] = CTX_SORT_NAME; fm->ctx_items[fm->ctx_item_count++] = CTX_SORT_NAME;
@@ -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_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 = 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_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 = 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_apps_lg = svg_load("0:/os/icons/folder-blue-development.svg", 48, 48, defColor);
ds->icon_system_configuration_menu = ds->icon_system_configuration_menu =
@@ -78,7 +78,8 @@ void filemanager_on_mouse(Window* win, MouseEvent& ev) {
local_x, local_y, local_x, local_y,
ev.buttons, ev.prev_buttons, ev.buttons, ev.prev_buttons,
cw, win->content_h, cw, win->content_h,
FM_CTX_MENU_W); filemanager_context_menu_width(
items, fm->ctx_item_count));
filemanager_store_context_state(fm, state); filemanager_store_context_state(fm, state);
if (action >= 0) { 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_CUT: filemanager_do_cut(fm); break;
case CTX_PASTE: filemanager_do_paste(fm); break; case CTX_PASTE: filemanager_do_paste(fm); break;
case CTX_RENAME: filemanager_start_rename(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_NEW_FOLDER: filemanager_new_folder(fm); break;
case CTX_PROPERTIES: filemanager_show_selection_properties(fm, target); break; case CTX_PROPERTIES: filemanager_show_selection_properties(fm, target); break;
case CTX_REFRESH: filemanager_refresh(fm); 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 >= 244 && local_x < 268) filemanager_do_paste(fm);
else if (local_x >= 272 && local_x < 296) filemanager_start_rename(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 >= 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; 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)) if (have_focus && filemanager_is_entry_selected(fm, cur))
filemanager_open_entry(fm, cur); filemanager_open_entry(fm, cur);
} else if (nav == 0x53) { } else if (nav == 0x53) {
// Delete key // Delete key: Move to Trash; Shift+Delete deletes permanently
filemanager_delete_selected(fm); filemanager_delete_selected(fm, key.shift);
} else if (key.alt && montauk::nav_scancode(key) == 0x4B) { } else if (key.alt && montauk::nav_scancode(key) == 0x4B) {
// Alt+Left: go back // Alt+Left: go back
filemanager_go_back(fm); filemanager_go_back(fm);
@@ -25,6 +25,7 @@ enum FileManagerEntryType : int {
FM_ENTRY_VIRTUAL_ITEM = 6, FM_ENTRY_VIRTUAL_ITEM = 6,
FM_ENTRY_SPECIAL_DIR = 7, FM_ENTRY_SPECIAL_DIR = 7,
FM_ENTRY_SYSTEM_CONFIGURATION_ROOT = 8, FM_ENTRY_SYSTEM_CONFIGURATION_ROOT = 8,
FM_ENTRY_TRASH = 9,
}; };
enum FileManagerVirtualViewKind : uint8_t { 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_CELL_H_WRAPPED = 92;
inline constexpr int FM_GRID_ICON = 48; inline constexpr int FM_GRID_ICON = 48;
inline constexpr int FM_GRID_PAD = 4; 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 // 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. // 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_SIZE = 12;
inline constexpr int CTX_SORT_TYPE = 13; inline constexpr int CTX_SORT_TYPE = 13;
inline constexpr int CTX_SORT_MODIFIED = 14; 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) { inline bool filemanager_is_virtual_view(const FileManagerState* fm) {
return fm && fm->virtual_view != FM_VIRTUAL_VIEW_NONE; 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); bool str_ends_with(const char* s, const char* suffix);
int detect_file_type(const char* name, bool is_dir); int detect_file_type(const char* name, bool is_dir);
// Lowercased extension after the last '.', without the dot ("" if none). // 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_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); 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 "<dir>/<name> (N)<.ext>" path, or with copy_style the
// "<name> (copy)" / "<name> (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); const char* path_basename(const char* path);
// Grow the per-entry parallel arrays to hold at least `needed` entries. // 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). // These return the first failure encountered (Success when everything worked).
montauk::StatusCode filemanager_delete_recursive(const char* path); 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_file(const char* src, const char* dst);
montauk::StatusCode filemanager_copy_dir_recursive(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_finish_rename(FileManagerState* fm);
void filemanager_cancel_rename(FileManagerState* fm); void filemanager_cancel_rename(FileManagerState* fm);
void filemanager_new_folder(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_open_ctx_menu(FileManagerState* fm, int local_x, int local_y, int target_idx);
void filemanager_close_ctx_menu(FileManagerState* fm); 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_directory_delete_confirmation(FileManagerState* fm, int target_idx);
void filemanager_show_bulk_delete_confirmation(FileManagerState* fm, void filemanager_show_bulk_delete_confirmation(FileManagerState* fm,
const int* indices, int count); 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 // Graphical error popup for a failed file operation. `heading` says what
// failed, `path` (optional) names the item, and a non-Success `status` adds // failed, `path` (optional) names the item, and a non-Success `status` adds
// montauk::GetStatusExplanationText() as the cause. `owner_app_data` is only // 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); void filemanager_open_entry(FileManagerState* fm, int idx);
// ---- Trash (trash.cpp) ----
// <home>/.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) ---- // ---- File associations (associations.cpp, openwith.cpp) ----
// Default app for a file: the user's "Always use" choice from // Default app for a file: the user's "Always use" choice from
// <home>/config/associations.toml, else the first app (by name) whose manifest // <home>/config/associations.toml, else the first app (by name) whose manifest
@@ -387,6 +445,7 @@ enum FileOpKind : int {
FILE_OP_COPY = 0, FILE_OP_COPY = 0,
FILE_OP_MOVE = 1, FILE_OP_MOVE = 1,
FILE_OP_DELETE = 2, FILE_OP_DELETE = 2,
FILE_OP_TRASH = 3,
}; };
// Start a background copy/move of clipboard contents into the current dir, // 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). // Returns true if a job was started (caller should not also do it inline).
bool filemanager_start_paste_job(FileManagerState* fm); bool filemanager_start_paste_job(FileManagerState* fm);
bool filemanager_start_delete_job(FileManagerState* fm, const char* const* paths, int count); 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_height(FileManagerState* fm, int row, int cols);
int filemanager_grid_row_y(FileManagerState* fm, int row, int cols); int filemanager_grid_row_y(FileManagerState* fm, int row, int cols);
+226 -30
View File
@@ -17,6 +17,14 @@ namespace filemanager {
// Anything larger -- or any directory / multi-item operation -- runs async. // Anything larger -- or any directory / multi-item operation -- runs async.
static constexpr uint64_t FILEOP_INLINE_MAX_BYTES = 16ull * 1024 * 1024; 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 { struct FileOpJob {
int kind; // FileOpKind: copy / move / delete int kind; // FileOpKind: copy / move / delete
char (*paths)[256]; // owned: source paths (copy/move) or targets (delete) char (*paths)[256]; // owned: source paths (copy/move) or targets (delete)
@@ -39,8 +47,21 @@ struct FileOpJob {
montauk::StatusCode first_error; montauk::StatusCode first_error;
char first_error_path[256]; 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 tid;
int last_drawn_done; // dialog redraw tracking int last_drawn_done; // dialog redraw tracking
int last_drawn_pending;
int mouse_x, mouse_y; int mouse_x, mouse_y;
}; };
@@ -66,16 +87,12 @@ static void fileop_set_current(FileOpJob* job, const char* name) {
job->current[i] = '\0'; 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) { static const char* fileop_error_verb(int kind) {
switch (kind) { switch (kind) {
case FILE_OP_COPY: return "copy"; case FILE_OP_COPY: return "copy";
case FILE_OP_MOVE: return "move"; case FILE_OP_MOVE: return "move";
case FILE_OP_DELETE: return "delete"; case FILE_OP_DELETE: return "delete";
case FILE_OP_TRASH: return "move";
default: return "process"; default: return "process";
} }
} }
@@ -96,12 +113,13 @@ static void fileop_report_errors(FileOpJob* job) {
if (!job || job->failed_count <= 0) return; if (!job || job->failed_count <= 0) return;
char heading[192]; char heading[192];
const char* tail = job->kind == FILE_OP_TRASH ? " to the Trash" : "";
if (job->failed_count == 1) { if (job->failed_count == 1) {
snprintf(heading, sizeof(heading), "Couldn't %s \"%s\"", snprintf(heading, sizeof(heading), "Couldn't %s \"%s\"%s",
fileop_error_verb(job->kind), path_basename(job->first_error_path)); fileop_error_verb(job->kind), path_basename(job->first_error_path), tail);
} else { } else {
snprintf(heading, sizeof(heading), "Couldn't %s %d of %d items", snprintf(heading, sizeof(heading), "Couldn't %s %d of %d items%s",
fileop_error_verb(job->kind), job->failed_count, job->count); fileop_error_verb(job->kind), job->failed_count, job->count, tail);
} }
filemanager_show_error(job->desktop, job->owner, heading, filemanager_show_error(job->desktop, job->owner, heading,
job->first_error_path, job->first_error); job->first_error_path, job->first_error);
@@ -111,6 +129,36 @@ static void fileop_report_errors(FileOpJob* job) {
// Worker thread // 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) { static int fileop_worker(void* arg) {
FileOpJob* job = (FileOpJob*)arg; FileOpJob* job = (FileOpJob*)arg;
@@ -120,27 +168,44 @@ static int fileop_worker(void* arg) {
fileop_set_current(job, base); fileop_set_current(job, base);
if (job->kind == FILE_OP_DELETE) { 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; job->files_done = i + 1;
continue; continue;
} }
// Copy or move into dest_dir. // Copy or move into dest_dir.
char dst[512]; bool is_dir = filemanager_path_is_dir(src);
filemanager_build_fullpath(dst, 512, job->dest_dir, base); if (is_dir && fileop_path_same_or_descendant(job->dest_dir, src)) {
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)) {
// Refuse to copy a directory into itself. // Refuse to copy a directory into itself.
fileop_record_status(job, src, montauk::InvalidArgument); fileop_record_status(job, src, montauk::InvalidArgument);
job->files_done = i + 1; job->files_done = i + 1;
continue; 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; montauk::StatusCode status;
if (job->kind == FILE_OP_MOVE) { if (job->kind == FILE_OP_MOVE) {
// Try a rename first: an instant same-volume move with no copying. // 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)); status = filemanager_status_from_result(montauk::frename(src, dst));
if (status != montauk::Success && !filemanager_status_is_access_denied(status)) { if (status != montauk::Success && !filemanager_status_is_access_denied(status)) {
// Cross-volume (or driver without rename): fall back to copy + delete. // 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_COPY: return "Copying";
case FILE_OP_MOVE: return "Moving"; case FILE_OP_MOVE: return "Moving";
case FILE_OP_DELETE: return "Deleting"; case FILE_OP_DELETE: return "Deleting";
case FILE_OP_TRASH: return "Moving to Trash";
default: return "Working"; 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 }; *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) { static FileManagerState* fileop_live_owner(FileOpJob* job) {
if (!job || !job->desktop || !job->owner) return nullptr; if (!job || !job->desktop || !job->owner) return nullptr;
for (int i = 0; i < job->desktop->window_count; i++) { 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); mtk::Theme theme = mtk::make_theme(job->accent);
c.fill(theme.window_bg); c.fill(theme.window_bg);
if (fileop_conflict_pending(job)) {
fileop_draw_conflict(c, job, theme);
return;
}
int pad = 16; int pad = 16;
int fh = system_font_height(); int fh = system_font_height();
int y = 18; int y = 18;
@@ -234,9 +382,7 @@ static void fileop_dialog_on_draw(Window* win, Framebuffer& fb) {
cur[k] = '\0'; cur[k] = '\0';
if (cur[0]) { if (cur[0]) {
char fit[128]; char fit[128];
// Trim to width using the shared helper if available; otherwise show raw. filemanager_fit_text(cur, fit, sizeof(fit), c.w - pad * 2);
montauk::strncpy(fit, cur, sizeof(fit) - 1);
fit[sizeof(fit) - 1] = '\0';
c.text(pad, y, fit, theme.text_muted); c.text(pad, y, fit, theme.text_muted);
} }
y += fh + 12; y += fh + 12;
@@ -272,6 +418,29 @@ static void fileop_dialog_on_mouse(Window* win, MouseEvent& ev) {
int mx = 0, my = 0; int mx = 0, my = 0;
if (!host.map_mouse(ev, &mx, &my)) return; 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; Rect cancel_btn;
fileop_cancel_button_rect(win->content_w, win->content_h, &cancel_btn); fileop_cancel_button_rect(win->content_w, win->content_h, &cancel_btn);
bool old_hover = cancel_btn.contains(job->mouse_x, job->mouse_y); bool 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) { static void fileop_dialog_on_key(Window* win, const montauk::abi::KeyEvent& key) {
FileOpJob* job = (FileOpJob*)win->app_data; FileOpJob* job = (FileOpJob*)win->app_data;
if (!job || !key.pressed) return; 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) { 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; job->last_drawn_done = job->files_done;
win->dirty = true; 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) { if (job->finished) {
// Hand off to on_close, which joins the worker, refreshes the file // 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 bar_bottom = 18 + (fh + 10) + (fh + 12) + 10; // mirrors on_draw layout
int btn_h = 28; int btn_h = 28;
int content_h = bar_bottom + 16 /*gap*/ + btn_h + 14 /*bottom margin*/; 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 h = content_h + gui::TITLEBAR_HEIGHT + gui::BORDER_WIDTH;
int wx = 0, wy = 0; int wx = 0, wy = 0;
props_center_dialog(ds, job->owner, w, h, &wx, &wy); props_center_dialog(ds, job->owner, w, h, &wx, &wy);
const char* dlg_title = (job->kind == FILE_OP_DELETE) ? "Deleting" const char* dlg_title = fileop_verb(job->kind);
: (job->kind == FILE_OP_MOVE) ? "Moving"
: "Copying";
int idx = desktop_create_window(ds, dlg_title, wx, wy, w, h); int idx = desktop_create_window(ds, dlg_title, wx, wy, w, h);
if (idx < 0) return false; if (idx < 0) return false;
@@ -435,17 +617,21 @@ bool filemanager_start_paste_job(FileManagerState* fm) {
bool is_cut = fm->clipboard_is_cut; bool is_cut = fm->clipboard_is_cut;
// Fast path: a single small plain file pastes inline so quick copies stay // Fast path: a single small plain file pastes inline so quick copies stay
// snappy (no thread, no dialog flash). // snappy (no thread, no dialog flash). A name conflict needs the prompt,
if (fm->clipboard_count == 1 && !fileop_path_is_dir(fm->clipboard_paths[0])) { // 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]; const char* src = fm->clipboard_paths[0];
uint64_t sz = 0; uint64_t sz = 0;
int fd = montauk::open(src); int fd = montauk::open(src);
if (fd >= 0) { sz = montauk::getsize(fd); montauk::close(fd); } if (fd >= 0) { sz = montauk::getsize(fd); montauk::close(fd); }
if (sz <= FILEOP_INLINE_MAX_BYTES) {
char dst[512]; char dst[512];
filemanager_build_fullpath(dst, 512, fm->current_path, path_basename(src)); 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; montauk::StatusCode status = montauk::Success;
if (!montauk::streq(dst, src)) { if (!(same && is_cut)) {
if (is_cut) { if (is_cut) {
status = filemanager_status_from_result(montauk::frename(src, dst)); status = filemanager_status_from_result(montauk::frename(src, dst));
if (status != montauk::Success && if (status != montauk::Success &&
@@ -491,12 +677,12 @@ bool filemanager_start_paste_job(FileManagerState* fm) {
return fileop_launch(job); 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) { const char* const* paths, int count) {
if (!fm || fm->active_fileop) return false; if (!fm || fm->active_fileop) return false;
if (!paths || count <= 0) 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; if (!job) return false;
for (int i = 0; i < count; i++) { for (int i = 0; i < count; i++) {
montauk::strncpy(job->paths[i], paths[i] ? paths[i] : "", 255); 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); 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 } // namespace filemanager
@@ -233,6 +233,12 @@ void filemanager_read_drives(FileManagerState* fm) {
// Settings entry // Settings entry
filemanager_add_root_entry(fm, "Settings", FM_ENTRY_SYSTEM_CONFIGURATION_ROOT, -1); 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 // Drive entries
int drives[FM_MAX_DRIVES]; int drives[FM_MAX_DRIVES];
int driveCount = montauk::drivelist(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) { montauk::StatusCode filemanager_copy_file(const char* src, const char* dst) {
if (!src || !dst || montauk::streq(src, dst)) return montauk::InvalidArgument; 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); int sfd = montauk::open(src);
if (sfd < 0) return filemanager_status_from_result(sfd); if (sfd < 0) return filemanager_status_from_result(sfd);
uint64_t size = montauk::getsize(sfd); uint64_t size = montauk::getsize(sfd);
// Create destination // fcreate() truncates an existing file, and a failed copy deletes what it
int dfd = montauk::fcreate(dst); // 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) { if (dfd < 0) {
montauk::close(sfd); montauk::close(sfd);
return filemanager_status_from_result(dfd); return filemanager_status_from_result(dfd);
@@ -629,7 +658,7 @@ montauk::StatusCode filemanager_copy_file(const char* src, const char* dst) {
if (!buf) { if (!buf) {
montauk::close(sfd); montauk::close(sfd);
montauk::close(dfd); montauk::close(dfd);
montauk::fdelete(dst); montauk::fdelete(target);
return montauk::OutOfMemory; return montauk::OutOfMemory;
} }
@@ -656,7 +685,10 @@ montauk::StatusCode filemanager_copy_file(const char* src, const char* dst) {
montauk::close(sfd); montauk::close(sfd);
montauk::close(dfd); 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; return status;
} }
@@ -127,7 +127,7 @@ const char* ctx_label(int action) {
case CTX_CUT: return "Cut"; case CTX_CUT: return "Cut";
case CTX_PASTE: return "Paste"; case CTX_PASTE: return "Paste";
case CTX_RENAME: return "Rename"; 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_NEW_FOLDER: return "New Folder";
case CTX_PROPERTIES: return "Properties"; case CTX_PROPERTIES: return "Properties";
case CTX_REFRESH: return "Refresh"; 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_SIZE: return "Sort by Size";
case CTX_SORT_TYPE: return "Sort by Type"; case CTX_SORT_TYPE: return "Sort by Type";
case CTX_SORT_MODIFIED: return "Sort by Modified"; 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 "?"; default: return "?";
} }
} }
@@ -160,7 +163,10 @@ const char* ctx_shortcut(const FileManagerState* fm, int action) {
case CTX_CUT: return "Ctrl+X"; case CTX_CUT: return "Ctrl+X";
case CTX_PASTE: return "Ctrl+V"; case CTX_PASTE: return "Ctrl+V";
case CTX_RENAME: return "F2"; 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_NEW_FOLDER: return "Ctrl+Shift+N";
case CTX_PROPERTIES: return "Alt+Enter"; case CTX_PROPERTIES: return "Alt+Enter";
case CTX_REFRESH: return "F5"; case CTX_REFRESH: return "F5";
@@ -172,12 +178,15 @@ const char* ctx_shortcut(const FileManagerState* fm, int action) {
int ctx_group(int action) { int ctx_group(int action) {
switch (action) { switch (action) {
case CTX_OPEN: case CTX_OPEN:
case CTX_OPEN_WITH: return 0; case CTX_OPEN_WITH:
case CTX_RESTORE: return 0;
case CTX_COPY: case CTX_COPY:
case CTX_CUT: case CTX_CUT:
case CTX_PASTE: return 1; case CTX_PASTE: return 1;
case CTX_RENAME: case CTX_RENAME:
case CTX_TRASH:
case CTX_DELETE: case CTX_DELETE:
case CTX_EMPTY_TRASH:
case CTX_NEW_FOLDER: return 2; case CTX_NEW_FOLDER: return 2;
case CTX_SELECT_ALL: return 3; case CTX_SELECT_ALL: return 3;
case CTX_SORT_NAME: 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_SYSTEM_CONFIGURATION_ROOT: fixed = "Settings"; break;
case FM_ENTRY_HOME: fixed = "Home"; break; case FM_ENTRY_HOME: fixed = "Home"; break;
case FM_ENTRY_DRIVE: fixed = "Drive"; break; case FM_ENTRY_DRIVE: fixed = "Drive"; break;
case FM_ENTRY_TRASH: fixed = "Trash"; break;
case FM_ENTRY_SPECIAL_DIR: case FM_ENTRY_SPECIAL_DIR:
case FM_ENTRY_DIR: fixed = "Folder"; break; case FM_ENTRY_DIR: fixed = "Folder"; break;
case FM_ENTRY_EXEC: fixed = "Program"; 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); 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) // Extract basename from a path (pointer into the path string)
const char* path_basename(const char* path) { const char* path_basename(const char* path) {
const char* last = path; const char* last = path;
@@ -72,7 +72,9 @@ void filemanager_navigate(FileManagerState* fm, const char* name) {
void filemanager_go_up(FileManagerState* fm) { void filemanager_go_up(FileManagerState* fm) {
if (filemanager_is_computer_view(fm)) return; 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_read_drives(fm);
filemanager_push_history(fm); filemanager_push_history(fm);
return; return;
@@ -132,6 +134,14 @@ void filemanager_go_forward(FileManagerState* fm) {
filemanager_navigate_to_history(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) { void filemanager_go_home(FileManagerState* fm) {
filemanager_read_drives(fm); filemanager_read_drives(fm);
filemanager_push_history(fm); filemanager_push_history(fm);
@@ -262,6 +272,11 @@ void filemanager_open_entry(FileManagerState* fm, int idx) {
return; 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) { if (filemanager_is_computer_view(fm) && fm->entry_types[idx] == FM_ENTRY_DRIVE) {
int d = fm->drive_indices[idx]; int d = fm->drive_indices[idx];
char dpath[8]; char dpath[8];
@@ -41,6 +41,8 @@ struct FileDeleteConfirmDialogState {
char name[128]; // display name (single item) or summary ("3 items") char name[128]; // display name (single item) or summary ("3 items")
char path[512]; // primary path (used for single-item subtitle) 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; SvgIcon icon;
// Multi-item targets (dynamically allocated). count == 1 keeps the original // 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'; 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) { static Rect props_close_button_rect(int content_w, int content_h) {
return {content_w - 96, content_h - 42, 80, 28}; 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); c.text(x, y, label, theme.text_muted);
char fit[512]; 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); c.text(x + label_w, y, fit, theme.text);
return y + fh + 8; 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); props_draw_icon(c, st, pad, pad + 2);
char title[128]; 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, title, theme.text);
c.text(80, pad + 4 + fh + 4, st->type, theme.text_muted); 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 // Subtitle wording, shared between the width calculation at creation time and
// the draw path so the two never drift out of sync. // the draw path so the two never drift out of sync.
static const char* const kDeleteSubMulti = 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 = 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 // 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 // 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); FileManagerState* owner = delete_confirm_live_owner(st);
int n = st->count > 0 ? st->count : (st->path[0] ? 1 : 0); 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 // 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. // list, so it stays valid after this confirm dialog is freed.
bool started = false; 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*)); const char** arr = (const char**)montauk::malloc((uint64_t)n * sizeof(const char*));
if (arr) { if (arr) {
for (int i = 0; i < n; i++) 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++) { for (int i = 0; i < n; i++) {
const char* p = (st->count > 0) ? st->paths[i] : st->path; const char* p = (st->count > 0) ? st->paths[i] : st->path;
if (!p || !p[0]) continue; if (!p || !p[0]) continue;
montauk::StatusCode status = filemanager_delete_recursive(p); montauk::StatusCode status = filemanager_delete_item(p);
if (status == montauk::Success) { if (status == montauk::Success) {
any = true; any = true;
} else if (failed++ == 0) { } else if (failed++ == 0) {
@@ -546,22 +544,26 @@ static void delete_confirm_on_draw(Window* win, Framebuffer& fb) {
} }
char title[160]; char title[160];
if (st->count > 1) if (st->title[0])
snprintf(title, sizeof(title), "Delete %d items?", st->count); 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 else
snprintf(title, sizeof(title), "Delete folder \"%s\"?", st->name); snprintf(title, sizeof(title), "Permanently delete \"%s\"?", st->name);
char title_fit[160]; 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); 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]; 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); c.text(80, 22 + fh + 8, sub_fit, theme.text_muted);
if (st->count <= 1) { if (st->count <= 1) {
char path_fit[512]; 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); 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]); props_copy(st->name, sizeof(st->name), fm->entry_names[target_idx]);
filemanager_build_fullpath(st->path, sizeof(st->path), filemanager_build_fullpath(st->path, sizeof(st->path),
fm->current_path, fm->entry_names[target_idx]); fm->current_path, fm->entry_names[target_idx]);
st->is_dir = true;
int sfi = -1; int sfi = -1;
if (fm->entry_types[target_idx] == FM_ENTRY_SPECIAL_DIR) 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]; int idx = indices[0];
props_copy(st->name, sizeof(st->name), fm->entry_names[idx]); props_copy(st->name, sizeof(st->name), fm->entry_names[idx]);
montauk::strncpy(st->path, st->paths[0], sizeof(st->path) - 1); montauk::strncpy(st->path, st->paths[0], sizeof(st->path) - 1);
st->is_dir = fm->is_dir[idx];
} else { } else {
snprintf(st->name, sizeof(st->name), "%d items", out); 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 h = 180;
int wx = 0; int wx = 0;
int wy = 0; int wy = 0;
props_center_dialog(ds, fm, w, h, &wx, &wy); 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) { 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); montauk::mfree(st);
return; 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 (!src[pos]) break;
if (count == max_lines - 1) { 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; 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; if (n > 159) n = 159;
montauk::memcpy(buf, src + pos, n); montauk::memcpy(buf, src + pos, n);
buf[n] = '\0'; buf[n] = '\0';
props_fit_text(buf, lines[count++], 160, max_w); filemanager_fit_text(buf, lines[count++], 160, max_w);
pos = word_end; pos = word_end;
} else { } else {
int n = best - pos; int n = best - pos;
@@ -877,11 +942,11 @@ static void error_on_draw(Window* win, Framebuffer& fb) {
} }
char fit[192]; 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); c.text(kErrorTextX, kErrorTop, fit, theme.text);
if (st->path[0]) { 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); c.text(kErrorTextX, error_path_y(fh), fit, theme.text_muted);
} }
@@ -257,21 +257,23 @@ void filemanager_draw_header(Canvas& c, FileManagerState* fm, Color toolbar_colo
bool multi_sel = fm->multi_count > 0 bool multi_sel = fm->multi_count > 0
&& !filemanager_is_computer_view(fm) && !filemanager_is_virtual_view(fm); && !filemanager_is_computer_view(fm) && !filemanager_is_virtual_view(fm);
bool has_sel = single_sel || multi_sel; 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. // 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) 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); Color dim_bg = Color::from_rgb(0xF0, 0xF0, 0xF0);
// Icon toolbar buttons: Copy, Cut, Paste, Rename, New Folder, Delete // Icon toolbar buttons: Copy, Cut, Paste, Rename, New Folder, Delete
struct ActionBtn { int x; SvgIcon* icon; bool enabled; }; struct ActionBtn { int x; SvgIcon* icon; bool enabled; };
ActionBtn action_btns[] = { ActionBtn action_btns[] = {
{ 188, ds ? &ds->icon_copy : nullptr, has_sel }, { 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 }, { 244, ds ? &ds->icon_paste : nullptr, has_clip },
{ 272, ds ? &ds->icon_rename : nullptr, has_rename }, { 272, ds ? &ds->icon_rename : nullptr, has_rename },
{ 300, ds ? &ds->icon_folder_new : nullptr, { 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 }, { 328, ds ? &ds->icon_delete : nullptr, has_sel },
}; };
@@ -309,8 +311,17 @@ void filemanager_draw_header(Canvas& c, FileManagerState* fm, Color toolbar_colo
} else { } else {
c.fill_rect(0, pathbar_y, c.w, FM_PATHBAR_H, Color::from_rgb(0xF0, 0xF0, 0xF0)); c.fill_rect(0, pathbar_y, c.w, FM_PATHBAR_H, Color::from_rgb(0xF0, 0xF0, 0xF0));
const char* pathbar_text = fm->current_path; const char* pathbar_text = fm->current_path;
char trash_label[256];
if (filemanager_is_computer_view(fm)) pathbar_text = "Computer"; 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 (filemanager_is_virtual_view(fm)) pathbar_text = filemanager_virtual_view_label(fm->virtual_view);
else if (in_trash) {
// "Trash", or "Trash/<folder>" 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); 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); 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) { } 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); 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) { } else if (ds && fm->entry_types[i] == FM_ENTRY_DRIVE && ds->icon_drive_lg.pixels) {
// Kind 4 = USB MSC (kernel BlockDeviceKind enum) // Kind 4 = USB MSC (kernel BlockDeviceKind enum)
const SvgIcon& di = (fm->drive_kinds[i] == 4 && ds->icon_drive_usb_lg.pixels) 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); c.icon(ico_x, ico_y, ds->icon_apps);
} else if (ds && fm->entry_types[i] == FM_ENTRY_HOME && ds->icon_home_folder.pixels) { } 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); 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) { } 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) const SvgIcon& di = (fm->drive_kinds[i] == 4 && ds->icon_drive_usb.pixels)
? ds->icon_drive_usb : ds->icon_drive; ? 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]; mtk::ContextMenuItem items[FM_CTX_MAX_ITEMS];
filemanager_build_context_items(fm, 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, 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));
} }
} }
@@ -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 <montauk/config.h>
namespace filemanager {
// Layout, modelled on the freedesktop.org trash:
// <home>/.trash/files/<name> the trashed item itself
// <home>/.trash/info/<name>.trashinfo TOML: [trash] path = "<original>"
// 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
+2
View File
@@ -163,6 +163,8 @@ ICONS=(
"apps/scalable/sleep.svg" "apps/scalable/sleep.svg"
"apps/scalable/kolourpaint.svg" # paint app "apps/scalable/kolourpaint.svg" # paint app
"apps/scalable/dialog-error.svg" # Files error popup "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 "panel/sensors-temperature-symbolic.svg" # temperature panel icon
"actions/symbolic/view-paged-symbolic.svg" # page view toggle (word processor) "actions/symbolic/view-paged-symbolic.svg" # page view toggle (word processor)
) )