//! Module for utilities related to editing items in the file tree. #[cfg(test)] #[path = "editing_tests.rs"] mod tests; use std::cmp::Ordering; use std::sync::Arc; use repo_metadata::file_tree_store::FileTreeEntryState; use repo_metadata::{FileMetadata, FileTreeEntry}; use warp_util::standardized_path::StandardizedPath; use warpui::ViewContext; use warpui::elements::MouseStateHandle; use super::{FileTreeIdentifier, FileTreeItem, FileTreeView}; use crate::code::file_tree::FileTreeEvent; use crate::code::file_tree::view::{PendingEdit, PendingEditKind}; use crate::send_telemetry_from_ctx; use crate::server::telemetry::TelemetryEvent; /// Custom ordering function for items in the file tree. /// /// Directories are ordered first, sorted by natural (numeric-aware) order. /// Files are ordered second, sorted by natural (numeric-aware) order. /// Within each group, dotfiles (entries starting with a dot) are ordered first. pub(super) fn sort_entries_for_file_tree( entry_1: &StandardizedPath, entry_2: &StandardizedPath, entry_map: &FileTreeEntry, ) -> Ordering { use std::cmp::Ordering; // Entries missing from the map sort before present entries, and compare // equal to each other. Using the same `Ordering` on both sides would // violate antisymmetry and cause `sorted_by` to panic with // "user-provided comparison function does not correctly implement a total order". let (entry_1, entry_2) = match (entry_map.get(entry_1), entry_map.get(entry_2)) { (None, None) => return Ordering::Equal, (None, Some(_)) => return Ordering::Less, (Some(_), None) => return Ordering::Greater, (Some(e1), Some(e2)) => (e1, e2), }; let is_dir_1 = matches!(entry_1, FileTreeEntryState::Directory(_)); let is_dir_2 = matches!(entry_2, FileTreeEntryState::Directory(_)); // Order directories before any files. match (is_dir_1, is_dir_2) { (true, false) => return Ordering::Less, (false, true) => return Ordering::Greater, // Both are same type, continue with alphabetical sort. _ => {} } // Same antisymmetry requirement for missing file names. let (name_1, name_2) = match (entry_1.path().file_name(), entry_2.path().file_name()) { (None, None) => return Ordering::Equal, (None, Some(_)) => return Ordering::Less, (Some(_), None) => return Ordering::Greater, (Some(n1), Some(n2)) => (n1, n2), }; let starts_with_dot_1 = name_1.starts_with('.'); let starts_with_dot_2 = name_2.starts_with('.'); // Items starting with "." come first. match (starts_with_dot_1, starts_with_dot_2) { (true, false) => Ordering::Less, (false, true) => Ordering::Greater, _ => alphanumeric_sort::compare_str(name_1, name_2), } } impl FileTreeView { /// Creates a new file below the directory at the given identifier. pub(super) fn create_new_file(&mut self, id: &FileTreeIdentifier, ctx: &mut ViewContext) { let Some(root_dir) = self.root_directories.get_mut(&id.root) else { return; }; let (path, depth) = match root_dir.items.get(id.index) { Some(FileTreeItem::File { .. }) => { log::warn!("Cannot create a new file below a file"); return; } Some(FileTreeItem::DirectoryHeader { directory, depth, .. }) => (directory.path.clone(), *depth), _ => return, }; // Ensure the parent directory is expanded before creating a file beneath it. if !self.is_folder_expanded(&id.root, &path) { self.toggle_folder_expansion(&id.root, &path, ctx); } // Create a dummy FileTreeItem for the file we are about to create--we'll replace // this with something real once the user types in the actual file. let new_item_index = id.index + 1; let Some(root_dir) = self.root_directories.get_mut(&id.root) else { return; }; root_dir.items.insert( new_item_index, FileTreeItem::File { metadata: FileMetadata::from_standardized(path.join("new_file"), false).into(), depth: depth + 1, mouse_state_handle: MouseStateHandle::default(), draggable_state: warpui::elements::DraggableState::default(), }, ); // Ensure the new item we just created is selected. let new_id = FileTreeIdentifier { root: id.root.clone(), index: new_item_index, }; self.select_id(&new_id, ctx); // Ensure the editor is focused. ctx.focus(&self.editor_view); self.pending_edit = Some(PendingEdit { id: new_id, kind: PendingEditKind::CreateNewFile, }); } /// Starts a rename edit on the item at the given identifier. pub(super) fn start_rename(&mut self, id: &FileTreeIdentifier, ctx: &mut ViewContext) { let Some(root_dir) = self.root_directories.get(&id.root) else { return; }; let Some(item) = root_dir.items.get(id.index) else { return; }; // Prefill the editor with the current file or directory name. let current_name = item .path() .file_name() .map(|s| s.to_owned()) .unwrap_or_default(); self.pending_edit = Some(PendingEdit { id: id.clone(), kind: PendingEditKind::RenameExisting, }); self.editor_view.update(ctx, |view, ctx| { view.set_buffer_text(¤t_name, ctx); }); ctx.focus(&self.editor_view); } /// Commits a pending edit to the file tree. pub(super) fn commit_pending_edit(&mut self, ctx: &mut ViewContext) { let Some(pending_edit) = self.pending_edit.take() else { return; }; let file_tree_id = pending_edit.id.clone(); let buffer_content = self.editor_view.as_ref(ctx).buffer_text(ctx); self.editor_view.update(ctx, |view, ctx| { view.clear_buffer(ctx); }); match pending_edit.kind { PendingEditKind::CreateNewFile => { let new_entry = { let Some(root_dir) = self.root_directories.get_mut(&file_tree_id.root) else { return; }; let Some(item) = root_dir.items.get_mut(file_tree_id.index) else { return; }; if let FileTreeItem::File { metadata, .. } = item { let mut new_std = (*metadata.path).clone(); new_std.set_file_name(&buffer_content); let local_path = new_std.to_local_path_lossy(); metadata.path = Arc::new(new_std); if let Err(e) = std::fs::File::create_new(&local_path) { log::warn!("Failed to create file: {e}"); return; } send_telemetry_from_ctx!(TelemetryEvent::FileTreeItemCreated, ctx); FileTreeEntryState::File(metadata.clone()) } else { return; } }; if let Some(root_dir) = self.root_directories.get_mut(&file_tree_id.root) { // Ensure the file tree has the new item we've just created. Self::insert_entry(&mut root_dir.entry, new_entry); } self.open_in_new_pane(&file_tree_id, ctx); self.rebuild_flattened_items(); } PendingEditKind::RenameExisting => { let Some(root_dir) = self.root_directories.get(&file_tree_id.root) else { return; }; let Some(item) = root_dir.items.get(file_tree_id.index) else { return; }; if buffer_content.is_empty() { return; } let old_std_path = item.path().clone(); let mut new_std_path = old_std_path.clone(); new_std_path.set_file_name(&buffer_content); let old_path = old_std_path.to_local_path_lossy(); let new_path = new_std_path.to_local_path_lossy(); if let Err(e) = std::fs::rename(&old_path, &new_path) { log::warn!( "Failed to rename {} -> {}: {e}", old_path.display(), new_path.display() ); return; } // Update the in-memory model immediately so the UI reflects the change without delay. if let Some(root_dir) = self.root_directories.get_mut(&file_tree_id.root) { root_dir.entry.rename_path(&old_std_path, &new_std_path); } // Emit event to notify workspace that a file was renamed ctx.emit(FileTreeEvent::FileRenamed { old_path: old_path.clone(), new_path: new_path.clone(), }); // Rebuild and select the renamed item using its FileTreeIdentifier self.rebuild_flatten_items_impl(Some(&file_tree_id), None, None); ctx.notify(); } } } /// Cancels a pending edit and discards any changes. pub(super) fn cancel_pending_edit(&mut self, ctx: &mut ViewContext) { if let Some(pending_edit) = self.pending_edit.take() { let id = &pending_edit.id; if self.selected_item.as_ref() == Some(id) { self.selected_item = None; } self.editor_view.update(ctx, |view, ctx| { view.clear_buffer(ctx); }); // Only remove placeholder in the create-new-file flow. if pending_edit.kind == PendingEditKind::CreateNewFile && let Some(root_dir) = self.root_directories.get_mut(&id.root) { root_dir.items.remove(id.index); } } ctx.notify(); } /// Inserts a new entry into the tree. fn insert_entry(root_entry: &mut FileTreeEntry, child_entry: FileTreeEntryState) { let Some(parent) = child_entry.path().parent() else { return; }; root_entry.insert_child_state(&parent, child_entry); } pub(super) fn handle_pending_edit(&mut self, ctx: &mut ViewContext) { if self.pending_edit.is_none() { return; }; let editor_contents = self.editor_view.as_ref(ctx).buffer_text(ctx); // If the editor is empty and the editor was dismissed, cancel the editor. // Otherwise commit the editor. This matches VSCode's behavior. if editor_contents.is_empty() { self.cancel_pending_edit(ctx); } else { self.commit_pending_edit(ctx); } } }