Skip to content

Commit 80ec297

Browse files
committed
Add "New folder" feature
- Add back-end feature - Add front-end implementation - Add conflict handling, incl. file watching - Add FE+BE+E2E tests - Document new feature
1 parent f520ab7 commit 80ec297

10 files changed

Lines changed: 690 additions & 1 deletion

File tree

apps/desktop/coverage-allowlist.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"file-explorer/FileIcon.svelte": { "reason": "Simple UI component, display only" },
1111
"file-explorer/FilePane.svelte": { "reason": "Tested in integration.test.ts, complex component" },
1212
"file-explorer/FullList.svelte": { "reason": "Logic tested in full-list-utils.ts, component mounting heavy" },
13+
"file-explorer/NewFolderDialog.svelte": { "reason": "UI modal, logic tested in new-folder-utils.test.ts" },
1314
"file-explorer/NetworkBrowser.svelte": { "reason": "Network component, needs Tauri integration" },
1415
"file-explorer/PaneResizer.svelte": { "reason": "Mouse drag UI component, difficult to unit test" },
1516
"file-explorer/NetworkLoginForm.svelte": { "reason": "Network component, needs Tauri integration" },

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

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,35 @@ pub fn path_exists(path: String) -> bool {
3939
path_buf.exists()
4040
}
4141

42+
/// Creates a new directory.
43+
///
44+
/// # Arguments
45+
/// * `parent_path` - The parent directory path. Supports tilde expansion (~).
46+
/// * `name` - The folder name to create.
47+
///
48+
/// # Returns
49+
/// The full path of the created directory, or an error message.
50+
#[tauri::command]
51+
pub fn create_directory(parent_path: String, name: String) -> Result<String, String> {
52+
if name.is_empty() {
53+
return Err("Folder name cannot be empty".to_string());
54+
}
55+
if name.contains('/') || name.contains('\0') {
56+
return Err("Folder name contains invalid characters".to_string());
57+
}
58+
let expanded_path = expand_tilde(&parent_path);
59+
let mut new_path = PathBuf::from(&expanded_path);
60+
new_path.push(&name);
61+
std::fs::create_dir(&new_path).map_err(|e| match e.kind() {
62+
std::io::ErrorKind::AlreadyExists => format!("'{}' already exists", name),
63+
std::io::ErrorKind::PermissionDenied => {
64+
format!("Permission denied: cannot create '{}' in '{}'", name, parent_path)
65+
}
66+
_ => format!("Failed to create folder: {}", e),
67+
})?;
68+
Ok(new_path.to_string_lossy().to_string())
69+
}
70+
4271
// ============================================================================
4372
// On-demand virtual scrolling API
4473
// ============================================================================
@@ -545,6 +574,18 @@ fn expand_tilde(path: &str) -> String {
545574
#[cfg(test)]
546575
mod tests {
547576
use super::*;
577+
use std::fs;
578+
579+
fn create_test_dir(name: &str) -> PathBuf {
580+
let dir = std::env::temp_dir().join(format!("cmdr_fs_cmd_test_{}", name));
581+
let _ = fs::remove_dir_all(&dir);
582+
fs::create_dir_all(&dir).expect("Failed to create test directory");
583+
dir
584+
}
585+
586+
fn cleanup_test_dir(path: &PathBuf) {
587+
let _ = fs::remove_dir_all(path);
588+
}
548589

549590
#[test]
550591
fn test_expand_tilde() {
@@ -566,4 +607,57 @@ mod tests {
566607
let path = "/usr/local/bin";
567608
assert_eq!(expand_tilde(path), path);
568609
}
610+
611+
#[test]
612+
fn test_create_directory_success() {
613+
let tmp = create_test_dir("create_success");
614+
let parent = tmp.to_string_lossy().to_string();
615+
let result = create_directory(parent, "new-folder".to_string());
616+
assert!(result.is_ok());
617+
let created_path = result.unwrap();
618+
assert!(PathBuf::from(&created_path).is_dir());
619+
assert!(created_path.ends_with("new-folder"));
620+
cleanup_test_dir(&tmp);
621+
}
622+
623+
#[test]
624+
fn test_create_directory_already_exists() {
625+
let tmp = create_test_dir("create_exists");
626+
let parent = tmp.to_string_lossy().to_string();
627+
fs::create_dir(tmp.join("existing")).unwrap();
628+
let result = create_directory(parent, "existing".to_string());
629+
assert!(result.is_err());
630+
assert!(result.unwrap_err().contains("already exists"));
631+
cleanup_test_dir(&tmp);
632+
}
633+
634+
#[test]
635+
fn test_create_directory_empty_name() {
636+
let tmp = create_test_dir("create_empty");
637+
let parent = tmp.to_string_lossy().to_string();
638+
let result = create_directory(parent, "".to_string());
639+
assert!(result.is_err());
640+
assert!(result.unwrap_err().contains("cannot be empty"));
641+
cleanup_test_dir(&tmp);
642+
}
643+
644+
#[test]
645+
fn test_create_directory_invalid_chars() {
646+
let tmp = create_test_dir("create_invalid");
647+
let parent = tmp.to_string_lossy().to_string();
648+
let result = create_directory(parent.clone(), "foo/bar".to_string());
649+
assert!(result.is_err());
650+
assert!(result.unwrap_err().contains("invalid characters"));
651+
652+
let result = create_directory(parent, "foo\0bar".to_string());
653+
assert!(result.is_err());
654+
assert!(result.unwrap_err().contains("invalid characters"));
655+
cleanup_test_dir(&tmp);
656+
}
657+
658+
#[test]
659+
fn test_create_directory_nonexistent_parent() {
660+
let result = create_directory("/nonexistent_path_12345".to_string(), "test".to_string());
661+
assert!(result.is_err());
662+
}
569663
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,7 @@ pub fn run() {
263263
commands::file_system::find_file_index,
264264
commands::file_system::resort_listing,
265265
commands::file_system::path_exists,
266+
commands::file_system::create_directory,
266267
commands::file_system::benchmark_log,
267268
commands::file_system::copy_files,
268269
commands::file_system::move_files,

apps/desktop/src/lib/file-explorer/DualPaneExplorer.svelte

Lines changed: 115 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import FilePane from './FilePane.svelte'
44
import PaneResizer from './PaneResizer.svelte'
55
import LoadingIcon from '../LoadingIcon.svelte'
6+
import NewFolderDialog from './NewFolderDialog.svelte'
67
import CopyDialog from '../write-operations/CopyDialog.svelte'
78
import CopyProgressDialog from '../write-operations/CopyProgressDialog.svelte'
89
import { toBackendIndices, toBackendCursorIndex } from '../write-operations/copy-dialog-utils'
@@ -29,10 +30,12 @@
2930
updateFocusedPane,
3031
getFileAt,
3132
getListingStats,
33+
findFileIndex,
3234
} from '$lib/tauri-commands'
33-
import type { VolumeInfo, SortColumn, SortOrder, NetworkHost } from './types'
35+
import type { VolumeInfo, SortColumn, SortOrder, NetworkHost, DirectoryDiff } from './types'
3436
import { defaultSortOrders, DEFAULT_SORT_BY } from './types'
3537
import { ensureFontMetricsLoaded } from '$lib/font-metrics'
38+
import { removeExtension } from './new-folder-utils'
3639
import {
3740
createHistory,
3841
push,
@@ -102,6 +105,15 @@
102105
previewId: string | null
103106
} | null>(null)
104107
108+
// New folder dialog state
109+
let showNewFolderDialog = $state(false)
110+
let newFolderDialogProps = $state<{
111+
currentPath: string
112+
listingId: string
113+
showHiddenFiles: boolean
114+
initialName: string
115+
} | null>(null)
116+
105117
// Navigation history for each pane (per-pane, session-only)
106118
// Initialize with default volume - will be updated on mount with actual state
107119
let leftHistory = $state<NavigationHistory>(createHistory(DEFAULT_VOLUME_ID, '~'))
@@ -534,6 +546,13 @@
534546
return
535547
}
536548
549+
// F7 - New folder dialog
550+
if (e.key === 'F7') {
551+
e.preventDefault()
552+
void openNewFolderDialog()
553+
return
554+
}
555+
537556
// Route to volume chooser if one is open
538557
if (routeToVolumeChooser(e)) {
539558
return
@@ -814,6 +833,90 @@
814833
return paths
815834
}
816835
836+
/** Gets the initial name for the new folder dialog (dir name as-is, file name without extension). */
837+
async function getInitialFolderName(paneRef: FilePane | undefined, paneListingId: string): Promise<string> {
838+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
839+
const cursorIndex = paneRef?.getCursorIndex?.() as number | undefined
840+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
841+
const hasParent = paneRef?.hasParentEntry?.() as boolean | undefined
842+
if (cursorIndex === undefined || cursorIndex < 0) return ''
843+
const backendIndex = hasParent ? cursorIndex - 1 : cursorIndex
844+
if (backendIndex < 0) return ''
845+
const entry = await getFileAt(paneListingId, backendIndex, showHiddenFiles)
846+
if (!entry) return ''
847+
return entry.isDirectory ? entry.name : removeExtension(entry.name)
848+
}
849+
850+
async function openNewFolderDialog() {
851+
/** Opens the new folder dialog. Pre-fills with the entry name under cursor. */
852+
const isLeft = focusedPane === 'left'
853+
const paneRef = isLeft ? leftPaneRef : rightPaneRef
854+
const path = isLeft ? leftPath : rightPath
855+
856+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
857+
const paneListingId = paneRef?.getListingId?.() as string | undefined
858+
if (!paneListingId) return
859+
860+
const initialName = await getInitialFolderName(paneRef, paneListingId)
861+
862+
newFolderDialogProps = {
863+
currentPath: path,
864+
listingId: paneListingId,
865+
showHiddenFiles,
866+
initialName,
867+
}
868+
showNewFolderDialog = true
869+
}
870+
871+
function handleNewFolderCreated(folderName: string) {
872+
const paneRef = focusedPane === 'left' ? leftPaneRef : rightPaneRef
873+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
874+
const paneListingId = paneRef?.getListingId?.() as string | undefined
875+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
876+
const hasParent = paneRef?.hasParentEntry?.() as boolean | undefined
877+
878+
showNewFolderDialog = false
879+
newFolderDialogProps = null
880+
containerElement?.focus()
881+
882+
// Wait for file watcher to pick up the new folder, then move cursor to it
883+
if (!paneListingId) return
884+
void moveCursorToNewFolder(paneListingId, folderName, paneRef, hasParent ?? false)
885+
}
886+
887+
/** Waits for the file watcher diff, then moves cursor to the newly created folder. */
888+
async function moveCursorToNewFolder(
889+
paneListingId: string,
890+
folderName: string,
891+
paneRef: FilePane | undefined,
892+
hasParent: boolean,
893+
) {
894+
const unlisten = await listen<DirectoryDiff>('directory-diff', (event) => {
895+
if (event.payload.listingId !== paneListingId) return
896+
// Small delay to ensure listing cache is fully updated before querying
897+
setTimeout(() => {
898+
void findFileIndex(paneListingId, folderName, showHiddenFiles).then((index) => {
899+
if (index !== null) {
900+
const frontendIndex = hasParent ? index + 1 : index
901+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
902+
paneRef?.setCursorIndex?.(frontendIndex)
903+
unlisten()
904+
}
905+
})
906+
}, 50)
907+
})
908+
// Clean up listener after 3 seconds if folder never appears
909+
setTimeout(() => {
910+
unlisten()
911+
}, 3000)
912+
}
913+
914+
function handleNewFolderCancel() {
915+
showNewFolderDialog = false
916+
newFolderDialogProps = null
917+
containerElement?.focus()
918+
}
919+
817920
/** Opens the copy dialog with the current selection info. */
818921
async function openCopyDialog() {
819922
const isLeft = focusedPane === 'left'
@@ -1275,6 +1378,17 @@
12751378
/>
12761379
{/if}
12771380

1381+
{#if showNewFolderDialog && newFolderDialogProps}
1382+
<NewFolderDialog
1383+
currentPath={newFolderDialogProps.currentPath}
1384+
listingId={newFolderDialogProps.listingId}
1385+
showHiddenFiles={newFolderDialogProps.showHiddenFiles}
1386+
initialName={newFolderDialogProps.initialName}
1387+
onCreated={handleNewFolderCreated}
1388+
onCancel={handleNewFolderCancel}
1389+
/>
1390+
{/if}
1391+
12781392
<style>
12791393
.dual-pane-explorer {
12801394
display: flex;

0 commit comments

Comments
 (0)