From 7fdf5e02d8da34581ef100d173c43458598f70a2 Mon Sep 17 00:00:00 2001 From: Pedro Almeida Date: Thu, 20 Aug 2026 17:48:08 +0100 Subject: [PATCH] feat(SRVOCF-953): add file and folder deletion to the edit page Adds a kebab menu action on each file tree entry that sends deleted entries to the GitHub Git Trees API with SHA=null, removing them from the repository on save. Adds backend, unit, and e2e tests for the deletion flow. --- backend/scm/client.go | 1 + backend/scm/github/client.go | 28 +++-- backend/scm/github/client_test.go | 63 ++++++++-- e2e/use-cases/edit/delete-file.test.ts | 101 ++++++++++++++++ src/common/types.ts | 1 + .../function-edit/FunctionEditPage.test.tsx | 110 +++++++++++++++++ src/pages/function-edit/FunctionEditPage.tsx | 50 ++++++-- .../components/FileTreeView.test.tsx | 67 +++++++++++ .../function-edit/components/FileTreeView.tsx | 112 ++++++++++++++++-- 9 files changed, 493 insertions(+), 40 deletions(-) create mode 100644 e2e/use-cases/edit/delete-file.test.ts diff --git a/backend/scm/client.go b/backend/scm/client.go index e36f6b21..8e0c4e2c 100644 --- a/backend/scm/client.go +++ b/backend/scm/client.go @@ -68,4 +68,5 @@ type FileEntry struct { Mode string `json:"mode"` // Git modes: "100644" regular, "100755" executable, "120000" symlink Content string `json:"content"` Type string `json:"type"` + Deleted bool `json:"deleted,omitempty"` } diff --git a/backend/scm/github/client.go b/backend/scm/github/client.go index b1a4bad7..876c6a7d 100644 --- a/backend/scm/github/client.go +++ b/backend/scm/github/client.go @@ -174,7 +174,7 @@ func (c *ghClient) PushFiles(ctx context.Context, owner, repo, branch, message s } parentTreeSHA := commit.GetTree().GetSHA() - treeEntries, err := c.createBlobs(ctx, owner, repo, files) + treeEntries, err := c.buildTreeEntries(ctx, owner, repo, files) if err != nil { return err } @@ -203,28 +203,32 @@ func (c *ghClient) PushFiles(ctx context.Context, owner, repo, branch, message s return nil } -func (c *ghClient) createBlobs(ctx context.Context, owner, repo string, files []scm.FileEntry) ([]*ghlib.TreeEntry, error) { +func (c *ghClient) buildTreeEntries(ctx context.Context, owner, repo string, files []scm.FileEntry) ([]*ghlib.TreeEntry, error) { entries := make([]*ghlib.TreeEntry, len(files)) g, ctx := errgroup.WithContext(ctx) g.SetLimit(10) for i, f := range files { g.Go(func() error { - blob, _, err := c.client.Git.CreateBlob(ctx, owner, repo, &ghlib.Blob{ - Content: new(f.Content), - Encoding: new("utf-8"), - }) - if err != nil { - return mapErr(err) - } - if blob.GetSHA() == "" { - return fmt.Errorf("GitHub returned empty blob SHA") + var sha *string + if !f.Deleted { + blob, _, err := c.client.Git.CreateBlob(ctx, owner, repo, &ghlib.Blob{ + Content: new(f.Content), + Encoding: new("utf-8"), + }) + if err != nil { + return mapErr(err) + } + if blob.GetSHA() == "" { + return fmt.Errorf("GitHub returned empty blob SHA") + } + sha = blob.SHA } mode := f.Mode entries[i] = &ghlib.TreeEntry{ Path: new(f.Path), Mode: &mode, Type: new("blob"), - SHA: blob.SHA, + SHA: sha, } return nil }) diff --git a/backend/scm/github/client_test.go b/backend/scm/github/client_test.go index d47fc35e..5a679e8a 100644 --- a/backend/scm/github/client_test.go +++ b/backend/scm/github/client_test.go @@ -227,7 +227,7 @@ var _ = Describe("GitHub SCM client", func() { Force bool `json:"force"` } - pushStub := func(failAt string, lastRequest *updateRefBody) http.HandlerFunc { + pushStub := func(failAt string, lastRequest *updateRefBody, blobCount *int, treeEntries *[]map[string]any) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, "/git/ref/"): @@ -250,6 +250,9 @@ var _ = Describe("GitHub SCM client", func() { json.NewEncoder(w).Encode(map[string]string{"message": "server error"}) return } + if blobCount != nil { + *blobCount++ + } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"sha": "blobsha"}) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/git/trees"): @@ -258,6 +261,13 @@ var _ = Describe("GitHub SCM client", func() { json.NewEncoder(w).Encode(map[string]string{"message": "server error"}) return } + if treeEntries != nil { + var body struct { + Tree []map[string]any `json:"tree"` + } + json.NewDecoder(r.Body).Decode(&body) + *treeEntries = body.Tree + } w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(map[string]string{"sha": "newtreesha"}) case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/git/commits"): @@ -289,46 +299,83 @@ var _ = Describe("GitHub SCM client", func() { It("commits all files and updates the ref to the new commit SHA", func() { var refUpdate updateRefBody - cl := newClient(pushStub("", &refUpdate)) + cl := newClient(pushStub("", &refUpdate, nil, nil)) Expect(pushFiles(cl)).To(Succeed()) Expect(refUpdate.SHA).To(Equal("newcommitsha")) }) It("returns an error when getting the branch ref fails", func() { - err := pushFiles(newClient(pushStub("getRef", nil))) + err := pushFiles(newClient(pushStub("getRef", nil, nil, nil))) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("get ref")) }) It("returns an error when getting the head commit fails", func() { - err := pushFiles(newClient(pushStub("getCommit", nil))) + err := pushFiles(newClient(pushStub("getCommit", nil, nil, nil))) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("get commit")) }) It("returns an error when creating a blob fails", func() { - err := pushFiles(newClient(pushStub("createBlob", nil))) + err := pushFiles(newClient(pushStub("createBlob", nil, nil, nil))) Expect(err).To(HaveOccurred()) }) It("returns an error when creating the tree fails", func() { - err := pushFiles(newClient(pushStub("createTree", nil))) + err := pushFiles(newClient(pushStub("createTree", nil, nil, nil))) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("create tree")) }) It("returns an error when creating the commit fails", func() { - err := pushFiles(newClient(pushStub("createCommit", nil))) + err := pushFiles(newClient(pushStub("createCommit", nil, nil, nil))) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("create commit")) }) It("returns an error when updating the ref fails", func() { - err := pushFiles(newClient(pushStub("updateRef", nil))) + err := pushFiles(newClient(pushStub("updateRef", nil, nil, nil))) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("update ref")) }) + + It("does not create a blob and sends a nil SHA for a deleted file", func() { + var blobCount int + var entries []map[string]any + files := []scm.FileEntry{{Path: "remove.go", Mode: "100644", Content: "", Type: "blob", Deleted: true}} + cl := newClient(pushStub("", nil, &blobCount, &entries)) + + Expect(cl.PushFiles(context.Background(), "alice", "my-func", "main", "Delete file", files)).To(Succeed()) + Expect(blobCount).To(Equal(0), "blob should not be created for a deleted file") + Expect(entries).To(HaveLen(1)) + Expect(entries[0]["path"]).To(Equal("remove.go")) + Expect(entries[0]["sha"]).To(BeNil()) + }) + + It("creates blobs only for non-deleted files in a mixed batch", func() { + var blobCount int + var entries []map[string]any + files := []scm.FileEntry{ + {Path: "keep.go", Mode: "100644", Content: "package main", Type: "blob"}, + {Path: "remove.go", Mode: "100644", Content: "", Type: "blob", Deleted: true}, + } + cl := newClient(pushStub("", nil, &blobCount, &entries)) + + Expect(cl.PushFiles(context.Background(), "alice", "my-func", "main", "Partial delete", files)).To(Succeed()) + Expect(blobCount).To(Equal(1), "only non-deleted files should create blobs") + Expect(entries).To(HaveLen(2)) + var keepSHA, removeSHA any + for _, e := range entries { + if e["path"] == "keep.go" { + keepSHA = e["sha"] + } else { + removeSHA = e["sha"] + } + } + Expect(keepSHA).NotTo(BeNil()) + Expect(removeSHA).To(BeNil()) + }) }) Describe("InitRepo", func() { diff --git a/e2e/use-cases/edit/delete-file.test.ts b/e2e/use-cases/edit/delete-file.test.ts new file mode 100644 index 00000000..7d805662 --- /dev/null +++ b/e2e/use-cases/edit/delete-file.test.ts @@ -0,0 +1,101 @@ +import { test, expect } from '../../fixtures/authenticated-page'; +import { navigateToEditPage } from '../../helpers/navigation'; +import { E2E_USER, PRESEEDED_FUNC_NAME } from '../../helpers/constants'; +import { seedRepo } from '../../helpers/fakegithub'; + +const BASE_FILES = [ + { + path: 'func.yaml', + mode: '100644', + content: `name: ${PRESEEDED_FUNC_NAME}\nruntime: node\nnamespace: default\n`, + }, + { + path: 'index.js', + mode: '100644', + content: 'module.exports = async (context) => context;', + }, +]; + +test.beforeEach(async () => { + await seedRepo( + E2E_USER, + PRESEEDED_FUNC_NAME, + 'main', + ['serverless-function'], + [ + ...BASE_FILES, + { path: 'delete-me.txt', mode: '100644', content: 'temporary file for deletion tests' }, + ], + ); +}); + +test.afterEach(async () => { + await seedRepo(E2E_USER, PRESEEDED_FUNC_NAME, 'main', ['serverless-function'], BASE_FILES); +}); + +test.describe('Delete file', () => { + test('user deletes a file and saves the changes', async ({ page }) => { + await test.step('navigate to edit page', async () => { + await navigateToEditPage(page, PRESEEDED_FUNC_NAME); + const tree = page.getByRole('tree', { name: 'File tree' }); + await expect(tree.getByText('delete-me.txt')).toBeVisible({ timeout: 15_000 }); + }); + + await test.step('verify save button is disabled before changes', async () => { + await expect(page.getByRole('button', { name: 'Save & Deploy' })).toBeDisabled(); + }); + + await test.step('hover to reveal action button and delete the file', async () => { + const tree = page.getByRole('tree', { name: 'File tree' }); + await tree.getByText('delete-me.txt', { exact: true }).hover(); + await page.getByRole('button', { name: 'delete-me.txt actions' }).click(); + await page.getByRole('menuitem', { name: 'Delete File' }).click(); + }); + + await test.step('verify delete-me.txt is removed from the tree', async () => { + const tree = page.getByRole('tree', { name: 'File tree' }); + await expect(tree.getByText('delete-me.txt')).not.toBeVisible(); + }); + + await test.step('verify save button is enabled after deletion', async () => { + await expect(page.getByRole('button', { name: 'Save & Deploy' })).toBeEnabled(); + }); + + await test.step('save and verify success', async () => { + await page.getByRole('button', { name: 'Save & Deploy' }).click(); + + await expect(page.getByText('Pushed to GitHub. Deployment running...')).toBeVisible({ + timeout: 10_000, + }); + + await expect(page.getByRole('button', { name: 'Save & Deploy' })).toBeDisabled({ + timeout: 5_000, + }); + }); + }); + + test('deleting the selected file clears the editor', async ({ page }) => { + await test.step('navigate to edit page and select delete-me.txt', async () => { + await navigateToEditPage(page, PRESEEDED_FUNC_NAME); + const tree = page.getByRole('tree', { name: 'File tree' }); + await expect(tree.getByText('delete-me.txt')).toBeVisible({ timeout: 15_000 }); + await tree.getByText('delete-me.txt', { exact: true }).click(); + await expect(page.locator('.monaco-editor').first()).toContainText('temporary file', { + timeout: 5_000, + }); + }); + + await test.step('delete the selected file', async () => { + const tree = page.getByRole('tree', { name: 'File tree' }); + await tree.getByText('delete-me.txt', { exact: true }).hover(); + await page.getByRole('button', { name: 'delete-me.txt actions' }).click(); + await page.getByRole('menuitem', { name: 'Delete File' }).click(); + }); + + await test.step('verify the editor empty state is shown', async () => { + await expect(page.getByRole('heading', { name: 'Start editing' })).toBeVisible({ + timeout: 5_000, + }); + }); + }); +}); diff --git a/src/common/types.ts b/src/common/types.ts index d3036f71..0920aada 100644 --- a/src/common/types.ts +++ b/src/common/types.ts @@ -9,6 +9,7 @@ export interface FileEntry { mode: '100644' | '100755' | '120000'; content: string; type: 'blob'; + deleted?: boolean; } export type EnvVarSource = 'value' | 'secret' | 'configMap'; diff --git a/src/pages/function-edit/FunctionEditPage.test.tsx b/src/pages/function-edit/FunctionEditPage.test.tsx index 2e5e3e42..eee4b678 100644 --- a/src/pages/function-edit/FunctionEditPage.test.tsx +++ b/src/pages/function-edit/FunctionEditPage.test.tsx @@ -431,6 +431,116 @@ describe('FunctionEditPage', () => { vi.useRealTimers(); }); + + it('deletes a file from the tree when Delete File is clicked', async () => { + setupFetchHandlers(); + + renderEditPage('my-func'); + + await waitFor(() => { + expect(screen.getByText('func.yaml')).toBeInTheDocument(); + }); + + await userEvent.setup().click(screen.getByLabelText('func.yaml actions')); + await userEvent.setup().click(screen.getByRole('menuitem', { name: 'Delete File' })); + + await waitFor(() => { + expect(screen.queryByText('func.yaml')).not.toBeInTheDocument(); + }); + }); + + it('enables save button after deleting a file', async () => { + setupFetchHandlers(); + + renderEditPage('my-func'); + + await waitFor(() => { + expect(screen.getByText('func.yaml')).toBeInTheDocument(); + }); + + expect(screen.getByRole('button', { name: /Save & Deploy/ })).toBeDisabled(); + + await userEvent.setup().click(screen.getByLabelText('func.yaml actions')); + await userEvent.setup().click(screen.getByRole('menuitem', { name: 'Delete File' })); + + expect(screen.getByRole('button', { name: /Save & Deploy/ })).toBeEnabled(); + }); + + it('clears the editor when the selected file is deleted', async () => { + setupFetchHandlers(); + + renderEditPage('my-func'); + + await waitFor(() => { + expect(screen.getByText('func.yaml')).toBeInTheDocument(); + }); + + await userEvent.setup().click(screen.getByText('func.yaml')); + + await waitFor(() => { + expect(screen.getByTestId('code-editor')).toHaveTextContent('name: my-func'); + }); + + await userEvent.setup().click(screen.getByLabelText('func.yaml actions')); + await userEvent.setup().click(screen.getByRole('menuitem', { name: 'Delete File' })); + + await waitFor(() => { + expect(screen.getByText('Start editing')).toBeInTheDocument(); + }); + }); + + it('includes deleted files with deleted:true in the PUT request body', async () => { + setupFetchHandlers(); + const putHandler = vi.fn(); + server.use( + http.put(`${BACKEND_API}/api/v1/func/twoGiants/my-func/files`, async ({ request }) => { + putHandler(await request.json()); + return new HttpResponse(null, { status: 204 }); + }), + ); + + renderEditPage('my-func'); + + await waitFor(() => { + expect(screen.getByText('func.yaml')).toBeInTheDocument(); + }); + + await userEvent.setup().click(screen.getByLabelText('func.yaml actions')); + await userEvent.setup().click(screen.getByRole('menuitem', { name: 'Delete File' })); + + await userEvent.setup().click(screen.getByRole('button', { name: /Save & Deploy/ })); + + await waitFor(() => { + expect(putHandler).toHaveBeenCalled(); + }); + + const body = putHandler.mock.calls[0][0]; + const deletedEntry = body.files.find((f: { path: string }) => f.path === 'func.yaml'); + expect(deletedEntry).toBeDefined(); + expect(deletedEntry.deleted).toBe(true); + }); + + it('resets deleted files after a successful save', async () => { + setupFetchHandlers(); + setupPutHandler(); + + renderEditPage('my-func'); + + await waitFor(() => { + expect(screen.getByText('func.yaml')).toBeInTheDocument(); + }); + + await userEvent.setup().click(screen.getByLabelText('func.yaml actions')); + await userEvent.setup().click(screen.getByRole('menuitem', { name: 'Delete File' })); + + expect(screen.getByRole('button', { name: /Save & Deploy/ })).toBeEnabled(); + + await userEvent.setup().click(screen.getByRole('button', { name: /Save & Deploy/ })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Save & Deploy/ })).toBeDisabled(); + }); + }); }); function setupPutHandler() { diff --git a/src/pages/function-edit/FunctionEditPage.tsx b/src/pages/function-edit/FunctionEditPage.tsx index 933b6d42..1cfdda9c 100644 --- a/src/pages/function-edit/FunctionEditPage.tsx +++ b/src/pages/function-edit/FunctionEditPage.tsx @@ -13,7 +13,7 @@ import { SidebarPanel, } from '@patternfly/react-core'; import { CodeIcon } from '@patternfly/react-icons'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router'; import { EditToolbar } from './components/EditToolbar'; @@ -72,6 +72,7 @@ function FunctionEditPageContent() { dirtyPaths={state.dirtyPaths} isLoading={state.isLoading} onSelect={state.onFileSelect} + onDelete={state.onFileDelete} /> @@ -119,6 +120,7 @@ interface FunctionEditPageState { repoInfo: FunctionListItem | undefined; onFileSelect: (path: string) => void; onFileChange: (content: string) => void; + onFileDelete: (path: string) => void; saveFiles: () => Promise; } @@ -127,17 +129,28 @@ function useFunctionEditPage(): FunctionEditPageState { const [files, setFiles] = useState([]); const [originalFiles, setOriginalFiles] = useState([]); + const [deletedFiles, setDeletedFiles] = useState([]); const [repoInfo, setRepoInfo] = useState(); const [selectedPath, setSelectedPath] = useState(''); const [isLoading, setIsLoading] = useState(true); - const dirtyFiles = new Set( - files - // Guard: originalFiles is empty during initial render before fetch completes. - .filter((f, i) => originalFiles[i] && f.content !== originalFiles[i].content) - .map((f) => f.path), + const originalByPath = useMemo( + () => new Map(originalFiles.map((f) => [f.path, f])), + [originalFiles], ); - const hasChanges = dirtyFiles.size > 0; + const dirtyFiles = useMemo( + () => + new Set( + files + .filter((f) => { + const orig = originalByPath.get(f.path); + return orig && f.content !== orig.content; + }) + .map((f) => f.path), + ), + [files, originalByPath], + ); + const hasChanges = dirtyFiles.size > 0 || deletedFiles.length > 0; const selectedFile = files.find((f) => f.path === selectedPath); const selectedContent = selectedFile?.content ?? ''; @@ -183,16 +196,36 @@ function useFunctionEditPage(): FunctionEditPageState { setFiles((prev) => prev.map((f) => (f.path === selectedPath ? { ...f, content } : f))); }; + const onFileDelete = useCallback( + (path: string) => { + const dirPrefix = path + '/'; + const isDir = files.some((f) => f.path.startsWith(dirPrefix)); + const toDelete = isDir + ? files.filter((f) => f.path.startsWith(dirPrefix)) + : files.filter((f) => f.path === path); + if (toDelete.length === 0) return; + const pathsToDelete = new Set(toDelete.map((f) => f.path)); + setFiles((prev) => prev.filter((f) => !pathsToDelete.has(f.path))); + setDeletedFiles((prev) => [ + ...prev, + ...toDelete.map((f) => ({ ...f, deleted: true as const })), + ]); + if (isDir ? selectedPath?.startsWith(dirPrefix) : selectedPath === path) setSelectedPath(''); + }, + [files, selectedPath], + ); + const saveFiles = async () => { if (!repoInfo) return; await putFiles( repoInfo.owner, repoInfo.repoName, - files, + [...files, ...deletedFiles], 'Update function files', repoInfo.defaultBranch, ); setOriginalFiles(files.map((f) => ({ ...f }))); + setDeletedFiles([]); }; return { @@ -206,6 +239,7 @@ function useFunctionEditPage(): FunctionEditPageState { repoInfo, onFileSelect, onFileChange, + onFileDelete, saveFiles, }; } diff --git a/src/pages/function-edit/components/FileTreeView.test.tsx b/src/pages/function-edit/components/FileTreeView.test.tsx index 639596fd..76a24531 100644 --- a/src/pages/function-edit/components/FileTreeView.test.tsx +++ b/src/pages/function-edit/components/FileTreeView.test.tsx @@ -204,4 +204,71 @@ describe('FileTreeView', () => { expect(screen.getByText(/func\.yaml \u25CF/)).toBeInTheDocument(); expect(screen.queryByText(/index\.js \u25CF/)).not.toBeInTheDocument(); }); + + it('does not render action buttons when onDelete is not provided', () => { + render( + , + ); + + expect(screen.queryByLabelText(/actions/i)).not.toBeInTheDocument(); + }); + + it('renders an action button for each tree item when onDelete is provided', () => { + render( + , + ); + + expect(screen.getAllByLabelText(/actions/i).length).toBeGreaterThan(0); + }); + + it('calls onDelete with the file path when Delete File is clicked', async () => { + const user = userEvent.setup(); + const onDelete = vi.fn(); + + render( + , + ); + + await user.click(screen.getByLabelText('func.yaml actions')); + await user.click(screen.getByRole('menuitem', { name: 'Delete File' })); + + expect(onDelete).toHaveBeenCalledWith('func.yaml'); + }); + + it('calls onDelete with the folder path when Delete Folder is clicked', async () => { + const user = userEvent.setup(); + const onDelete = vi.fn(); + + render( + , + ); + + await user.click(screen.getByLabelText('test actions')); + await user.click(screen.getByRole('menuitem', { name: 'Delete Folder' })); + + expect(onDelete).toHaveBeenCalledWith('test'); + }); }); diff --git a/src/pages/function-edit/components/FileTreeView.tsx b/src/pages/function-edit/components/FileTreeView.tsx index 2f6ec404..8ed79bb0 100644 --- a/src/pages/function-edit/components/FileTreeView.tsx +++ b/src/pages/function-edit/components/FileTreeView.tsx @@ -1,9 +1,22 @@ -import { useMemo } from 'react'; -import { Spinner, TreeView, TreeViewDataItem } from '@patternfly/react-core'; -import { FileIcon, FolderIcon, FolderOpenIcon } from '@patternfly/react-icons'; +import { useMemo, useState, useContext, createContext } from 'react'; +import { + Dropdown, + DropdownItem, + DropdownList, + MenuToggle, + Spinner, + TreeView, + TreeViewDataItem, +} from '@patternfly/react-core'; +import { EllipsisVIcon, FileIcon, FolderIcon, FolderOpenIcon } from '@patternfly/react-icons'; import { FileEntry } from '../../../common/types'; import * as React from 'react'; +const OpenMenuContext = createContext<{ + openPath: string | null; + setOpenPath: (path: string | null) => void; +}>({ openPath: null, setOpenPath: () => {} }); + const emptyTreeData: TreeViewDataItem[] = [{ id: '__empty__', name: 'No files' }]; const loadingTreeData: TreeViewDataItem[] = [ { @@ -22,6 +35,7 @@ interface FileTreeViewProps { dirtyPaths: Set; isLoading?: boolean; onSelect: (path: string) => void; + onDelete?: (path: string) => void; } export const FileTreeView = React.memo(function FileTreeView({ @@ -30,23 +44,35 @@ export const FileTreeView = React.memo(function FileTreeView({ dirtyPaths, isLoading = false, onSelect, + onDelete, }: FileTreeViewProps) { + const [openMenuPath, setOpenMenuPath] = useState(null); const { treeData, activeItems, handleSelect, selectable } = useFileTreeView( files, selectedPath, dirtyPaths, isLoading, onSelect, + onDelete, ); return ( - + + + + ); }); @@ -58,13 +84,17 @@ function useFileTreeView( dirtyPaths: Set, isLoading: boolean = false, onSelect: (path: string) => void, + onDelete?: (path: string) => void, ): { treeData: TreeViewDataItem[]; activeItems: TreeViewDataItem[]; handleSelect: (_: React.MouseEvent, item: TreeViewDataItem) => void; selectable: boolean; } { - const treeData = useMemo(() => buildFileTree(files, dirtyPaths), [files, dirtyPaths]); + const treeData = useMemo( + () => buildFileTree(files, dirtyPaths, onDelete), + [files, dirtyPaths, onDelete], + ); const activeItems = useMemo(() => { if (!selectedPath) return []; @@ -88,6 +118,57 @@ function useFileTreeView( } // --- helpers --- + +function TreeItemMenu({ + path, + label, + isDir, + onDelete, +}: { + path: string; + label: string; + isDir: boolean; + onDelete: (path: string) => void; +}) { + const { openPath, setOpenPath } = useContext(OpenMenuContext); + const isOpen = openPath === path; + return ( + setOpenPath(open ? path : null)} + popperProps={{ position: 'right' }} + toggle={(toggleRef) => ( + { + e.stopPropagation(); + setOpenPath(isOpen ? null : path); + }} + isExpanded={isOpen} + > + + + )} + > + + { + e.stopPropagation(); + setOpenPath(null); + onDelete(path); + }} + > + {isDir ? 'Delete Folder' : 'Delete File'} + + + + ); +} + function findItemByPath(items: TreeViewDataItem[], path: string): TreeViewDataItem[] { for (const item of items) { if (item.id === path) return [item]; @@ -99,7 +180,11 @@ function findItemByPath(items: TreeViewDataItem[], path: string): TreeViewDataIt return []; } -function buildFileTree(files: FileEntry[], dirtyPaths: Set): TreeViewDataItem[] { +function buildFileTree( + files: FileEntry[], + dirtyPaths: Set, + onDelete?: (path: string) => void, +): TreeViewDataItem[] { const root: TreeViewDataItem[] = []; for (const file of files) { @@ -146,6 +231,9 @@ function buildFileTree(files: FileEntry[], dirtyPaths: Set): TreeViewDat id, name: dirtyPaths.has(id) ? `${name} \u25CF` : name, defaultExpanded: true, + action: onDelete ? ( + + ) : undefined, }; if (isDir) { item.children = [];