Skip to content

Commit 9f77fcf

Browse files
committed
feat(FR-1014): implement text file editor for NEO File Explorer (#4966)
Resolves #3686 ([FR-1014](https://lablup.atlassian.net/browse/FR-1014)) ## Summary Implement the ability to directly edit and save simple text files within the NEO File Explorer. ## Background Users frequently need to modify configuration files such as yaml, toml, and json within vfolders for model serving. Currently, this requires downloading the file → editing locally → re-uploading, which results in a poor user experience. ## Implementation ### Key Features - **Edit button in More menu**: Added dropdown menu with edit option in File Explorer controls - **Text file detection**: Supports .txt, .md, .json, .yaml, .yml, .xml, .csv, .js, .ts, .jsx, .tsx, .py, .sh, .bash, .html, .css, .scss, .less, .sql, .log, .env, .conf, .config, .ini, .toml - **CodeMirror editor**: Full-featured text editor with syntax highlighting - **File operations**: Download → Edit → Upload workflow using existing APIs ### Components Added - `TextFileEditorModal.tsx`: Main editor modal with BAICodeEditor - Enhanced `FileItemControls.tsx`: Added More menu with Edit option - Enhanced `BAIFileExplorer.tsx`: Added `enableEdit` and `onClickEditFile` props - Updated `FolderExplorerModal.tsx`: Integration with TextFileEditorModal ### Technical Details - **File Reading**: `request_download_token` → `fetch` → `blob.text()` - **File Saving**: Modified content → `new File()` → `FileUploadManager.uploadFiles()` (overwrites) - **Permissions**: Requires `write_content` permission - **Size Limit**: `baiClient._config.maxFileUploadSize` ### i18n Support - `comp:FileExplorer.EditFile`: "Edit" - `data.explorer.EditFile`: "Edit File" - `data.explorer.FailedToLoadFile": "Failed to load file" **Checklist:** - [x] Documentation (PR description) - [ ] Minimum required manager version (N/A - client-side feature) - [ ] Specific setting for review: Test with various text files in File Explorer - [x] Minimum requirements: `write_content` permission on vfolder - [x] Test cases: Edit .txt, .yaml, .json files; verify save/cancel functionality [FR-1014]: https://lablup.atlassian.net/browse/FR-1014 ### test https://github.com/user-attachments/assets/f067e671-999a-4f20-9f7f-1c13f1d92e00
1 parent 94f0197 commit 9f77fcf

48 files changed

Lines changed: 438 additions & 22 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/backend.ai-ui/src/components/baiClient/FileExplorer/BAIFileExplorer.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ export interface BAIFileExplorerProps {
5555
enableDownload?: boolean;
5656
enableDelete?: boolean;
5757
enableWrite?: boolean;
58+
enableEdit?: boolean;
5859
onChangeFetchKey?: (fetchKey: string) => void;
5960
ref?: React.Ref<BAIFileExplorerRef>;
6061
onDeleteFilesInBackground?: (
@@ -64,6 +65,7 @@ export interface BAIFileExplorerProps {
6465
) => void;
6566
// FIXME: need to delete when `delete-file-async` API returns deleting file paths
6667
deletingFilePaths?: Array<string>;
68+
onClickEditFile?: (file: VFolderFile, currentPath: string) => void;
6769
}
6870

6971
const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
@@ -75,11 +77,15 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
7577
enableDownload = false,
7678
enableDelete = false,
7779
enableWrite = false,
80+
enableEdit = false,
7881
onDeleteFilesInBackground,
7982
deletingFilePaths,
83+
onClickEditFile,
8084
style,
8185
ref,
8286
}) => {
87+
'use memo';
88+
8389
const { t } = useTranslation();
8490
const { token } = theme.useToken();
8591

@@ -214,8 +220,10 @@ const BAIFileExplorer: React.FC<BAIFileExplorerProps> = ({
214220
onClickDelete={() => {
215221
setSelectedSingleItem(record);
216222
}}
223+
onClickEdit={() => onClickEditFile?.(record, currentPath)}
217224
enableDownload={enableDownload}
218225
enableDelete={enableDelete}
226+
enableEdit={enableEdit}
219227
deleteButtonProps={{ loading: isPendingDelete }}
220228
/>
221229
</Suspense>

packages/backend.ai-ui/src/components/baiClient/FileExplorer/FileItemControls.tsx

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,48 @@
1+
import { filterOutEmpty } from '../../../helper';
2+
import { useTanMutation } from '../../../helper/reactQueryAlias';
13
import { BAITrashBinIcon } from '../../../icons';
2-
import { BAIButtonProps } from '../../BAIButton';
4+
import BAIButton, { BAIButtonProps } from '../../BAIButton';
35
import BAIFlex from '../../BAIFlex';
46
import useConnectedBAIClient from '../../provider/BAIClientProvider/hooks/useConnectedBAIClient';
57
import { VFolderFile } from '../../provider/BAIClientProvider/types';
68
import { FolderInfoContext } from './BAIFileExplorer';
7-
import { useMutation } from '@tanstack/react-query';
8-
import { App, Button, theme } from 'antd';
9-
import { DownloadIcon } from 'lucide-react';
9+
import { MoreOutlined } from '@ant-design/icons';
10+
import { App, theme, Dropdown, Tooltip } from 'antd';
11+
import type { MenuProps } from 'antd';
12+
import { DownloadIcon, EditIcon } from 'lucide-react';
1013
import { use } from 'react';
1114
import { useTranslation } from 'react-i18next';
1215

1316
interface FileItemControlsProps {
1417
selectedItem: VFolderFile;
1518
onClickDelete: () => void;
19+
onClickEdit?: () => void;
1620
enableDownload?: boolean;
1721
enableDelete?: boolean;
22+
enableEdit?: boolean;
1823
downloadButtonProps?: BAIButtonProps;
1924
deleteButtonProps?: BAIButtonProps;
2025
}
2126

2227
const FileItemControls: React.FC<FileItemControlsProps> = ({
2328
selectedItem,
2429
onClickDelete,
30+
onClickEdit,
2531
enableDownload = false,
2632
enableDelete = false,
33+
enableEdit = false,
2734
downloadButtonProps,
2835
deleteButtonProps,
2936
}) => {
37+
'use memo';
38+
3039
const { t } = useTranslation();
3140
const { token } = theme.useToken();
3241
const { message } = App.useApp();
3342
const { targetVFolderId, currentPath } = use(FolderInfoContext);
3443
const baiClient = useConnectedBAIClient();
3544

36-
const downloadFileMutation = useMutation({
45+
const downloadFileMutation = useTanMutation({
3746
mutationFn: async ({
3847
fileName,
3948
currentFolder,
@@ -73,31 +82,46 @@ const FileItemControls: React.FC<FileItemControlsProps> = ({
7382
},
7483
});
7584

76-
const handleDownload = () => {
77-
if (!selectedItem || downloadFileMutation.isPending) return;
85+
const isDirectory = selectedItem.type === 'DIRECTORY';
7886

79-
downloadFileMutation.mutate({
80-
fileName: `${currentPath}/${selectedItem.name}`,
81-
currentFolder: targetVFolderId,
82-
archive: selectedItem.type === 'DIRECTORY',
83-
});
84-
};
87+
const dropdownMenuItems: MenuProps['items'] = filterOutEmpty([
88+
{
89+
key: 'fileEdit',
90+
icon: <EditIcon />,
91+
label: isDirectory ? (
92+
<Tooltip title={t('comp:FileExplorer.UnsupportedFileFormat')}>
93+
<span>{t('comp:FileExplorer.EditFile')}</span>
94+
</Tooltip>
95+
) : (
96+
t('comp:FileExplorer.EditFile')
97+
),
98+
disabled: !enableEdit || isDirectory,
99+
onClick: (e) => {
100+
e.domEvent.stopPropagation();
101+
onClickEdit?.();
102+
},
103+
},
104+
]);
85105

86106
return (
87107
<BAIFlex gap="xs">
88-
<Button
108+
<BAIButton
89109
type="text"
90110
size="small"
91111
icon={<DownloadIcon color={token.colorInfo} />}
92-
disabled={!enableDownload || downloadFileMutation.isPending}
93-
loading={downloadFileMutation.isPending}
94-
onClick={(e) => {
95-
e.stopPropagation();
96-
handleDownload();
112+
disabled={!enableDownload}
113+
onClick={(e) => e.stopPropagation()}
114+
action={async () => {
115+
if (!selectedItem) return;
116+
await downloadFileMutation.mutateAsync({
117+
fileName: `${currentPath}/${selectedItem.name}`,
118+
currentFolder: targetVFolderId,
119+
archive: isDirectory,
120+
});
97121
}}
98122
{...downloadButtonProps}
99123
/>
100-
<Button
124+
<BAIButton
101125
type="text"
102126
size="small"
103127
icon={<BAITrashBinIcon style={{ color: token.colorError }} />}
@@ -108,6 +132,23 @@ const FileItemControls: React.FC<FileItemControlsProps> = ({
108132
}}
109133
{...deleteButtonProps}
110134
/>
135+
<Dropdown
136+
menu={{
137+
items: dropdownMenuItems,
138+
}}
139+
trigger={['click']}
140+
>
141+
<BAIButton
142+
type="text"
143+
size="small"
144+
onClick={(e) => {
145+
e.stopPropagation();
146+
}}
147+
icon={<MoreOutlined />}
148+
aria-label={t('comp:FileExplorer.MoreOptions')}
149+
style={{ color: token.colorTextSecondary }}
150+
/>
151+
</Dropdown>
111152
</BAIFlex>
112153
);
113154
};
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export { default as BAIClientProvider } from './BAIClientProvider';
22
export type { BAIClientProviderProps } from './BAIClientProvider';
33
export { BAIClientContext, BAIAnonymousClientContext } from './context';
4-
export type { BAIClient } from './types';
4+
export type { BAIClient, VFolderFile } from './types';
55
export { default as useConnectedBAIClient } from './hooks/useConnectedBAIClient';
66
export { default as useAnonymousBAIClient } from './hooks/useAnonymousBAIClient';

packages/backend.ai-ui/src/locale/de.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,17 +197,20 @@
197197
"DragAndDropDesc": "Ziehen Sie Dateien in diesen Bereich zum Hochladen.",
198198
"DuplicatedFiles": "Überschreibung der Bestätigung",
199199
"DuplicatedFilesDesc": "Die Datei oder der Ordner mit demselben Namen existieren bereits. \nMöchten Sie überschreiben?",
200+
"EditFile": "Datei bearbeiten",
200201
"FolderCreatedSuccessfully": "Ordner erfolgreich erstellt.",
201202
"FolderName": "Ordner Name",
202203
"MaxFolderNameLength": "Der Ordnername muss 255 Zeichen oder weniger betragen.",
203204
"ModifiedAt": "Modifiziert bei",
205+
"MoreOptions": "Weitere Optionen",
204206
"Name": "Name",
205207
"PleaseEnterAFolderName": "Bitte geben Sie den Ordneramen ein.",
206208
"RenameSuccess": "Der Name wurde erfolgreich geändert.",
207209
"SelectedItemsDeletedSuccessfully": "Ausgewählte Dateien und Ordner wurden erfolgreich gelöscht.",
208210
"Size": "Größe",
209211
"TypeDeleteToConfirm": "Geben Sie zur Bestätigung „Löschen“ ein.",
210212
"TypeFolderNameToDelete": "Geben Sie den Namen des zu löschenden Ordners oder der Datei ein.",
213+
"UnsupportedFileFormat": "Dieses Dateiformat unterstützt keine Bearbeitung.",
211214
"UploadFiles": "Dateien hochladen",
212215
"UploadFolder": "Ordner hochladen",
213216
"error": {

packages/backend.ai-ui/src/locale/el.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,17 +197,20 @@
197197
"DragAndDropDesc": "Σύρετε και αποθέστε αρχεία σε αυτήν την περιοχή για μεταφόρτωση.",
198198
"DuplicatedFiles": "Αντιπροσώπηση επιβεβαίωσης",
199199
"DuplicatedFilesDesc": "Το αρχείο ή ο φάκελος με το ίδιο όνομα υπάρχει ήδη. \nΘέλετε να αντικαταστήσετε;",
200+
"EditFile": "Επεξεργασία αρχείου",
200201
"FolderCreatedSuccessfully": "Ο φάκελος δημιούργησε με επιτυχία.",
201202
"FolderName": "Όνομα φακέλου",
202203
"MaxFolderNameLength": "Το όνομα του φακέλου πρέπει να είναι 255 χαρακτήρες ή λιγότερο.",
203204
"ModifiedAt": "Τροποποιημένος",
205+
"MoreOptions": "Περισσότερες επιλογές",
204206
"Name": "Ονομα",
205207
"PleaseEnterAFolderName": "Εισαγάγετε το όνομα του φακέλου.",
206208
"RenameSuccess": "Το όνομα έχει αλλάξει με επιτυχία.",
207209
"SelectedItemsDeletedSuccessfully": "Επιλεγμένα αρχεία και φακέλους έχουν διαγραφεί με επιτυχία.",
208210
"Size": "Μέγεθος",
209211
"TypeDeleteToConfirm": "Πληκτρολογήστε «Διαγράφω» για επιβεβαίωση.",
210212
"TypeFolderNameToDelete": "Πληκτρολογήστε φάκελο ή όνομα αρχείου για διαγραφή.",
213+
"UnsupportedFileFormat": "Αυτή η μορφή αρχείου δεν υποστηρίζει επεξεργασία.",
211214
"UploadFiles": "Μεταφόρτωση αρχείων",
212215
"UploadFolder": "Μεταφορτωμένος φάκελος",
213216
"error": {

packages/backend.ai-ui/src/locale/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,17 +200,20 @@
200200
"DragAndDropDesc": "Drag and drop files to this area to upload.",
201201
"DuplicatedFiles": "Overwrite Confirmation",
202202
"DuplicatedFilesDesc": "The file or folder with the same name already exists. Do you want to overwrite?",
203+
"EditFile": "Edit File",
203204
"FolderCreatedSuccessfully": "Folder created successfully.",
204205
"FolderName": "Folder Name",
205206
"MaxFolderNameLength": "Folder name must be 255 characters or less.",
206207
"ModifiedAt": "Modified At",
208+
"MoreOptions": "More options",
207209
"Name": "Name",
208210
"PleaseEnterAFolderName": "Please enter the folder name.",
209211
"RenameSuccess": "The name has been successfully changed.",
210212
"SelectedItemsDeletedSuccessfully": "Selected files and folders have been deleted successfully.",
211213
"Size": "Size",
212214
"TypeDeleteToConfirm": "Type 'Delete' to confirm.",
213215
"TypeFolderNameToDelete": "Type folder or file name to delete.",
216+
"UnsupportedFileFormat": "This file format does not support editing.",
214217
"UploadFiles": "Upload Files",
215218
"UploadFolder": "Upload Folder",
216219
"error": {

packages/backend.ai-ui/src/locale/es.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,17 +197,20 @@
197197
"DragAndDropDesc": "Arrastre y suelte archivos a esta área para cargar.",
198198
"DuplicatedFiles": "Confirmación de sobrescribencia",
199199
"DuplicatedFilesDesc": "El archivo o carpeta con el mismo nombre ya existe. \n¿Quieres sobrescribir?",
200+
"EditFile": "Editar archivo",
200201
"FolderCreatedSuccessfully": "Carpeta creada con éxito.",
201202
"FolderName": "Nombre de carpeta",
202203
"MaxFolderNameLength": "El nombre de la carpeta debe ser de 255 caracteres o menos.",
203204
"ModifiedAt": "Modificado en",
205+
"MoreOptions": "Más opciones",
204206
"Name": "Nombre",
205207
"PleaseEnterAFolderName": "Ingrese el nombre de la carpeta.",
206208
"RenameSuccess": "El nombre ha sido cambiado con éxito.",
207209
"SelectedItemsDeletedSuccessfully": "Los archivos y carpetas seleccionados se han eliminado correctamente.",
208210
"Size": "Tamaño",
209211
"TypeDeleteToConfirm": "Escriba \"Borrar\" para confirmar.",
210212
"TypeFolderNameToDelete": "Escriba el nombre de la carpeta o del archivo para eliminar.",
213+
"UnsupportedFileFormat": "Este formato de archivo no admite la edición.",
211214
"UploadFiles": "Cargar archivos",
212215
"UploadFolder": "Carpeta de carga",
213216
"error": {

packages/backend.ai-ui/src/locale/fi.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,17 +197,20 @@
197197
"DragAndDropDesc": "Vedä ja pudota tiedostoja tälle alueelle ladataksesi.",
198198
"DuplicatedFiles": "Korvata vahvistus",
199199
"DuplicatedFilesDesc": "Tiedosto tai kansio, jolla on sama nimi, on jo olemassa. \nHaluatko korvata?",
200+
"EditFile": "Muokkaa tiedostoa",
200201
"FolderCreatedSuccessfully": "Kansio luotu onnistuneesti.",
201202
"FolderName": "Kansionimi",
202203
"MaxFolderNameLength": "Kansion nimen on oltava enintään 255 merkkiä.",
203204
"ModifiedAt": "Muutettu",
205+
"MoreOptions": "Lisää vaihtoehtoja",
204206
"Name": "Nimi",
205207
"PleaseEnterAFolderName": "Anna kansionimi.",
206208
"RenameSuccess": "Nimi on muutettu onnistuneesti.",
207209
"SelectedItemsDeletedSuccessfully": "Valitut tiedostot ja kansiot on poistettu onnistuneesti.",
208210
"Size": "Koko",
209211
"TypeDeleteToConfirm": "Kirjoita 'Poistaa' vahvistaaksesi.",
210212
"TypeFolderNameToDelete": "Kirjoita kansio tai tiedostonimi poistaaksesi.",
213+
"UnsupportedFileFormat": "Tämä tiedostomuoto ei tue muokkaamista.",
211214
"UploadFiles": "Lähetä tiedostot",
212215
"UploadFolder": "Lataa kansio",
213216
"error": {

packages/backend.ai-ui/src/locale/fr.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,17 +197,20 @@
197197
"DragAndDropDesc": "Faites glisser et déposez les fichiers dans cette zone pour télécharger.",
198198
"DuplicatedFiles": "Écran de confirmation",
199199
"DuplicatedFilesDesc": "Le fichier ou le dossier avec le même nom existe déjà. \nVoulez-vous écraser?",
200+
"EditFile": "Modifier le fichier",
200201
"FolderCreatedSuccessfully": "Dossier créé avec succès.",
201202
"FolderName": "Nom du dossier",
202203
"MaxFolderNameLength": "Le nom du dossier doit comporter 255 caractères ou moins.",
203204
"ModifiedAt": "Modifié à",
205+
"MoreOptions": "Plus d'options",
204206
"Name": "Nom",
205207
"PleaseEnterAFolderName": "Veuillez saisir le nom du dossier.",
206208
"RenameSuccess": "Le nom a été modifié avec succès.",
207209
"SelectedItemsDeletedSuccessfully": "Les fichiers et dossiers sélectionnés ont été supprimés avec succès.",
208210
"Size": "Taille",
209211
"TypeDeleteToConfirm": "Tapez « Supprimer » pour confirmer.",
210212
"TypeFolderNameToDelete": "Tapez le nom du dossier ou du fichier à supprimer.",
213+
"UnsupportedFileFormat": "Ce format de fichier ne prend pas en charge l'édition.",
211214
"UploadFiles": "Télécharger des fichiers",
212215
"UploadFolder": "Dossier de téléchargement",
213216
"error": {

packages/backend.ai-ui/src/locale/id.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,17 +197,20 @@
197197
"DragAndDropDesc": "Seret dan jatuhkan file ke area ini untuk diunggah.",
198198
"DuplicatedFiles": "Timpa konfirmasi",
199199
"DuplicatedFilesDesc": "File atau folder dengan nama yang sama sudah ada. \nApakah Anda ingin menimpa?",
200+
"EditFile": "Edit File",
200201
"FolderCreatedSuccessfully": "Folder berhasil dibuat.",
201202
"FolderName": "Nama folder",
202203
"MaxFolderNameLength": "Nama folder harus 255 karakter atau kurang.",
203204
"ModifiedAt": "Dimodifikasi di",
205+
"MoreOptions": "Opsi lainnya",
204206
"Name": "Nama",
205207
"PleaseEnterAFolderName": "Harap masukkan nama folder.",
206208
"RenameSuccess": "Namanya telah berhasil diubah.",
207209
"SelectedItemsDeletedSuccessfully": "File dan folder yang dipilih telah berhasil dihapus.",
208210
"Size": "Ukuran",
209211
"TypeDeleteToConfirm": "Ketik 'Menghapus' untuk mengonfirmasi.",
210212
"TypeFolderNameToDelete": "Ketik folder atau nama file yang ingin dihapus.",
213+
"UnsupportedFileFormat": "Format file ini tidak mendukung pengeditan.",
211214
"UploadFiles": "Unggah file",
212215
"UploadFolder": "Unggah folder",
213216
"error": {

0 commit comments

Comments
 (0)