git.lucas.co / cce-files
file manager
git clone https://git.lucas.co/cce-files.git

commitc6b43ba701beec8c5e542b2d4ec22f164f7ff866
parent7726758e40
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 14:56
Add New Folder to the browse context menu

Right-clicking a row (outside the trash) now offers New Folder, and
right-clicking the empty space below the rows opens a directory menu
with the same entry. FsRequest::CreateDir makes "New Folder" (or
"New Folder N" when taken) off-thread; the list then refreshes and
selects the new folder.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/main.rs         | 41 +++++++++++++++++++++++++++++------------
 src/pages/browse.rs | 25 ++++++++++++++++++++++++-
 src/row_list.rs     |  4 +++-
 src/services/fs.rs  | 43 +++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 99 insertions(+), 14 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index b276d86..28077d7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -490,6 +490,13 @@ impl FilesystemApp {
         self.fs_service.send(services::fs::FsRequest::ScanTree(dir, cancel));
     }
 
+    /// Show the app's context menu at (x, y). Row 0 of `options` is the
+    /// non-interactive header.
+    fn open_context_menu(&mut self, x: f32, y: f32, options: Vec<(String, Option<Message>)>) {
+        let (w, h) = context_menu_size(&options);
+        self.context_menu = ContextMenu { visible: true, x, y, w, h, options, hovered: None };
+    }
+
     fn start_watching(&mut self, path: std::path::PathBuf) {
         use tokio::sync::mpsc;
         use std::time::Duration;
@@ -2145,27 +2152,37 @@ impl Application for FilesystemApp {
                             options.push(("Empty Trash".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::EmptyTrash))));
                         } else {
                             options.push(("Delete".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::DeleteEntry(idx)))));
+                            options.push(("New Folder".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NewFolder))));
                             if let Some(trash_files) = services::trash::files_dir() {
                                 options.push(("Open Trash".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NavigateToPath(trash_files)))));
                             }
                         }
 
-                        // Calculate width
-                        let (menu_w, menu_h) = context_menu_size(&options);
-
-                        self.context_menu = ContextMenu {
-                            visible: true,
-                            x: pos.x,
-                            y: pos.y,
-                            w: menu_w,
-                            h: menu_h,
-                            options,
-                            hovered: None,
-                        };
+                        self.open_context_menu(pos.x, pos.y, options);
                         *needs_rebuild = true;
                         self.needs_rebuild = true;
                         return None;
                     }
+                } else if self.current_page == Page::Browse
+                    && self.browse.list.hit(pos.x, pos.y)
+                    && pos.y <= self.browse.list.y + self.browse.list.viewport_h
+                    && !services::trash::is_trash_files_dir(&self.browse.current_dir)
+                {
+                    // Empty space below the rows: a menu for the directory itself.
+                    let name = self
+                        .browse
+                        .current_dir
+                        .file_name()
+                        .map(|n| n.to_string_lossy().into_owned())
+                        .unwrap_or_else(|| "/".to_string());
+                    let options = vec![
+                        (format!("[Directory] {}", name), None),
+                        ("New Folder".to_string(), Some(Message::Browse(pages::browse::BrowseMessage::NewFolder))),
+                    ];
+                    self.open_context_menu(pos.x, pos.y, options);
+                    *needs_rebuild = true;
+                    self.needs_rebuild = true;
+                    return None;
                 }
             }
         }
diff --git a/src/pages/browse.rs b/src/pages/browse.rs
index 8741ec5..dd189fe 100644
--- a/src/pages/browse.rs
+++ b/src/pages/browse.rs
@@ -35,6 +35,8 @@ pub struct BrowseState {
     /// the chooser is always, since row 0 starts selected. Auto-scroll fires
     /// on selection CHANGE only.
     pub autoscrolled_to: Option<usize>,
+    /// A just-created folder to select once a refresh lists it.
+    pub pending_select: Option<PathBuf>,
     pub breadcrumb: Adapted<Breadcrumb>,
     pub save_name_box: cce_ui::widget::Adapted<cce_ui::widget::TextBox>,
 }
@@ -57,6 +59,7 @@ impl Default for BrowseState {
                 .with_update_on_type(true),
             selected: None,
             autoscrolled_to: None,
+            pending_select: None,
             breadcrumb,
             save_name_box: cce_ui::widget::TextBox::new(String::new()).with_max_width(None),
         };
@@ -99,6 +102,10 @@ pub enum BrowseMessage {
     DeleteEntryPermanent(usize),
     RestoreEntry(usize),
     EmptyTrash,
+    /// Create a new folder in the current directory.
+    NewFolder,
+    /// (parent dir, the created folder or the error).
+    FolderCreated(PathBuf, Result<PathBuf, String>),
     Deleted(PathBuf, Result<(), String>),
     TrashEmptied(Result<(), String>),
     LastDirLoaded(Option<PathBuf>),
@@ -420,6 +427,7 @@ pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::serv
         BrowseMessage::DirectoryLoaded(path, entries) => {
             state.current_dir = path.clone();
             state.all_entries = entries;
+            state.pending_select = None;
             state.search_box.text.clear();
             state.search_box.edit_buffer.clear();
             state.search_visible = false;
@@ -429,7 +437,10 @@ pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::serv
         }
         BrowseMessage::DirectoryRefreshed(path, entries) => {
             if state.current_dir == path {
-                let selected_path = state.selected.and_then(|idx| state.entries.get(idx).map(|e| e.path.clone()));
+                let selected_path = state
+                    .pending_select
+                    .take()
+                    .or_else(|| state.selected.and_then(|idx| state.entries.get(idx).map(|e| e.path.clone())));
                 state.all_entries = entries;
                 apply_filters(state);
                 if let Some(path) = selected_path {
@@ -469,6 +480,18 @@ pub fn update(state: &mut BrowseState, msg: BrowseMessage) -> Option<crate::serv
             }
         }
         BrowseMessage::EmptyTrash => Some(crate::services::fs::FsRequest::EmptyTrash),
+        BrowseMessage::NewFolder => Some(crate::services::fs::FsRequest::CreateDir(state.current_dir.clone())),
+        BrowseMessage::FolderCreated(parent, result) => {
+            match result {
+                Ok(path) => state.pending_select = Some(path),
+                Err(e) => log::error!("Failed to create folder in {}: {}", parent.display(), e),
+            }
+            if state.current_dir == parent {
+                Some(crate::services::fs::FsRequest::RefreshDirectory(parent))
+            } else {
+                None
+            }
+        }
         BrowseMessage::TrashEmptied(result) => {
             if let Err(e) = result {
                 log::error!("Failed to empty trash: {}", e);
diff --git a/src/row_list.rs b/src/row_list.rs
index b9221a8..3afc70f 100644
--- a/src/row_list.rs
+++ b/src/row_list.rs
@@ -134,7 +134,9 @@ impl RowList {
         }
     }
 
-    fn hit(&self, px: f32, py: f32) -> bool {
+    /// Whether (px, py) is inside the list's full rect — public for the app's
+    /// right-click menu on the empty space below the rows.
+    pub fn hit(&self, px: f32, py: f32) -> bool {
         px >= self.x && px < self.x + self.w && py >= self.y && py < self.y + self.h
     }
 
diff --git a/src/services/fs.rs b/src/services/fs.rs
index c8d61a6..a0adc9e 100644
--- a/src/services/fs.rs
+++ b/src/services/fs.rs
@@ -40,6 +40,8 @@ pub enum FsRequest {
     /// Restore a trashed item (a path under Trash/files) to its origin.
     RestorePath(PathBuf),
     EmptyTrash,
+    /// Create a new, uniquely named folder inside the given directory.
+    CreateDir(PathBuf),
     /// Walk a whole subtree for the Space view. The flag is the caller's
     /// cancel token — raising it abandons a scan whose answer is no longer
     /// wanted (see `SpaceState::begin_scan`).
@@ -123,6 +125,14 @@ impl FsService {
                             ));
                         });
                     }
+                    FsRequest::CreateDir(parent) => {
+                        tokio::spawn(async move {
+                            let result = create_new_folder(&parent).map_err(|e| e.to_string());
+                            let _ = app_sender.send(crate::Message::Browse(
+                                crate::pages::browse::BrowseMessage::FolderCreated(parent, result),
+                            ));
+                        });
+                    }
                     FsRequest::ScanTree(path, cancel) => {
                         // Minutes of blocking recursion on a large tree, so
                         // this goes to the blocking pool rather than tying up
@@ -194,6 +204,22 @@ impl FsService {
 
 // ── Internal Helper Functions ───────────────────────────────────────
 
+/// Create "New Folder" in `parent`, or "New Folder 2", "New Folder 3", … when
+/// the name is taken. `create_dir` itself is the existence check, so a name
+/// claimed between two attempts just moves on to the next one.
+pub fn create_new_folder(parent: &Path) -> std::io::Result<PathBuf> {
+    for n in 1u32.. {
+        let name = if n == 1 { "New Folder".to_string() } else { format!("New Folder {n}") };
+        let path = parent.join(name);
+        match fs::create_dir(&path) {
+            Ok(()) => return Ok(path),
+            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
+            Err(e) => return Err(e),
+        }
+    }
+    unreachable!()
+}
+
 pub fn read_directory_internal(path: &Path) -> Vec<DirEntry> {
     let in_trash = super::trash::is_trash_files_dir(path);
     let mut entries: Vec<DirEntry> = match fs::read_dir(path) {
@@ -706,6 +732,23 @@ pub fn open_file(path: &Path) {
 mod tests {
     use super::*;
 
+    #[test]
+    fn test_create_new_folder_picks_unique_names() {
+        let dir = std::env::temp_dir().join(format!(
+            "cce_test_new_folder_{}",
+            std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos()
+        ));
+        fs::create_dir_all(&dir).unwrap();
+
+        let first = create_new_folder(&dir).unwrap();
+        let second = create_new_folder(&dir).unwrap();
+        assert_eq!(first, dir.join("New Folder"));
+        assert_eq!(second, dir.join("New Folder 2"));
+        assert!(first.is_dir() && second.is_dir());
+
+        let _ = fs::remove_dir_all(&dir);
+    }
+
     #[test]
     #[serial_test::serial]
     fn test_kdl() {