file manager
git clone https://git.lucas.co/cce-files.git
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() {