.tasks/core/FILE-004-rename-and-folders.md
Implement file rename, new folder creation, and new folder with items operations. These features integrate with the existing unified keybind system and context menus, providing inline editing UX similar to macOS Finder.
Key Features:
Existing Infrastructure:
FileCopyJob::new_rename() but needs dedicated action APIexplorer.renameFile already registered (Enter key)keybindId for automatic shortcut displaycreate_directory()create_directory(path, recursive) methodcreate_directory() using tokio::fsFileRenameAction exists at core/src/ops/files/rename/
FileCopyJob::new_rename() for executionJobReceiptCreateFolderAction exists at core/src/ops/files/create_folder/
parent, name, and optional items arrayfiles.rename and files.createFolderrenamingFileId: string | nullstartRename(fileId: string)cancelRename()saveRename(newName: string) using files.rename mutationInlineNameEdit component created at packages/interface/src/components/Explorer/components/InlineNameEdit.tsx
renamingFileId === file.iduseKeybind('explorer.renameFile') triggers rename on EnteruseFileContextMenu:
keybindId: 'explorer.renameFile'File: core/src/volume/backend/mod.rs
Add method to VolumeBackend trait:
async fn create_directory(&self, path: &Path, recursive: bool) -> Result<(), VolumeError>;
File: core/src/volume/backend/local.rs
Implement for LocalBackend:
async fn create_directory(&self, path: &Path, recursive: bool) -> Result<(), VolumeError> {
let full_path = self.resolve_path(path);
if recursive {
fs::create_dir_all(&full_path).await.map_err(VolumeError::Io)?;
} else {
fs::create_dir(&full_path).await.map_err(VolumeError::Io)?;
}
Ok(())
}
File: core/src/volume/backend/cloud.rs (if exists)
Add stub for future implementation.
Directory Structure:
core/src/ops/files/rename/
├── mod.rs # Module exports
├── input.rs # FileRenameInput
├── action.rs # FileRenameAction
└── validation.rs # Filename validation
input.rs:
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct FileRenameInput {
pub target: SdPath,
pub new_name: String,
}
validation.rs:
action.rs:
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileRenameAction {
pub target: SdPath,
pub new_name: String,
}
impl LibraryAction for FileRenameAction {
type Input = FileRenameInput;
type Output = JobReceipt;
async fn validate(...) -> Result<ValidationResult, ActionError> {
// 1. Validate target exists
// 2. Validate new_name (no path separators, not empty, valid chars)
// 3. Check if destination already exists (conflict detection)
// 4. Validate target is not Content/Sidecar path
}
async fn execute(...) -> Result<JobReceipt, ActionError> {
// Dispatch FileCopyJob::new_rename(target, new_name)
// Return job receipt for tracking
}
}
register_library_action!(FileRenameAction, "files.rename");
File: core/src/ops/files/mod.rs
Add: pub mod rename;
Directory Structure:
core/src/ops/files/create_folder/
├── mod.rs # Module exports
├── input.rs # CreateFolderInput
├── output.rs # CreateFolderOutput
└── action.rs # CreateFolderAction
input.rs:
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
pub struct CreateFolderInput {
pub parent: SdPath,
pub name: String,
#[serde(default)]
pub items: Vec<SdPath>, // Optional items to move into folder
}
output.rs:
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateFolderOutput {
pub folder_path: SdPath,
pub job_handle: Option<JobReceipt>, // Present if items were provided
}
action.rs:
impl LibraryAction for CreateFolderAction {
type Input = CreateFolderInput;
type Output = CreateFolderOutput;
async fn validate(...) -> Result<ValidationResult, ActionError> {
// 1. Validate parent exists and is a directory
// 2. Validate folder name
// 3. Check if folder already exists
// 4. Validate items (if provided) exist
}
async fn execute(...) -> Result<CreateFolderOutput, ActionError> {
// 1. Construct destination path: parent.join(name)
// 2. Create directory using VolumeBackend
let volume_manager = library.volumes();
let backend = volume_manager.get_backend_for_path(&parent)?;
backend.create_directory(&folder_path, false).await?;
// 3. If items provided, dispatch FileCopyJob
let job_handle = if !self.items.is_empty() {
let job = FileCopyJob::new(
SdPathBatch::new(self.items),
folder_path.clone()
);
Some(library.jobs().dispatch(job).await?)
} else {
None
};
Ok(CreateFolderOutput {
folder_path,
job_handle: job_handle.map(|h| h.into()),
})
}
}
register_library_action!(CreateFolderAction, "files.createFolder");
File: core/src/ops/files/mod.rs
Add: pub mod create_folder;
File: packages/interface/src/components/Explorer/SelectionContext.tsx
Add to interface (around line 6):
interface SelectionContextValue {
// ... existing fields
renamingFileId: string | null;
startRename: (fileId: string) => void;
cancelRename: () => void;
saveRename: (newName: string) => Promise<void>;
}
Add state and handlers in SelectionProvider:
const [renamingFileId, setRenamingFileId] = useState<string | null>(null);
const renameFile = useLibraryMutation("files.rename");
const startRename = useCallback(
(fileId: string) => {
if (selectedFiles.length === 1) {
setRenamingFileId(fileId);
}
},
[selectedFiles],
);
const cancelRename = useCallback(() => {
setRenamingFileId(null);
}, []);
const saveRename = useCallback(
async (newName: string) => {
if (!renamingFileId) return;
const file = selectedFiles.find((f) => f.id === renamingFileId);
if (!file) return;
try {
await renameFile.mutateAsync({
target: file.sd_path,
new_name: newName,
});
setRenamingFileId(null);
} catch (error) {
// Keep in edit mode, show error
console.error("Rename failed:", error);
throw error;
}
},
[renamingFileId, selectedFiles, renameFile],
);
New File: packages/interface/src/components/Explorer/components/InlineNameEdit.tsx
import { useState, useEffect, useRef } from 'react';
import { Input } from '@sd/ui';
import type { File } from '@sd/ts-client';
interface InlineNameEditProps {
file: File;
onSave: (newName: string) => void;
onCancel: () => void;
className?: string;
}
export function InlineNameEdit({ file, onSave, onCancel, className }: InlineNameEditProps) {
// Split name and extension
const nameWithoutExtension = file.extension
? file.name
: file.name;
const [value, setValue] = useState(nameWithoutExtension);
const inputRef = useRef<HTMLInputElement>(null);
// Auto-focus and select on mount
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, []);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
if (value.trim()) {
onSave(file.extension ? `${value}.${file.extension}` : value);
} else {
onCancel();
}
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onCancel();
}
};
const handleBlur = () => {
onCancel();
};
return (
<Input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleBlur}
variant="transparent"
size="sm"
className={className}
/>
);
}
File: packages/interface/src/components/Explorer/views/GridView/FileCard.tsx
Around line 160, replace file name rendering:
import { InlineNameEdit } from '../../components/InlineNameEdit';
import { useSelection } from '../../SelectionContext';
// Inside FileCard component:
const { renamingFileId, saveRename, cancelRename } = useSelection();
// In render (around line 160):
{renamingFileId === file.id ? (
<InlineNameEdit
file={file}
onSave={saveRename}
onCancel={cancelRename}
className="text-sm truncate px-2 py-0.5"
/>
) : (
<div className="text-sm truncate px-2 py-0.5">
{file.name}
</div>
)}
File: packages/interface/src/components/Explorer/views/ListView/TableRow.tsx
Modify NameCell component (around line 196):
import { InlineNameEdit } from '../../components/InlineNameEdit';
import { useSelection } from '../../SelectionContext';
// Inside NameCell:
const { renamingFileId, saveRename, cancelRename } = useSelection();
{renamingFileId === file.id ? (
<InlineNameEdit
file={file}
onSave={saveRename}
onCancel={cancelRename}
className="truncate text-sm text-ink"
/>
) : (
<span className="truncate text-sm text-ink">
{file.name}
</span>
)}
File: packages/interface/src/components/Explorer/views/GridView/GridView.tsx
After existing useEffect for keyboard nav (around line 204):
import { useKeybind } from "../../../hooks/useKeybind";
useKeybind(
"explorer.renameFile",
() => {
if (selectedFiles.length === 1) {
startRename(selectedFiles[0].id);
}
},
{ enabled: selectedFiles.length === 1 },
);
File: packages/interface/src/components/Explorer/views/ListView/ListView.tsx
Same keybind handler after existing keyboard nav.
File: packages/interface/src/components/Explorer/hooks/useFileContextMenu.ts
Add after "Open" item (around line 90):
import { Pencil, FolderPlus } from '@phosphor-icons/react';
{
icon: Pencil,
label: "Rename",
onClick: () => {
startRename(file.id);
},
keybindId: "explorer.renameFile",
condition: () => selected && selectedFiles.length === 1,
},
{ type: "separator" },
{
icon: FolderPlus,
label: "New Folder",
onClick: () => createFolder(),
// keybindId: "explorer.newFolder", // Add to registry if needed
},
{
icon: FolderPlus,
label: "New Folder with Items",
onClick: () => createFolderWithItems(),
condition: () => selectedFiles.length > 0,
}
Implement createFolder and createFolderWithItems:
const createFolderMutation = useLibraryMutation("files.createFolder");
const createFolder = async () => {
// Create with default name, then enter rename mode
const result = await createFolderMutation.mutateAsync({
parent: currentPath,
name: "Untitled Folder",
items: [],
});
// TODO: Select new folder and enter rename mode
};
const createFolderWithItems = async () => {
const result = await createFolderMutation.mutateAsync({
parent: currentPath,
name: "New Folder",
items: selectedFiles.map((f) => f.sd_path),
});
// result.job_handle tracks copy progress
};
core/src/volume/backend/mod.rscore/src/volume/backend/local.rscore/src/volume/backend/cloud.rscore/src/ops/files/rename/mod.rs (new)core/src/ops/files/rename/input.rs (new)core/src/ops/files/rename/action.rs (new)core/src/ops/files/rename/validation.rs (new)core/src/ops/files/create_folder/mod.rs (new)core/src/ops/files/create_folder/input.rs (new)core/src/ops/files/create_folder/output.rs (new)core/src/ops/files/create_folder/action.rs (new)core/src/ops/files/mod.rspackages/interface/src/components/Explorer/SelectionContext.tsxpackages/interface/src/components/Explorer/components/InlineNameEdit.tsx (new)packages/interface/src/components/Explorer/views/GridView/FileCard.tsxpackages/interface/src/components/Explorer/views/ListView/TableRow.tsxpackages/interface/src/components/Explorer/views/GridView/GridView.tsxpackages/interface/src/components/Explorer/views/ListView/ListView.tsxpackages/interface/src/components/Explorer/hooks/useFileContextMenu.tsRename Flow:
New Folder:
New Folder with Items:
Edge Cases:
core/tests/test_rename_action.rs - Test rename validation and executioncore/tests/test_create_folder_action.rs - Test folder creation with/without itemscore/tests/test_volume_backend.rs - Test create_directory() implementationFileCopyJob::new_rename() for cleaner API semanticskeybindId