diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 25e4a37..f2ffddd 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -2555,6 +2555,69 @@ pub async fn delete_file_tree_entry( Ok(()) } +#[tauri::command] +pub async fn create_file_tree_entry( + root_path: String, + path: String, + name: String, + kind: String, +) -> Result { + let root = PathBuf::from(&root_path); + if !root.exists() || !root.is_dir() { + return Err(AppCommandError::not_found("Folder does not exist")); + } + + let validated_name = validate_new_name(&name)?; + + let parent_dir = if path.is_empty() { + root.clone() + } else { + let resolved = resolve_tree_path(&root, &path)?; + if !resolved.exists() { + return Err(AppCommandError::not_found("Parent path does not exist")); + } + if resolved.is_file() { + resolved + .parent() + .map(|p| p.to_path_buf()) + .ok_or_else(|| AppCommandError::invalid_input("Cannot determine parent directory"))? + } else { + resolved + } + }; + + let target = parent_dir.join(validated_name); + if target.exists() { + return Err(AppCommandError::already_exists( + "A file or directory with this name already exists", + )); + } + + match kind.as_str() { + "file" => { + std::fs::File::create(&target).map_err(AppCommandError::io)?; + } + "dir" => { + std::fs::create_dir(&target).map_err(AppCommandError::io)?; + } + _ => { + return Err(AppCommandError::invalid_input( + "Kind must be 'file' or 'dir'", + )); + } + } + + let rel = target + .strip_prefix(&root) + .map_err(|e| { + AppCommandError::invalid_input("Failed to compute relative path") + .with_detail(e.to_string()) + })? + .to_string_lossy() + .to_string(); + Ok(rel) +} + #[tauri::command] pub async fn git_log( path: String, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1638c21..ed16474 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -218,6 +218,7 @@ pub fn run() { folders::save_file_copy, folders::rename_file_tree_entry, folders::delete_file_tree_entry, + folders::create_file_tree_entry, folders::git_log, folders::git_commit_branches, windows::open_folder_window, diff --git a/src/components/layout/aux-panel-file-tree-tab.tsx b/src/components/layout/aux-panel-file-tree-tab.tsx index d983c7f..5afbce4 100644 --- a/src/components/layout/aux-panel-file-tree-tab.tsx +++ b/src/components/layout/aux-panel-file-tree-tab.tsx @@ -21,6 +21,7 @@ import { useTabContext } from "@/contexts/tab-context" import { useTerminalContext } from "@/contexts/terminal-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { + createFileTreeEntry, deleteFileTreeEntry, gitAddFiles, getGitBranch, @@ -419,6 +420,7 @@ interface RenderNodeProps { onOpenDirInTerminal: (dirPath: string, fileName: string) => Promise onRequestAddToVcs: (target: FileActionTarget) => void onRequestRename: (target: FileActionTarget) => void + onRequestCreate: (parentPath: string, kind: "file" | "dir") => void onRequestDelete: (target: FileActionTarget) => void onRefresh: () => void } @@ -441,6 +443,7 @@ function RenderNode({ onRequestRollback, onOpenDirInTerminal, onRequestAddToVcs, + onRequestCreate, onRequestRename, onRequestDelete, onRefresh, @@ -507,6 +510,21 @@ function RenderNode({ > {t("attachToCurrentSession")} + + {t("new")} + + onRequestCreate(node.path, "file")} + > + {t("newFile")} + + onRequestCreate(node.path, "dir")} + > + {t("newDirectory")} + + + {t("git")} @@ -629,6 +647,7 @@ function RenderNode({ onRequestCompareWithBranch={onRequestCompareWithBranch} onRequestRollback={onRequestRollback} onOpenDirInTerminal={onOpenDirInTerminal} + onRequestCreate={onRequestCreate} onRequestAddToVcs={onRequestAddToVcs} onRequestRename={onRequestRename} onRequestDelete={onRequestDelete} @@ -639,6 +658,19 @@ function RenderNode({ + + {t("new")} + + onRequestCreate(node.path, "file")} + > + {t("newFile")} + + onRequestCreate(node.path, "dir")}> + {t("newDirectory")} + + + {t("git")} @@ -736,6 +768,10 @@ export function FileTreeTab() { ) const [renameValue, setRenameValue] = useState("") const [renaming, setRenaming] = useState(false) + const [createParentPath, setCreateParentPath] = useState(null) + const [createKind, setCreateKind] = useState<"file" | "dir">("file") + const [createName, setCreateName] = useState("") + const [creating, setCreating] = useState(false) const [deleteTarget, setDeleteTarget] = useState( null ) @@ -1139,6 +1175,15 @@ export function FileTreeTab() { }) }, [folder, t]) + const handleRequestCreate = useCallback( + (parentPath: string, kind: "file" | "dir") => { + setCreateParentPath(parentPath) + setCreateKind(kind) + setCreateName("") + }, + [] + ) + const handleRequestRename = useCallback((target: FileActionTarget) => { setRenameTarget(target) setRenameValue(target.name) @@ -1516,6 +1561,33 @@ export function FileTreeTab() { ] ) + const handleCreateConfirm = useCallback(async () => { + if (!folder?.path || createParentPath === null) return + const trimmedName = createName.trim() + if (!trimmedName) { + setCreateParentPath(null) + return + } + + setCreating(true) + try { + await createFileTreeEntry( + folder.path, + createParentPath, + trimmedName, + createKind + ) + setCreateParentPath(null) + setCreateName("") + await fetchTree() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + toast.error(t("toasts.createFailed"), { description: message }) + } finally { + setCreating(false) + } + }, [createKind, createName, createParentPath, fetchTree, folder?.path, t]) + const handleRenameConfirm = useCallback(async () => { if (!folder?.path || !renameTarget) return const nextName = renameValue.trim() @@ -2074,6 +2146,7 @@ export function FileTreeTab() { } onRequestRollback={handleRequestRollback} onOpenDirInTerminal={handleOpenDirInTerminal} + onRequestCreate={handleRequestCreate} onRequestAddToVcs={handleAddToVcs} onRequestRename={handleRequestRename} onRequestDelete={handleRequestDelete} @@ -2083,6 +2156,21 @@ export function FileTreeTab() { + + {t("new")} + + handleRequestCreate("", "file")} + > + {t("newFile")} + + handleRequestCreate("", "dir")} + > + {t("newDirectory")} + + + {t("git")} @@ -2165,6 +2253,17 @@ export function FileTreeTab() { + + {t("new")} + + handleRequestCreate("", "file")}> + {t("newFile")} + + handleRequestCreate("", "dir")}> + {t("newDirectory")} + + + { void fetchTree() @@ -2175,6 +2274,68 @@ export function FileTreeTab() { + { + if (open) return + setCreateParentPath(null) + setCreateName("") + }} + > + + + + {createKind === "dir" + ? t("createDialog.newDirectory") + : t("createDialog.newFile")} + + + {t("createDialog.description", { + kind: + createKind === "dir" + ? t("newDirectory").toLowerCase() + : t("newFile").toLowerCase(), + })} + + +
{ + event.preventDefault() + void handleCreateConfirm() + }} + className="space-y-4" + > + setCreateName(event.target.value)} + autoFocus + disabled={creating} + placeholder={ + createKind === "dir" + ? t("createDialog.placeholderDirectory") + : t("createDialog.placeholderFile") + } + /> + + + + +
+
+
+ { @@ -2473,7 +2634,12 @@ export function FileTreeTab() { - {remoteName} ({groupedCompareRemoteBranches[remoteName].length}) + {remoteName} ( + { + groupedCompareRemoteBranches[remoteName] + .length + } + ) {groupedCompareRemoteBranches[remoteName].map( @@ -2489,7 +2655,9 @@ export function FileTreeTab() { }} disabled={comparing} > - {branch.substring(remoteName.length + 1)} + {branch.substring( + remoteName.length + 1 + )} ) )} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index d8d72fa..e14dbc6 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -967,6 +967,9 @@ "attachToCurrentSession": "إرفاق بالجلسة الحالية", "compareWithBranch": "المقارنة مع الفرع...", "reloadFromDisk": "إعادة التحميل من القرص", + "new": "جديد", + "newFile": "ملف", + "newDirectory": "مجلد", "openIn": "فتح في", "openInTerminal": "فتح في الطرفية", "actions": { @@ -996,7 +999,15 @@ "rolledBackFiles": "{count, plural, one {تم التراجع عن ملف واحد} other {تم التراجع عن # ملفات}}", "savedAsCopy": "تم الحفظ كنسخة", "saveCopyFailed": "فشل الحفظ كنسخة", - "watchStartFailed": "فشل بدء مراقبة الملفات" + "watchStartFailed": "فشل بدء مراقبة الملفات", + "createFailed": "فشل في الإنشاء" + }, + "createDialog": { + "newFile": "ملف جديد", + "newDirectory": "مجلد جديد", + "description": "أدخل اسمًا لـ{kind} الجديد.", + "placeholderFile": "file-name.ext", + "placeholderDirectory": "folder-name" }, "renameDialog": { "renameDirectory": "إعادة تسمية المجلد", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 59cc818..3f48a03 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -967,6 +967,9 @@ "attachToCurrentSession": "An aktuelle Sitzung anhängen", "compareWithBranch": "Mit Branch vergleichen...", "reloadFromDisk": "Von Datenträger neu laden", + "new": "Neu", + "newFile": "Datei", + "newDirectory": "Verzeichnis", "openIn": "Öffnen in", "openInTerminal": "Im Terminal öffnen", "actions": { @@ -996,7 +999,15 @@ "rolledBackFiles": "{count, plural, one {# Datei zurückgesetzt} other {# Dateien zurückgesetzt}}", "savedAsCopy": "Als Kopie gespeichert", "saveCopyFailed": "Speichern als Kopie fehlgeschlagen", - "watchStartFailed": "Dateiwatch konnte nicht gestartet werden" + "watchStartFailed": "Dateiwatch konnte nicht gestartet werden", + "createFailed": "Erstellen fehlgeschlagen" + }, + "createDialog": { + "newFile": "Neue Datei", + "newDirectory": "Neues Verzeichnis", + "description": "Geben Sie einen Namen für das neue {kind} ein.", + "placeholderFile": "file-name.ext", + "placeholderDirectory": "folder-name" }, "renameDialog": { "renameDirectory": "Verzeichnis umbenennen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 891297e..c3913b3 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -967,6 +967,9 @@ "attachToCurrentSession": "Attach to current session", "compareWithBranch": "Compare with branch...", "reloadFromDisk": "Reload from disk", + "new": "New", + "newFile": "File", + "newDirectory": "Directory", "openIn": "Open in", "openInTerminal": "Open in terminal", "actions": { @@ -996,7 +999,15 @@ "rolledBackFiles": "Rolled back {count, plural, one {# file} other {# files}}", "savedAsCopy": "Saved as a copy", "saveCopyFailed": "Failed to save as copy", - "watchStartFailed": "Failed to start file watch" + "watchStartFailed": "Failed to start file watch", + "createFailed": "Failed to create" + }, + "createDialog": { + "newFile": "New file", + "newDirectory": "New directory", + "description": "Enter a name for the new {kind}.", + "placeholderFile": "file-name.ext", + "placeholderDirectory": "folder-name" }, "renameDialog": { "renameDirectory": "Rename directory", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 7ba8977..78933e8 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -967,6 +967,9 @@ "attachToCurrentSession": "Adjuntar a la sesión actual", "compareWithBranch": "Comparar con rama...", "reloadFromDisk": "Recargar desde disco", + "new": "Nuevo", + "newFile": "Archivo", + "newDirectory": "Directorio", "openIn": "Abrir en", "openInTerminal": "Abrir en terminal", "actions": { @@ -996,7 +999,15 @@ "rolledBackFiles": "{count, plural, one {Se revirtió # archivo} other {Se revirtieron # archivos}}", "savedAsCopy": "Guardado como copia", "saveCopyFailed": "No se pudo guardar como copia", - "watchStartFailed": "No se pudo iniciar la vigilancia de archivos" + "watchStartFailed": "No se pudo iniciar la vigilancia de archivos", + "createFailed": "Error al crear" + }, + "createDialog": { + "newFile": "Nuevo archivo", + "newDirectory": "Nuevo directorio", + "description": "Ingrese un nombre para el nuevo {kind}.", + "placeholderFile": "file-name.ext", + "placeholderDirectory": "folder-name" }, "renameDialog": { "renameDirectory": "Renombrar directorio", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 8ea9ea3..fc1ae84 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -967,6 +967,9 @@ "attachToCurrentSession": "Attacher à la session actuelle", "compareWithBranch": "Comparer avec la branche...", "reloadFromDisk": "Recharger depuis le disque", + "new": "Nouveau", + "newFile": "Fichier", + "newDirectory": "Répertoire", "openIn": "Ouvrir dans", "openInTerminal": "Ouvrir dans le terminal", "actions": { @@ -996,7 +999,15 @@ "rolledBackFiles": "{count, plural, one {# fichier annulé} other {# fichiers annulés}}", "savedAsCopy": "Enregistré en copie", "saveCopyFailed": "Échec de l'enregistrement en copie", - "watchStartFailed": "Échec du démarrage de la surveillance de fichiers" + "watchStartFailed": "Échec du démarrage de la surveillance de fichiers", + "createFailed": "Échec de la création" + }, + "createDialog": { + "newFile": "Nouveau fichier", + "newDirectory": "Nouveau répertoire", + "description": "Entrez un nom pour le nouveau {kind}.", + "placeholderFile": "file-name.ext", + "placeholderDirectory": "folder-name" }, "renameDialog": { "renameDirectory": "Renommer le dossier", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 4da2571..2dfae86 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -967,6 +967,9 @@ "attachToCurrentSession": "現在のセッションに添付", "compareWithBranch": "ブランチと比較...", "reloadFromDisk": "ディスクから再読み込み", + "new": "新規作成", + "newFile": "ファイル", + "newDirectory": "ディレクトリ", "openIn": "で開く", "openInTerminal": "ターミナルで開く", "actions": { @@ -996,7 +999,15 @@ "rolledBackFiles": "{count, plural, one {# 件のファイルをロールバックしました} other {# 件のファイルをロールバックしました}}", "savedAsCopy": "コピーとして保存しました", "saveCopyFailed": "コピーとして保存できませんでした", - "watchStartFailed": "ファイル監視の開始に失敗しました" + "watchStartFailed": "ファイル監視の開始に失敗しました", + "createFailed": "作成に失敗しました" + }, + "createDialog": { + "newFile": "新規ファイル", + "newDirectory": "新規ディレクトリ", + "description": "新しい{kind}の名前を入力してください。", + "placeholderFile": "file-name.ext", + "placeholderDirectory": "folder-name" }, "renameDialog": { "renameDirectory": "ディレクトリ名を変更", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 3f2876d..28ae5da 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -967,6 +967,9 @@ "attachToCurrentSession": "현재 세션에 연결", "compareWithBranch": "브랜치와 비교...", "reloadFromDisk": "디스크에서 다시 불러오기", + "new": "새로 만들기", + "newFile": "파일", + "newDirectory": "디렉터리", "openIn": "열기", "openInTerminal": "터미널에서 열기", "actions": { @@ -996,7 +999,15 @@ "rolledBackFiles": "{count, plural, one {#개 파일을 롤백했습니다} other {#개 파일을 롤백했습니다}}", "savedAsCopy": "사본으로 저장했습니다", "saveCopyFailed": "사본으로 저장하지 못했습니다", - "watchStartFailed": "파일 감시 시작에 실패했습니다" + "watchStartFailed": "파일 감시 시작에 실패했습니다", + "createFailed": "생성 실패" + }, + "createDialog": { + "newFile": "새 파일", + "newDirectory": "새 디렉터리", + "description": "새 {kind}의 이름을 입력하세요.", + "placeholderFile": "file-name.ext", + "placeholderDirectory": "folder-name" }, "renameDialog": { "renameDirectory": "디렉터리 이름 변경", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index a473c07..520912d 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -967,6 +967,9 @@ "attachToCurrentSession": "Anexar à sessão atual", "compareWithBranch": "Comparar com branch...", "reloadFromDisk": "Recarregar do disco", + "new": "Novo", + "newFile": "Arquivo", + "newDirectory": "Diretório", "openIn": "Abrir em", "openInTerminal": "Abrir no terminal", "actions": { @@ -996,7 +999,15 @@ "rolledBackFiles": "{count, plural, one {# arquivo revertido} other {# arquivos revertidos}}", "savedAsCopy": "Salvo como cópia", "saveCopyFailed": "Falha ao salvar como cópia", - "watchStartFailed": "Falha ao iniciar monitoramento de arquivos" + "watchStartFailed": "Falha ao iniciar monitoramento de arquivos", + "createFailed": "Falha ao criar" + }, + "createDialog": { + "newFile": "Novo arquivo", + "newDirectory": "Novo diretório", + "description": "Digite um nome para o novo {kind}.", + "placeholderFile": "file-name.ext", + "placeholderDirectory": "folder-name" }, "renameDialog": { "renameDirectory": "Renomear diretório", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 8ce10bf..185a4c8 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -967,6 +967,9 @@ "attachToCurrentSession": "附加到当前会话", "compareWithBranch": "与分支比较...", "reloadFromDisk": "从磁盘重新加载", + "new": "新建", + "newFile": "文件", + "newDirectory": "目录", "openIn": "打开于", "openInTerminal": "在终端打开", "actions": { @@ -996,7 +999,15 @@ "rolledBackFiles": "已回滚 {count} 个文件", "savedAsCopy": "已另存为副本", "saveCopyFailed": "另存为副本失败", - "watchStartFailed": "文件监听启动失败" + "watchStartFailed": "文件监听启动失败", + "createFailed": "创建失败" + }, + "createDialog": { + "newFile": "新建文件", + "newDirectory": "新建目录", + "description": "输入新{kind}的名称。", + "placeholderFile": "文件名.ext", + "placeholderDirectory": "文件夹名" }, "renameDialog": { "renameDirectory": "重命名目录", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index ad716b1..5eb6f89 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -967,6 +967,9 @@ "attachToCurrentSession": "附加到目前會話", "compareWithBranch": "與分支比較...", "reloadFromDisk": "從磁碟重新載入", + "new": "新建", + "newFile": "檔案", + "newDirectory": "目錄", "openIn": "開啟於", "openInTerminal": "在終端開啟", "actions": { @@ -996,7 +999,15 @@ "rolledBackFiles": "已回滾 {count} 個檔案", "savedAsCopy": "已另存為副本", "saveCopyFailed": "另存為副本失敗", - "watchStartFailed": "檔案監聽啟動失敗" + "watchStartFailed": "檔案監聽啟動失敗", + "createFailed": "建立失敗" + }, + "createDialog": { + "newFile": "新建檔案", + "newDirectory": "新建目錄", + "description": "輸入新{kind}的名稱。", + "placeholderFile": "file-name.ext", + "placeholderDirectory": "folder-name" }, "renameDialog": { "renameDirectory": "重新命名目錄", diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 30710fb..a446671 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -850,6 +850,15 @@ export async function deleteFileTreeEntry( return invoke("delete_file_tree_entry", { rootPath, path }) } +export async function createFileTreeEntry( + rootPath: string, + path: string, + name: string, + kind: "file" | "dir" +): Promise { + return invoke("create_file_tree_entry", { rootPath, path, name, kind }) +} + export async function gitLog( path: string, limit?: number,