Skip to content

Commit da8ca93

Browse files
committed
Shift+F4: Create new file and edit
- Shift+F4 opens a naming dialog, creates an empty file, and opens it in the default text editor (Total Commander style) - Rust: `create_file` command + `create_file_core` with 5s timeout, same pattern as `create_directory` - Refactored `emit_synthetic_mkdir_diff` → `emit_synthetic_entry_diff` (shared by both mkdir and mkfile) - Frontend: `NewFileDialog` — simplified version of `NewFolderDialog` (no AI suggestions, no timeout banner) - Pre-fills filename from cursor (keeps extension for files, empty for directories) - MCP: `mkfile` tool triggers the dialog - Fixed pre-existing style guide violation: "Failed to create folder" → "Couldn't create folder"
1 parent 6af1efe commit da8ca93

24 files changed

Lines changed: 974 additions & 62 deletions

File tree

apps/desktop/src-tauri/src/commands/file_system.rs

Lines changed: 140 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,20 @@ pub async fn create_directory(
8181
) -> Result<String, IpcError> {
8282
let (new_path, expanded_path) = create_directory_core(volume_id, &parent_path, &name).await?;
8383

84-
emit_synthetic_mkdir_diff(&app, &new_path, &PathBuf::from(&expanded_path));
84+
emit_synthetic_entry_diff(&app, &new_path, &PathBuf::from(&expanded_path));
85+
Ok(new_path.to_string_lossy().to_string())
86+
}
87+
88+
#[tauri::command]
89+
pub async fn create_file(
90+
app: tauri::AppHandle,
91+
volume_id: Option<String>,
92+
parent_path: String,
93+
name: String,
94+
) -> Result<String, IpcError> {
95+
let (new_path, expanded_path) = create_file_core(volume_id, &parent_path, &name).await?;
96+
97+
emit_synthetic_entry_diff(&app, &new_path, &PathBuf::from(&expanded_path));
8598
Ok(new_path.to_string_lossy().to_string())
8699
}
87100

@@ -129,7 +142,7 @@ async fn create_directory_core(
129142
name_owned, parent_path_owned
130143
)
131144
}
132-
_ => format!("Failed to create folder: {}", e),
145+
_ => format!("Couldn't create folder: {}", e),
133146
})
134147
}),
135148
)
@@ -150,7 +163,77 @@ async fn create_directory_core(
150163
std::io::ErrorKind::PermissionDenied => {
151164
format!("Permission denied: cannot create '{}' in '{}'", name, parent_path)
152165
}
153-
_ => format!("Failed to create folder: {}", e),
166+
_ => format!("Couldn't create folder: {}", e),
167+
})
168+
.map_err(IpcError::from_err)?;
169+
170+
Ok((new_path, expanded_path))
171+
}
172+
173+
/// Core file creation logic, separated from the Tauri command so it can be tested without `AppHandle`.
174+
async fn create_file_core(
175+
volume_id: Option<String>,
176+
parent_path: &str,
177+
name: &str,
178+
) -> Result<(PathBuf, String), IpcError> {
179+
if name.is_empty() {
180+
return Err(IpcError::from_err("File name cannot be empty"));
181+
}
182+
if name.contains('/') || name.contains('\0') {
183+
return Err(IpcError::from_err("File name contains invalid characters"));
184+
}
185+
186+
let volume_id = volume_id.unwrap_or_else(|| "root".to_string());
187+
188+
// For local volumes, expand tilde
189+
let expanded_path = if volume_id == "root" {
190+
expand_tilde(parent_path)
191+
} else {
192+
parent_path.to_string()
193+
};
194+
195+
// Try to use Volume abstraction
196+
if let Some(volume) = get_volume_manager().get(&volume_id) {
197+
let new_path = PathBuf::from(&expanded_path).join(name);
198+
let new_path_clone = new_path.clone();
199+
let parent_path_owned = parent_path.to_string();
200+
let name_owned = name.to_string();
201+
202+
tokio::time::timeout(
203+
Duration::from_secs(5),
204+
tokio::task::spawn_blocking(move || {
205+
volume.create_file(&new_path_clone, b"").map_err(|e| match e {
206+
crate::file_system::VolumeError::AlreadyExists(_) => {
207+
format!("'{}' already exists", name_owned)
208+
}
209+
crate::file_system::VolumeError::PermissionDenied(_) => {
210+
format!(
211+
"Permission denied: cannot create '{}' in '{}'",
212+
name_owned, parent_path_owned
213+
)
214+
}
215+
_ => format!("Couldn't create file: {}", e),
216+
})
217+
}),
218+
)
219+
.await
220+
.map_err(|_| IpcError::timeout())?
221+
.map_err(|e| IpcError::from_err(format!("Task failed: {}", e)))?
222+
.map_err(IpcError::from_err)?;
223+
224+
return Ok((new_path, expanded_path));
225+
}
226+
227+
// Fallback for unknown volumes (shouldn't happen in practice)
228+
let mut new_path = PathBuf::from(&expanded_path);
229+
new_path.push(name);
230+
std::fs::File::create_new(&new_path)
231+
.map_err(|e| match e.kind() {
232+
std::io::ErrorKind::AlreadyExists => format!("'{}' already exists", name),
233+
std::io::ErrorKind::PermissionDenied => {
234+
format!("Permission denied: cannot create '{}' in '{}'", name, parent_path)
235+
}
236+
_ => format!("Couldn't create file: {}", e),
154237
})
155238
.map_err(IpcError::from_err)?;
156239

@@ -688,21 +771,21 @@ pub fn clear_self_drag_overlay() {
688771
#[tauri::command]
689772
pub fn clear_self_drag_overlay() {}
690773

691-
/// Emits a synthetic `directory-diff` event for a newly created directory.
774+
/// Emits a synthetic `directory-diff` event for a newly created entry (file or directory).
692775
///
693776
/// Best-effort: if any step fails (stat, cache lookup, etc.) we log a warning
694777
/// and return — the watcher will pick up the change later.
695-
fn emit_synthetic_mkdir_diff(app: &tauri::AppHandle, new_dir_path: &Path, parent_path: &Path) {
778+
fn emit_synthetic_entry_diff(app: &tauri::AppHandle, entry_path: &Path, parent_path: &Path) {
696779
use crate::file_system::listing::reading::get_single_entry;
697780
use crate::file_system::listing::{find_listings_for_path, insert_entry_sorted};
698781
use crate::file_system::watcher::{DiffChange, DirectoryDiff, WATCHER_MANAGER};
699782
use tauri::Emitter;
700783

701-
// 1. Construct a FileEntry for the new folder
702-
let mut entry = match get_single_entry(new_dir_path) {
784+
// 1. Construct a FileEntry for the new entry
785+
let mut entry = match get_single_entry(entry_path) {
703786
Ok(e) => e,
704787
Err(e) => {
705-
log::warn!("Synthetic mkdir diff: couldn't stat new directory: {}", e);
788+
log::warn!("Synthetic entry diff: couldn't stat new entry: {}", e);
706789
return;
707790
}
708791
};
@@ -748,7 +831,7 @@ fn emit_synthetic_mkdir_diff(app: &tauri::AppHandle, new_dir_path: &Path, parent
748831
};
749832

750833
if let Err(e) = app.emit("directory-diff", &diff) {
751-
log::warn!("Synthetic mkdir diff: couldn't emit event: {}", e);
834+
log::warn!("Synthetic entry diff: couldn't emit event: {}", e);
752835
}
753836
}
754837
}
@@ -853,6 +936,54 @@ mod tests {
853936
assert!(result.is_err());
854937
}
855938

939+
#[tokio::test]
940+
async fn test_create_file_success() {
941+
let tmp = create_test_dir("create_file_success");
942+
let parent = tmp.to_string_lossy().to_string();
943+
let result = create_file_core(None, &parent, "new-file.txt").await;
944+
assert!(result.is_ok());
945+
let (created_path, _) = result.unwrap();
946+
assert!(created_path.is_file());
947+
assert!(created_path.to_string_lossy().ends_with("new-file.txt"));
948+
assert_eq!(fs::read(&created_path).unwrap(), b"");
949+
cleanup_test_dir(&tmp);
950+
}
951+
952+
#[tokio::test]
953+
async fn test_create_file_already_exists() {
954+
let tmp = create_test_dir("create_file_exists");
955+
let parent = tmp.to_string_lossy().to_string();
956+
fs::write(tmp.join("existing.txt"), b"hello").unwrap();
957+
let result = create_file_core(None, &parent, "existing.txt").await;
958+
assert!(result.is_err());
959+
assert!(result.unwrap_err().message.contains("already exists"));
960+
cleanup_test_dir(&tmp);
961+
}
962+
963+
#[tokio::test]
964+
async fn test_create_file_empty_name() {
965+
let tmp = create_test_dir("create_file_empty");
966+
let parent = tmp.to_string_lossy().to_string();
967+
let result = create_file_core(None, &parent, "").await;
968+
assert!(result.is_err());
969+
assert!(result.unwrap_err().message.contains("cannot be empty"));
970+
cleanup_test_dir(&tmp);
971+
}
972+
973+
#[tokio::test]
974+
async fn test_create_file_invalid_chars() {
975+
let tmp = create_test_dir("create_file_invalid");
976+
let parent = tmp.to_string_lossy().to_string();
977+
let result = create_file_core(None, &parent, "foo/bar.txt").await;
978+
assert!(result.is_err());
979+
assert!(result.unwrap_err().message.contains("invalid characters"));
980+
981+
let result = create_file_core(None, &parent, "foo\0bar.txt").await;
982+
assert!(result.is_err());
983+
assert!(result.unwrap_err().message.contains("invalid characters"));
984+
cleanup_test_dir(&tmp);
985+
}
986+
856987
#[tokio::test]
857988
async fn test_blocking_with_timeout_fast_closure_returns_value() {
858989
let result = blocking_with_timeout(Duration::from_secs(2), false, || true).await;

apps/desktop/src-tauri/src/file_system/listing/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,8 @@ Frontend Backend
9090
**Decision**: Incremental watcher path with fallback to full re-read
9191
**Why**: Most FS changes are a few files (save, rename, drop). Re-reading an entire 50k-entry directory for one changed file is wasteful. The incremental path processes individual events: stat each changed path, classify as add/remove/modify against the cache, then use `insert_entry_sorted`/`remove_entry_by_path`/`update_entry_sorted` to patch the cache in-place. Falls back to full `handle_directory_change` when events exceed 500 or contain unknown event kinds (`Any`/`Other`), since those can't be reliably classified.
9292

93-
**Decision**: Synthetic diff for mkdir (`emit_synthetic_mkdir_diff`)
94-
**Why**: `create_directory` returns before the watcher fires. Without a synthetic diff, the new folder wouldn't appear until the next debounce cycle (~200ms). The command handler stats the new directory, inserts it into all affected listings via `insert_entry_sorted`, and emits a `directory-diff` event immediately. The watcher later sees the same change but `has_entry` prevents duplicates.
93+
**Decision**: Synthetic diff for entry creation (`emit_synthetic_entry_diff`)
94+
**Why**: `create_directory` and `create_file` return before the watcher fires. Without a synthetic diff, the new entry wouldn't appear until the next debounce cycle (~200ms). The command handler stats the new entry, inserts it into all affected listings via `insert_entry_sorted`, and emits a `directory-diff` event immediately. The watcher later sees the same change but `has_entry` prevents duplicates.
9595

9696
**Decision**: Re-sort `new_entries` before `compute_diff` in full re-read path
9797
**Why**: `list_directory_core` always returns entries sorted by Name/Asc, but the cached listing may use a different sort. Without re-sorting, diff indices would be wrong (comparing two differently-ordered lists). The re-sort aligns `new_entries` with the cached sort order so `compute_diff` produces correct indices.

apps/desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -584,6 +584,7 @@ pub fn run() {
584584
commands::file_system::get_path_limits,
585585
commands::file_system::path_exists,
586586
commands::file_system::create_directory,
587+
commands::file_system::create_file,
587588
commands::file_system::benchmark_log,
588589
commands::file_system::copy_files,
589590
commands::file_system::move_files,

apps/desktop/src-tauri/src/mcp/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ Expose Cmdr functionality to AI agents via the Model Context Protocol (MCP). Age
2121

2222
### Tools (`tools.rs`)
2323

24-
24 semantic tools grouped by category:
24+
25 semantic tools grouped by category:
2525
- Navigation (6): `select_volume`, `nav_to_path`, `nav_to_parent`, `nav_back`, `nav_forward`, `scroll_to`
2626
- Cursor/Selection (3): `move_cursor`, `open_under_cursor`, `select`
27-
- File operations (4): `copy`, `delete`, `mkdir`, `refresh`
27+
- File operations (5): `copy`, `delete`, `mkdir`, `mkfile`, `refresh`
2828
- View (3): `sort`, `toggle_hidden`, `set_view_mode`
2929
- Tabs (2): `activate_tab` (switch to a specific tab by pane + tab ID), `pin_tab` (pin/unpin a tab)
3030
- Dialogs (1): `dialog` (unified open/focus/close)

apps/desktop/src-tauri/src/mcp/executor.rs

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ pub async fn execute_tool<R: Runtime>(app: &AppHandle<R>, name: &str, params: &V
7070
"copy" => execute_copy(app),
7171
"delete" => execute_delete(app),
7272
"mkdir" => execute_mkdir(app),
73+
"mkfile" => execute_mkfile(app),
7374
"refresh" => execute_refresh(app),
7475
// Selection command
7576
"select" => execute_select_command(app, params),
@@ -457,6 +458,12 @@ fn execute_mkdir<R: Runtime>(app: &AppHandle<R>) -> ToolResult {
457458
Ok(json!("OK: Create folder dialog opened."))
458459
}
459460

461+
/// Execute mkfile command.
462+
fn execute_mkfile<R: Runtime>(app: &AppHandle<R>) -> ToolResult {
463+
app.emit("mcp-mkfile", ())?;
464+
Ok(json!("OK: Create file dialog opened."))
465+
}
466+
460467
/// Execute refresh command.
461468
fn execute_refresh<R: Runtime>(app: &AppHandle<R>) -> ToolResult {
462469
app.emit("mcp-refresh", ())?;
@@ -594,9 +601,11 @@ fn execute_dialog_open<R: Runtime>(
594601
app.emit_to("main", "execute-command", json!({"commandId": "app.about"}))?;
595602
Ok(json!("OK: Opened about dialog"))
596603
}
597-
"copy-confirmation" | "mkdir-confirmation" | "delete-confirmation" => Err(ToolError::invalid_params(
598-
"Cannot open confirmation dialogs directly. Use copy, delete, or mkdir tools instead.",
599-
)),
604+
"copy-confirmation" | "mkdir-confirmation" | "new-file-confirmation" | "delete-confirmation" => {
605+
Err(ToolError::invalid_params(
606+
"Cannot open confirmation dialogs directly. Use copy, delete, mkdir, or mkfile tools instead.",
607+
))
608+
}
600609
_ => Err(ToolError::invalid_params(format!("Invalid dialog type: {dialog_type}"))),
601610
}
602611
}
@@ -626,7 +635,7 @@ fn execute_dialog_focus<R: Runtime>(app: &AppHandle<R>, dialog_type: &str, path:
626635
app.emit("focus-about", ())?;
627636
Ok(json!("OK: Focused about dialog"))
628637
}
629-
"copy-confirmation" | "mkdir-confirmation" | "delete-confirmation" => {
638+
"copy-confirmation" | "mkdir-confirmation" | "new-file-confirmation" | "delete-confirmation" => {
630639
app.emit("focus-confirmation", ())?;
631640
Ok(json!("OK: Focused confirmation dialog"))
632641
}
@@ -659,7 +668,7 @@ fn execute_dialog_close<R: Runtime>(app: &AppHandle<R>, dialog_type: &str, path:
659668
app.emit("close-about", ())?;
660669
Ok(json!("OK: Closed about dialog"))
661670
}
662-
"copy-confirmation" | "mkdir-confirmation" | "delete-confirmation" => {
671+
"copy-confirmation" | "mkdir-confirmation" | "new-file-confirmation" | "delete-confirmation" => {
663672
app.emit("close-confirmation", ())?;
664673
Ok(json!("OK: Cancelled confirmation dialog"))
665674
}

apps/desktop/src-tauri/src/mcp/tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -105,11 +105,11 @@ fn test_tool_input_schemas_are_valid() {
105105
#[test]
106106
fn test_total_tool_count() {
107107
let tools = get_all_tools();
108-
// 6 nav + 2 cursor + 1 select + 4 file_op + 3 view + 2 tab + 1 dialog + 3 app + 2 search = 24
108+
// 6 nav + 2 cursor + 1 select + 5 file_op + 3 view + 2 tab + 1 dialog + 3 app + 2 search = 25
109109
assert_eq!(
110110
tools.len(),
111-
24,
112-
"Expected 24 tools, got {}. Did you add/remove tools?",
111+
25,
112+
"Expected 25 tools, got {}. Did you add/remove tools?",
113113
tools.len()
114114
);
115115
}

apps/desktop/src-tauri/src/mcp/tools.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ fn get_file_op_tools() -> Vec<Tool> {
150150
Tool::no_params("copy", "Copy selected files to other pane (triggers native dialog)"),
151151
Tool::no_params("delete", "Delete selected files (triggers confirmation dialog)"),
152152
Tool::no_params("mkdir", "Create folder in focused pane (triggers naming dialog)"),
153+
Tool::no_params("mkfile", "Create file in focused pane (triggers naming dialog)"),
153154
Tool::no_params("refresh", "Refresh focused pane"),
154155
]
155156
}
@@ -309,7 +310,7 @@ fn get_dialog_tools() -> Vec<Tool> {
309310
},
310311
"type": {
311312
"type": "string",
312-
"enum": ["settings", "file-viewer", "about", "copy-confirmation", "mkdir-confirmation", "delete-confirmation"],
313+
"enum": ["settings", "file-viewer", "about", "copy-confirmation", "mkdir-confirmation", "new-file-confirmation", "delete-confirmation"],
313314
"description": "Dialog type"
314315
},
315316
"section": {
@@ -460,8 +461,8 @@ mod tests {
460461
#[test]
461462
fn test_file_op_tools_count() {
462463
let tools = get_file_op_tools();
463-
// copy, delete, mkdir, refresh
464-
assert_eq!(tools.len(), 4);
464+
// copy, delete, mkdir, mkfile, refresh
465+
assert_eq!(tools.len(), 5);
465466
}
466467

467468
#[test]
@@ -503,8 +504,8 @@ mod tests {
503504
#[test]
504505
fn test_all_tools_count() {
505506
let tools = get_all_tools();
506-
// 6 nav + 2 cursor + 1 selection + 4 file_op + 3 view + 2 tab + 1 dialog + 3 app + 2 search = 24
507-
assert_eq!(tools.len(), 24);
507+
// 6 nav + 2 cursor + 1 selection + 5 file_op + 3 view + 2 tab + 1 dialog + 3 app + 2 search = 25
508+
assert_eq!(tools.len(), 25);
508509
}
509510

510511
#[test]
@@ -597,6 +598,7 @@ mod tests {
597598
assert!(type_enum.contains(&json!("about")));
598599
assert!(type_enum.contains(&json!("copy-confirmation")));
599600
assert!(type_enum.contains(&json!("mkdir-confirmation")));
601+
assert!(type_enum.contains(&json!("new-file-confirmation")));
600602
assert!(type_enum.contains(&json!("delete-confirmation")));
601603

602604
// Check required fields

apps/desktop/src/lib/commands/command-registry.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,13 @@ export const commands: Command[] = [
272272
showInPalette: true,
273273
shortcuts: ['F7'],
274274
},
275+
{
276+
id: 'file.newFile',
277+
name: 'Create new file',
278+
scope: 'Main window/File list',
279+
showInPalette: true,
280+
shortcuts: ['⇧F4'],
281+
},
275282
{
276283
id: 'file.delete',
277284
name: 'Delete',

0 commit comments

Comments
 (0)