From 888ab5639baeea515ac0fcd67e378a29026fc181 Mon Sep 17 00:00:00 2001 From: Arnab Chakraborty <11457760+Rocky43007@users.noreply.github.com> Date: Thu, 4 Apr 2024 00:20:31 -0400 Subject: [PATCH] [ENG-1483] Right Click -> New File (#2276) * feat: Right Click -> New File Creates an empty file called `Untitled` and adds `(1) -> (n)` for n number of files with the same `Untitled` name. * feat: Seperate into Text File and Empty File * chore: `cargo fmt` * fix: Forgot to type convert to String * feat: Working creation * fix: i18n changes * More i18n * All added tags now translated * With proper types checking now! * Fix types * chore: formatting --- core/src/api/ephemeral_files.rs | 37 ++++- core/src/api/files.rs | 86 ++++++++++++ crates/utils/src/lib.rs | 7 + .../$libraryId/Explorer/ParentContextMenu.tsx | 126 ++++++++++++++---- interface/locales/by/common.json | 9 +- interface/locales/de/common.json | 14 +- interface/locales/en/common.json | 9 +- interface/locales/es/common.json | 14 +- interface/locales/fr/common.json | 9 +- interface/locales/it/common.json | 14 +- interface/locales/ja/common.json | 9 +- interface/locales/nl/common.json | 14 +- interface/locales/ru/common.json | 9 +- interface/locales/tr/common.json | 14 +- interface/locales/zh-CN/common.json | 14 +- interface/locales/zh-TW/common.json | 14 +- packages/client/src/core.ts | 10 ++ 17 files changed, 373 insertions(+), 36 deletions(-) diff --git a/core/src/api/ephemeral_files.rs b/core/src/api/ephemeral_files.rs index b79c761e5..a7eaa0f08 100644 --- a/core/src/api/ephemeral_files.rs +++ b/core/src/api/ephemeral_files.rs @@ -1,5 +1,5 @@ use crate::{ - api::utils::library, + api::{files::create_file, utils::library}, invalidate_query, library::Library, object::{ @@ -33,6 +33,14 @@ use super::{ }; const UNTITLED_FOLDER_STR: &str = "Untitled Folder"; +const UNTITLED_FILE_STR: &str = "Untitled"; +const UNTITLED_TEXT_FILE_STR: &str = "Untitled.txt"; + +#[derive(Type, Deserialize)] +enum EphemeralFileCreateContextTypes { + empty, + text, +} pub(crate) fn mount() -> AlphaRouter { R.router() @@ -80,6 +88,33 @@ pub(crate) fn mount() -> AlphaRouter { }, ) }) + .procedure("createFile", { + #[derive(Type, Deserialize)] + pub struct CreateEphemeralFileArgs { + pub path: PathBuf, + pub context: EphemeralFileCreateContextTypes, + pub name: Option, + } + R.with2(library()).mutation( + |(_, library), + CreateEphemeralFileArgs { + mut path, + name, + context, + }: CreateEphemeralFileArgs| async move { + match context { + EphemeralFileCreateContextTypes::empty => { + path.push(name.as_deref().unwrap_or(UNTITLED_FILE_STR)); + } + EphemeralFileCreateContextTypes::text => { + path.push(name.as_deref().unwrap_or(UNTITLED_TEXT_FILE_STR)); + } + } + + create_file(path, &library).await + }, + ) + }) .procedure("deleteFiles", { R.with2(library()) .mutation(|(_, library), paths: Vec| async move { diff --git a/core/src/api/files.rs b/core/src/api/files.rs index 9dad39be2..5cf3cf0e5 100644 --- a/core/src/api/files.rs +++ b/core/src/api/files.rs @@ -46,6 +46,14 @@ use tracing::{error, warn}; use super::{Ctx, R}; const UNTITLED_FOLDER_STR: &str = "Untitled Folder"; +const UNTITLED_FILE_STR: &str = "Untitled"; +const UNTITLED_TEXT_FILE_STR: &str = "Untitled.txt"; + +#[derive(Type, Deserialize)] +enum FileCreateContextTypes { + empty, + text, +} pub(crate) fn mount() -> AlphaRouter { R.router() @@ -294,6 +302,45 @@ pub(crate) fn mount() -> AlphaRouter { }, ) }) + .procedure("createFile", { + #[derive(Type, Deserialize)] + pub struct CreateFileArgs { + pub location_id: location::id::Type, + pub sub_path: Option, + pub name: Option, + pub context: FileCreateContextTypes, + } + R.with2(library()).mutation( + |(_, library), + CreateFileArgs { + location_id, + sub_path, + context, + name, + }: CreateFileArgs| async move { + let mut path = + get_location_path_from_location_id(&library.db, location_id).await?; + + if let Some(sub_path) = sub_path + .as_ref() + .and_then(|sub_path| sub_path.strip_prefix("/").ok()) + { + path.push(sub_path); + } + + match context { + FileCreateContextTypes::empty => { + path.push(name.as_deref().unwrap_or(UNTITLED_FILE_STR)) + } + FileCreateContextTypes::text => { + path.push(name.as_deref().unwrap_or(UNTITLED_TEXT_FILE_STR)) + } + } + + create_file(path, &library).await + }, + ) + }) .procedure("updateAccessTime", { R.with2(library()) .mutation(|(_, library), ids: Vec| async move { @@ -872,6 +919,45 @@ pub(super) async fn create_directory( .to_string()) } +pub(super) async fn create_file( + mut target_path: PathBuf, + library: &Library, +) -> Result { + match fs::metadata(&target_path).await { + Ok(metadata) if metadata.is_file() => { + target_path = find_available_filename_for_duplicate(&target_path).await?; + } + Ok(_) => { + return Err(FileSystemJobsError::WouldOverwrite(target_path.into_boxed_path()).into()) + } + Err(e) if e.kind() == io::ErrorKind::NotFound => { + // Everything is awesome! + } + Err(e) => { + return Err(FileIOError::from(( + target_path, + e, + "Failed to access file system and get metadata on file to be created", + )) + .into()) + } + }; + + fs::File::create(&target_path) + .await + .map_err(|e| FileIOError::from((&target_path, e, "Failed to create file")))?; + + invalidate_query!(library, "search.objects"); + invalidate_query!(library, "search.paths"); + invalidate_query!(library, "search.ephemeralPaths"); + + Ok(target_path + .file_name() + .expect("Failed to get file name") + .to_string_lossy() + .to_string()) +} + #[derive(Type, Deserialize)] pub struct FromPattern { pub pattern: String, diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs index 87bc193bf..d8e2236b7 100644 --- a/crates/utils/src/lib.rs +++ b/crates/utils/src/lib.rs @@ -39,3 +39,10 @@ macro_rules! msgpack { value }} } + +// Only used for testing purposes. Do not use in production code. +use std::any::type_name; + +pub fn test_type_of(_: T) -> &'static str { + type_name::() +} diff --git a/interface/app/$libraryId/Explorer/ParentContextMenu.tsx b/interface/app/$libraryId/Explorer/ParentContextMenu.tsx index 65a9e0468..c6582804e 100644 --- a/interface/app/$libraryId/Explorer/ParentContextMenu.tsx +++ b/interface/app/$libraryId/Explorer/ParentContextMenu.tsx @@ -1,9 +1,11 @@ import { Clipboard, + FilePlus, FileX, FolderPlus, Hash, Image, + Notepad, Repeat, Share, ShieldCheck @@ -40,21 +42,57 @@ export default (props: PropsWithChildren) => { const cutEphemeralFiles = useLibraryMutation('ephemeralFiles.cutFiles'); const createFolder = useLibraryMutation(['files.createFolder'], { onError: (e) => { - toast.error({ title: 'Error creating folder', body: `Error: ${e}.` }); + toast.error({ title: t('create_folder_error'), body: `Error: ${e}.` }); console.error(e); }, onSuccess: (folder) => { - toast.success({ title: `Created new folder "${folder}"` }); + toast.success({ + title: t("create_folder_success", { + name: folder + }) + }); + rescan(); + } + }); + const createFile = useLibraryMutation(['files.createFile'], { + onError: (e) => { + toast.error({ title: t('create_file_error'), body: `${e}.` }); + console.error(e); + }, + onSuccess: (file) => { + toast.success({ + title: t("create_file_success", { + name: file + }) + }); rescan(); } }); const createEphemeralFolder = useLibraryMutation(['ephemeralFiles.createFolder'], { onError: (e) => { - toast.error({ title: 'Error creating folder', body: `Error: ${e}.` }); + toast.error({ title: t('create_folder_error'), body: `Error: ${e}.` }); console.error(e); }, onSuccess: (folder) => { - toast.success({ title: `Created new folder "${folder}"` }); + toast.success({ + title: t("create_folder_success", { + name: folder + }) + }); + rescan(); + } + }); + const createEphemeralFile = useLibraryMutation(['ephemeralFiles.createFile'], { + onError: (e) => { + toast.error({ title: t('create_file_error'), body: `${e}.` }); + console.error(e); + }, + onSuccess: (file) => { + toast.success({ + title: t("create_file_success", { + name: file + }) + }); rescan(); } }); @@ -89,25 +127,67 @@ export default (props: PropsWithChildren) => { )} - - { - if (parent?.type === 'Location') { - createFolder.mutate({ - location_id: parent.location.id, - sub_path: currentPath || null, - name: null - }); - } else if (parent?.type === 'Ephemeral') { - createEphemeralFolder.mutate({ - path: parent?.path, - name: null - }); - } - }} - /> + + { + if (parent?.type === 'Location') { + createFolder.mutate({ + location_id: parent.location.id, + sub_path: currentPath || null, + name: null + }); + } else if (parent?.type === 'Ephemeral') { + createEphemeralFolder.mutate({ + path: parent?.path, + name: null + }); + } + }} + /> + + { + if (parent?.type === 'Location') { + createFile.mutate({ + location_id: parent.location.id, + sub_path: currentPath || null, + name: null, + context: 'text' + }); + } else if (parent?.type === 'Ephemeral') { + createEphemeralFile.mutate({ + path: parent?.path, + context: 'text', + name: null + }); + } + }} + /> + { + if (parent?.type === 'Location') { + createFile.mutate({ + location_id: parent.location.id, + sub_path: currentPath || null, + name: null, + context: 'empty' + }); + } else if (parent?.type === 'Ephemeral') { + createEphemeralFile.mutate({ + path: parent?.path, + context: 'empty', + name: null + }); + } + }} + /> + )} diff --git a/interface/locales/by/common.json b/interface/locales/by/common.json index 2ce01e9cc..1e2680366 100644 --- a/interface/locales/by/common.json +++ b/interface/locales/by/common.json @@ -457,5 +457,12 @@ "search_for_files_and_actions": "Pesquisar por arquivos e ações...", "toggle_command_palette": "Alternar paleta de comandos", "pin": "шпілька", - "rescan": "паўторнае сканаванне" + "rescan": "паўторнае сканаванне", + "create_file_error": "Error creating file", + "create_file_success": "Created new file: {{name}}", + "create_folder_error": "Error creating folder", + "create_folder_success": "Created new folder: {{name}}", + "empty_file": "Empty file", + "new": "New", + "text_file": "Text File" } diff --git a/interface/locales/de/common.json b/interface/locales/de/common.json index ec24bcf8d..a59578183 100644 --- a/interface/locales/de/common.json +++ b/interface/locales/de/common.json @@ -452,5 +452,17 @@ "search_for_files_and_actions": "Nach Dateien und Aktionen suchen...", "toggle_command_palette": "Befehlspalette umschalten", "pin": "Stift", - "rescan": "Erneut scannen" + "rescan": "Erneut scannen", + "create_file_error": "Fehler beim Erstellen der Datei", + "create_file_success": "Neue Datei erstellt: {{name}}", + "create_folder_error": "Fehler beim Erstellen des Ordners", + "create_folder_success": "Neuer Ordner erstellt: {{name}}", + "empty_file": "Leere Akte", + "new": "Neu", + "size_b": "B", + "size_gb": "GB", + "size_kb": "kB", + "size_mb": "MB", + "size_tb": "TB", + "text_file": "Textdatei" } diff --git a/interface/locales/en/common.json b/interface/locales/en/common.json index 1c97aec10..769930c5b 100644 --- a/interface/locales/en/common.json +++ b/interface/locales/en/common.json @@ -294,7 +294,14 @@ "networking": "Networking", "networking_port": "Networking Port", "networking_port_description": "The port for Spacedrive's Peer-to-peer networking to communicate on. You should leave this disabled unless you have a restrictive firewall. Do not expose to the internet!", - "new_folder": "New folder", + "new": "New", + "new_folder": "Folder", + "text_file": "Text File", + "empty_file": "Empty file", + "create_folder_error":"Error creating folder", + "create_file_error":"Error creating file", + "create_folder_success":"Created new folder: {{name}}", + "create_file_success":"Created new file: {{name}}", "new_library": "New library", "new_location": "New location", "new_location_web_description": "As you are using the browser version of Spacedrive you will (for now) need to specify an absolute URL of a directory local to the remote node.", diff --git a/interface/locales/es/common.json b/interface/locales/es/common.json index af06d1fb7..49b0097a9 100644 --- a/interface/locales/es/common.json +++ b/interface/locales/es/common.json @@ -452,5 +452,17 @@ "search_for_files_and_actions": "Buscar archivos y acciones...", "toggle_command_palette": "Alternar paleta de comandos", "pin": "Alfiler", - "rescan": "Volver a escanear" + "rescan": "Volver a escanear", + "create_file_error": "Error al crear el archivo", + "create_file_success": "Nuevo archivo creado: {{name}}", + "create_folder_error": "Error al crear la carpeta", + "create_folder_success": "Nueva carpeta creada: {{name}}", + "empty_file": "Archivo vacío", + "new": "Nuevo", + "size_b": "B", + "size_gb": "ES", + "size_kb": "kB", + "size_mb": "MEGABYTE", + "size_tb": "tuberculosis", + "text_file": "Archivo de texto" } diff --git a/interface/locales/fr/common.json b/interface/locales/fr/common.json index dd99a88e5..e41899ced 100644 --- a/interface/locales/fr/common.json +++ b/interface/locales/fr/common.json @@ -457,5 +457,12 @@ "search_for_files_and_actions": "Rechercher des fichiers et des actions...", "toggle_command_palette": "Basculer la palette de commandes", "pin": "Épingle", - "rescan": "Nouvelle analyse" + "rescan": "Nouvelle analyse", + "create_file_error": "Erreur lors de la création du fichier", + "create_file_success": "Nouveau fichier créé : {{name}}", + "create_folder_error": "Erreur lors de la création du dossier", + "create_folder_success": "Nouveau dossier créé : {{name}}", + "empty_file": "Fichier vide", + "new": "Nouveau", + "text_file": "Fichier texte" } diff --git a/interface/locales/it/common.json b/interface/locales/it/common.json index 545161b40..b18ae8be0 100644 --- a/interface/locales/it/common.json +++ b/interface/locales/it/common.json @@ -452,5 +452,17 @@ "version": "Versione {{versione}}", "view_changes": "Visualizza modifiche", "pin": "Spillo", - "rescan": "Nuova scansione" + "rescan": "Nuova scansione", + "create_file_error": "Errore durante la creazione del file", + "create_file_success": "Nuovo file creato: {{name}}", + "create_folder_error": "Errore durante la creazione della cartella", + "create_folder_success": "Creata una nuova cartella: {{name}}", + "empty_file": "File vuoto", + "new": "Nuovo", + "size_b": "B", + "size_gb": "GB", + "size_kb": "kB", + "size_mb": "MB", + "size_tb": "TBC", + "text_file": "File di testo" } diff --git a/interface/locales/ja/common.json b/interface/locales/ja/common.json index 9b5585e6f..1549a3dcf 100644 --- a/interface/locales/ja/common.json +++ b/interface/locales/ja/common.json @@ -457,5 +457,12 @@ "search_for_files_and_actions": "ファイルとアクションを検索します...", "toggle_command_palette": "コマンドパレットの切り替え", "pin": "ピン", - "rescan": "再スキャン" + "rescan": "再スキャン", + "create_file_error": "ファイル作成エラー", + "create_file_success": "新しいファイルを作成しました: {{name}}", + "create_folder_error": "フォルダー作成エラー", + "create_folder_success": "新しいフォルダーを作成しました: {{name}}", + "empty_file": "空のファイル", + "new": "新しい", + "text_file": "テキストファイル" } diff --git a/interface/locales/nl/common.json b/interface/locales/nl/common.json index ca411914b..e68cc37e3 100644 --- a/interface/locales/nl/common.json +++ b/interface/locales/nl/common.json @@ -452,5 +452,17 @@ "search_for_files_and_actions": "Zoeken naar bestanden en acties...", "toggle_command_palette": "Schakel het opdrachtpalet in of uit", "pin": "Pin", - "rescan": "Opnieuw scannen" + "rescan": "Opnieuw scannen", + "create_file_error": "Fout bij aanmaken van bestand", + "create_file_success": "Nieuw bestand gemaakt: {{name}}", + "create_folder_error": "Fout bij maken van map", + "create_folder_success": "Nieuwe map gemaakt: {{name}}", + "empty_file": "Leeg bestand", + "new": "Nieuw", + "size_b": "B", + "size_gb": "GB", + "size_kb": "KB", + "size_mb": "MB", + "size_tb": "TB", + "text_file": "Tekstbestand" } diff --git a/interface/locales/ru/common.json b/interface/locales/ru/common.json index 1360530ba..3a43f15cb 100644 --- a/interface/locales/ru/common.json +++ b/interface/locales/ru/common.json @@ -457,5 +457,12 @@ "search_for_files_and_actions": "Поиск файлов и действий...", "toggle_command_palette": "Переключить палитру команд", "pin": "приколоть", - "rescan": "Повторное сканирование" + "rescan": "Повторное сканирование", + "create_file_error": "Ошибка создания файла", + "create_file_success": "Создан новый файл: {{name}}", + "create_folder_error": "Ошибка создания папки", + "create_folder_success": "Создана новая папка: {{name}}.", + "empty_file": "Пустой файл", + "new": "Новый", + "text_file": "Текстовый файл" } diff --git a/interface/locales/tr/common.json b/interface/locales/tr/common.json index 329cccf56..794151863 100644 --- a/interface/locales/tr/common.json +++ b/interface/locales/tr/common.json @@ -452,5 +452,17 @@ "search_for_files_and_actions": "Dosyaları ve eylemleri arayın...", "toggle_command_palette": "Komut paletini değiştir", "pin": "Toplu iğne", - "rescan": "Yeniden tara" + "rescan": "Yeniden tara", + "create_file_error": "Dosya oluşturulurken hata oluştu", + "create_file_success": "Yeni dosya oluşturuldu: {{name}}", + "create_folder_error": "Klasör oluşturulurken hata oluştu", + "create_folder_success": "Yeni klasör oluşturuldu: {{name}}", + "empty_file": "Boş dosya", + "new": "Yeni", + "size_b": "B", + "size_gb": "Büyük Britanya", + "size_kb": "kB", + "size_mb": "MB", + "size_tb": "verem", + "text_file": "Metin dosyası" } diff --git a/interface/locales/zh-CN/common.json b/interface/locales/zh-CN/common.json index 2a147a5ac..bbf6af094 100644 --- a/interface/locales/zh-CN/common.json +++ b/interface/locales/zh-CN/common.json @@ -452,5 +452,17 @@ "search_for_files_and_actions": "搜索文件和操作...", "toggle_command_palette": "切换命令面板", "pin": "别针", - "rescan": "重新扫描" + "rescan": "重新扫描", + "create_file_error": "创建文件时出错", + "create_file_success": "创建新文件:{{name}}", + "create_folder_error": "创建文件夹时出错", + "create_folder_success": "创建了新文件夹:{{name}}", + "empty_file": "空的文件", + "new": "新的", + "size_b": "乙", + "size_gb": "国标", + "size_kb": "千字节", + "size_mb": "MB", + "size_tb": "结核病", + "text_file": "文本文件" } diff --git a/interface/locales/zh-TW/common.json b/interface/locales/zh-TW/common.json index ba7a0221e..c1e646cc0 100644 --- a/interface/locales/zh-TW/common.json +++ b/interface/locales/zh-TW/common.json @@ -452,5 +452,17 @@ "search_for_files_and_actions": "搜尋文件和操作...", "toggle_command_palette": "切換命令面板", "pin": "別針", - "rescan": "重新掃描" + "rescan": "重新掃描", + "create_file_error": "建立文件時出錯", + "create_file_success": "建立新檔案:{{name}}", + "create_folder_error": "建立資料夾時出錯", + "create_folder_success": "建立了新資料夾:{{name}}", + "empty_file": "空的文件", + "new": "新的", + "size_b": "乙", + "size_gb": "國標", + "size_kb": "千位元組", + "size_mb": "MB", + "size_tb": "結核病", + "text_file": "文字檔案" } diff --git a/packages/client/src/core.ts b/packages/client/src/core.ts index a81a826e5..8011d872b 100644 --- a/packages/client/src/core.ts +++ b/packages/client/src/core.ts @@ -69,12 +69,14 @@ export type Procedures = { { key: "cloud.locations.testing", input: TestingParams, result: null } | { key: "cloud.setApiOrigin", input: string, result: null } | { key: "ephemeralFiles.copyFiles", input: LibraryArgs, result: null } | + { key: "ephemeralFiles.createFile", input: LibraryArgs, result: string } | { key: "ephemeralFiles.createFolder", input: LibraryArgs, result: string } | { key: "ephemeralFiles.cutFiles", input: LibraryArgs, result: null } | { key: "ephemeralFiles.deleteFiles", input: LibraryArgs, result: null } | { key: "ephemeralFiles.renameFile", input: LibraryArgs, result: null } | { key: "files.convertImage", input: LibraryArgs, result: null } | { key: "files.copyFiles", input: LibraryArgs, result: null } | + { key: "files.createFile", input: LibraryArgs, result: string } | { key: "files.createFolder", input: LibraryArgs, result: string } | { key: "files.cutFiles", input: LibraryArgs, result: null } | { key: "files.deleteFiles", input: LibraryArgs, result: null } | @@ -201,8 +203,12 @@ export type ConvertImageArgs = { location_id: number; file_path_id: number; dele export type ConvertibleExtension = "bmp" | "dib" | "ff" | "gif" | "ico" | "jpg" | "jpeg" | "png" | "pnm" | "qoi" | "tga" | "icb" | "vda" | "vst" | "tiff" | "tif" | "hif" | "heif" | "heifs" | "heic" | "heics" | "avif" | "avci" | "avcs" | "svg" | "svgz" | "pdf" | "webp" +export type CreateEphemeralFileArgs = { path: string; context: EphemeralFileCreateContextTypes; name: string | null } + export type CreateEphemeralFolderArgs = { path: string; name: string | null } +export type CreateFileArgs = { location_id: number; sub_path: string | null; name: string | null; context: FileCreateContextTypes } + export type CreateFolderArgs = { location_id: number; sub_path: string | null; name: string | null } export type CreateLibraryArgs = { name: LibraryName; default_locations: DefaultLocations | null } @@ -223,6 +229,8 @@ export type DoubleClickAction = "openFile" | "quickPreview" export type EditLibraryArgs = { id: string; name: LibraryName | null; description: MaybeUndefined } +export type EphemeralFileCreateContextTypes = "empty" | "text" + export type EphemeralFileSystemOps = { sources: string[]; target_dir: string } export type EphemeralPathOrder = { field: "name"; value: SortOrder } | { field: "sizeInBytes"; value: SortOrder } | { field: "dateCreated"; value: SortOrder } | { field: "dateModified"; value: SortOrder } @@ -254,6 +262,8 @@ export type ExplorerSettings = { layoutMode: ExplorerLayout | null; grid export type Feedback = { message: string; emoji: number } +export type FileCreateContextTypes = "empty" | "text" + export type FilePath = { id: number; pub_id: number[]; is_dir: boolean | null; cas_id: string | null; integrity_checksum: string | null; location_id: number | null; materialized_path: string | null; name: string | null; extension: string | null; hidden: boolean | null; size_in_bytes: string | null; size_in_bytes_bytes: number[] | null; inode: number[] | null; object_id: number | null; key_id: number | null; date_created: string | null; date_modified: string | null; date_indexed: string | null } export type FilePathCursor = { isDir: boolean; variant: FilePathCursorVariant }