Skip to content

Commit 6db2c5e

Browse files
author
Idan Attias
committed
feat(tui): add bucket and object deletion with confirmation
This change implements the ability to delete GCS buckets, objects, and directories (prefixes) directly from the TUI. A confirmation dialog has been added to prevent accidental deletions, especially for directories where the deletion is performed recursively. Summary of changes: - Added DeleteBucket, DeleteObject, and DeletePrefix to GCS client - Added viewDeleteConfirm state and confirmation view in TUI - Implemented 'x' keybinding for deletion in buckets and objects views - Added unit tests for GCS client deletion methods - Added integration tests for TUI deletion workflow
1 parent c89e8e3 commit 6db2c5e

File tree

13 files changed

+438
-28
lines changed

13 files changed

+438
-28
lines changed

internal/gcs/client.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,41 @@ func (c *Client) ListBucketsPage(ctx context.Context, projectID string, pageToke
539539
return buckets, nextToken, nil
540540
}
541541

542+
// DeleteBucket deletes a GCS bucket.
543+
func (c *Client) DeleteBucket(ctx context.Context, bucketName string) error {
544+
if err := c.storageClient.Bucket(bucketName).Delete(ctx); err != nil {
545+
return fmt.Errorf("failed to delete bucket %q: %w", bucketName, err)
546+
}
547+
return nil
548+
}
549+
550+
// DeleteObject deletes a specific GCS object.
551+
func (c *Client) DeleteObject(ctx context.Context, bucketName, objectName string) error {
552+
if err := c.storageClient.Bucket(bucketName).Object(objectName).Delete(ctx); err != nil {
553+
return fmt.Errorf("failed to delete object %q in %q: %w", objectName, bucketName, err)
554+
}
555+
return nil
556+
}
557+
558+
// DeletePrefix deletes all objects under a prefix.
559+
func (c *Client) DeletePrefix(ctx context.Context, bucketName, prefix string) error {
560+
it := c.storageClient.Bucket(bucketName).Objects(ctx, &storage.Query{Prefix: prefix})
561+
for {
562+
attrs, err := it.Next()
563+
if errors.Is(err, iterator.Done) {
564+
break
565+
}
566+
if err != nil {
567+
return fmt.Errorf("failed to list objects for prefix %q in %q: %w", prefix, bucketName, err)
568+
}
569+
570+
if err := c.storageClient.Bucket(bucketName).Object(attrs.Name).Delete(ctx); err != nil {
571+
return fmt.Errorf("failed to delete object %q in %q: %w", attrs.Name, bucketName, err)
572+
}
573+
}
574+
return nil
575+
}
576+
542577
// ListObjectsPage retrieves a specific page of object names and common prefixes (folders).
543578
func (c *Client) ListObjectsPage(ctx context.Context, bucketName, prefix, pageToken string, pageSize int) (*ObjectList, string, error) {
544579
it := c.storageClient.Bucket(bucketName).Objects(ctx, &storage.Query{

internal/gcs/client_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -681,3 +681,72 @@ func TestClient_DownloadPrefixAsZip_ErrorsAndProgress(t *testing.T) {
681681
assert.Equal(t, currentProgress, totalProgress)
682682
})
683683
}
684+
685+
func TestClient_DeleteBucket(t *testing.T) {
686+
bucketName := "delete-bucket"
687+
server, client := setupTestServer(t, []fakestorage.Object{})
688+
server.CreateBucketWithOpts(fakestorage.CreateBucketOpts{Name: bucketName})
689+
690+
t.Run("Success", func(t *testing.T) {
691+
err := client.DeleteBucket(context.Background(), bucketName)
692+
assert.NilError(t, err)
693+
694+
_, err = client.GetBucketMetadata(context.Background(), bucketName)
695+
assert.Assert(t, err != nil)
696+
})
697+
698+
t.Run("Non-existent bucket", func(t *testing.T) {
699+
err := client.DeleteBucket(context.Background(), "no-bucket")
700+
assert.Assert(t, err != nil)
701+
})
702+
}
703+
704+
func TestClient_DeleteObject(t *testing.T) {
705+
bucketName := "obj-delete-bucket"
706+
objectName := "delete-me.txt"
707+
_, client := setupTestServer(t, []fakestorage.Object{
708+
{ObjectAttrs: fakestorage.ObjectAttrs{BucketName: bucketName, Name: objectName}},
709+
})
710+
711+
t.Run("Success", func(t *testing.T) {
712+
err := client.DeleteObject(context.Background(), bucketName, objectName)
713+
assert.NilError(t, err)
714+
715+
_, err = client.GetObjectMetadata(context.Background(), bucketName, objectName)
716+
assert.Assert(t, err != nil)
717+
})
718+
719+
t.Run("Non-existent object", func(t *testing.T) {
720+
err := client.DeleteObject(context.Background(), bucketName, "no-obj")
721+
assert.Assert(t, err != nil)
722+
})
723+
}
724+
725+
func TestClient_DeletePrefix(t *testing.T) {
726+
bucketName := "prefix-delete-bucket"
727+
objects := []fakestorage.Object{
728+
{ObjectAttrs: fakestorage.ObjectAttrs{BucketName: bucketName, Name: "folder/file1.txt"}},
729+
{ObjectAttrs: fakestorage.ObjectAttrs{BucketName: bucketName, Name: "folder/sub/file2.txt"}},
730+
{ObjectAttrs: fakestorage.ObjectAttrs{BucketName: bucketName, Name: "other.txt"}},
731+
}
732+
server, client := setupTestServer(t, objects)
733+
734+
t.Run("Success", func(t *testing.T) {
735+
err := client.DeletePrefix(context.Background(), bucketName, "folder/")
736+
assert.NilError(t, err)
737+
738+
// folder objects should be gone
739+
it := server.Client().Bucket(bucketName).Objects(context.Background(), nil)
740+
count := 0
741+
for {
742+
attrs, err := it.Next()
743+
if errors.Is(err, iterator.Done) {
744+
break
745+
}
746+
assert.NilError(t, err)
747+
assert.Assert(t, attrs.Name == "other.txt")
748+
count++
749+
}
750+
assert.Equal(t, count, 1)
751+
})
752+
}

internal/testutil/testutil.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func CreateConfigFile(t *testing.T, projects []string, downloadDir string) strin
7979
}
8080

8181
// SetupTestApp initializes the full TUI application using a fake GCS server.
82-
func SetupTestApp(t *testing.T, initialObjects []fakestorage.Object, port uint16, projectIDs []string, downloadDir string) *teatest.TestModel {
82+
func SetupTestApp(t *testing.T, initialObjects []fakestorage.Object, port uint16, projectIDs []string, downloadDir string) (*teatest.TestModel, *fakestorage.Server) {
8383
t.Helper()
8484

8585
// Ensure tests produce deterministic colored output regardless of environment
@@ -112,7 +112,7 @@ func SetupTestApp(t *testing.T, initialObjects []fakestorage.Object, port uint16
112112
tm.WaitFinished(t, teatest.WithFinalTimeout(time.Second))
113113
})
114114

115-
return tm
115+
return tm, server
116116
}
117117

118118
// CreateMockTar creates a tar archive in memory with the given files map (name -> content).

internal/tui/commands.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,27 @@ func (m *Model) createObject(bucketName, objectName string) tea.Cmd {
254254
}
255255
}
256256

257+
func (m *Model) deleteBucket(bucketName string) tea.Cmd {
258+
return func() tea.Msg {
259+
err := m.client.DeleteBucket(context.Background(), bucketName)
260+
return DeleteMsg{Name: bucketName, Err: err}
261+
}
262+
}
263+
264+
func (m *Model) deleteObject(bucketName, objectName string) tea.Cmd {
265+
return func() tea.Msg {
266+
err := m.client.DeleteObject(context.Background(), bucketName, objectName)
267+
return DeleteMsg{Name: objectName, Err: err}
268+
}
269+
}
270+
271+
func (m *Model) deletePrefix(bucketName, prefix string) tea.Cmd {
272+
return func() tea.Msg {
273+
err := m.client.DeletePrefix(context.Background(), bucketName, prefix)
274+
return DeleteMsg{Name: prefix, Err: err}
275+
}
276+
}
277+
257278
func (m *Model) startDownloadTaskDirectly(task downloadTask) (*Model, tea.Cmd) {
258279
jobNum := task.jobNum
259280
m.activeDownloads++

internal/tui/keys.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type keyMap struct {
2323
Refresh key.Binding
2424
Search key.Binding
2525
Create key.Binding
26+
Delete key.Binding
2627
Esc key.Binding
2728
Help key.Binding
2829
Messages key.Binding
@@ -49,6 +50,7 @@ func (k keyMap) OrderedHelp() []key.Binding {
4950
k.Open,
5051
k.Edit,
5152
k.Create,
53+
k.Delete,
5254
k.Search,
5355
k.Refresh,
5456
k.Esc,
@@ -70,10 +72,10 @@ func (k keyMap) OrderedHelp() []key.Binding {
7072
// FullHelp returns keybindings for the expanded help view.
7173
func (k keyMap) FullHelp() [][]key.Binding {
7274
return [][]key.Binding{
73-
{k.Up, k.Down, k.Left, k.Right, k.Home}, // Navigation
74-
{k.Top, k.Bottom, k.PageUp, k.PageDown, k.HalfPageUp, k.HalfPageDown}, // Pagination
75-
{k.Select, k.Download, k.Copy, k.Open, k.Edit, k.Create, k.Refresh, k.Search}, // Actions
76-
{k.Esc, k.Info, k.Versions, k.Messages, k.Help, k.Quit}, // App
75+
{k.Up, k.Down, k.Left, k.Right, k.Home}, // Navigation
76+
{k.Top, k.Bottom, k.PageUp, k.PageDown, k.HalfPageUp, k.HalfPageDown}, // Pagination
77+
{k.Select, k.Download, k.Copy, k.Open, k.Edit, k.Create, k.Delete, k.Refresh, k.Search}, // Actions
78+
{k.Esc, k.Info, k.Versions, k.Messages, k.Help, k.Quit}, // App
7779
}
7880
}
7981

@@ -150,6 +152,10 @@ var keys = keyMap{
150152
key.WithKeys("n"),
151153
key.WithHelp("n", "new"),
152154
),
155+
Delete: key.NewBinding(
156+
key.WithKeys("x"),
157+
key.WithHelp("x", "delete"),
158+
),
153159
Search: key.NewBinding(
154160
key.WithKeys("/"),
155161
key.WithHelp("/", "filter"),

internal/tui/model.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const (
1919
viewBuckets viewState = iota
2020
viewObjects
2121
viewDownloadConfirm
22+
viewDeleteConfirm
2223
)
2324

2425
type downloadTask struct {
@@ -77,6 +78,12 @@ type Model struct {
7778
downloadQueue []downloadTask
7879
jobProgress map[int]*JobProgress
7980

81+
// Deletion State
82+
pendingDeleteBucket string
83+
pendingDeleteObject string
84+
pendingDeletePrefix string
85+
pendingDeleteIsBucket bool
86+
8087
// Buckets View
8188
projects []gcs.ProjectBuckets
8289
collapsedProjects map[string]struct{}

internal/tui/model_test.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,18 @@ func (m *mockGCSClient) UploadObject(_ context.Context, bucket, object, src stri
130130
return nil
131131
}
132132

133+
func (m *mockGCSClient) DeleteBucket(_ context.Context, _ string) error {
134+
return nil
135+
}
136+
137+
func (m *mockGCSClient) DeleteObject(_ context.Context, _, _ string) error {
138+
return nil
139+
}
140+
141+
func (m *mockGCSClient) DeletePrefix(_ context.Context, _, _ string) error {
142+
return nil
143+
}
144+
133145
func (m *mockGCSClient) DownloadPrefixAsZip(_ context.Context, _, _, _ string, _ gcs.ProgressFunc) error {
134146
return nil
135147
}

internal/tui/types.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ type GCSClient interface {
4040
NewReader(ctx context.Context, bucketName, objectName string) (io.ReadCloser, error)
4141
// NewReaderAt returns an io.ReaderAt for an object.
4242
NewReaderAt(ctx context.Context, bucketName, objectName string) io.ReaderAt
43+
// DeleteBucket deletes a GCS bucket.
44+
DeleteBucket(ctx context.Context, bucketName string) error
45+
// DeleteObject deletes a specific GCS object.
46+
DeleteObject(ctx context.Context, bucketName, objectName string) error
47+
// DeletePrefix deletes all objects under a prefix.
48+
DeletePrefix(ctx context.Context, bucketName, prefix string) error
4349
// ListObjectVersions retrieves all versions of a specific object.
4450
ListObjectVersions(ctx context.Context, bucketName, objectName string) ([]gcs.ObjectMetadata, error)
4551
// IsVersioningEnabled checks if versioning is enabled for a specific bucket.
@@ -154,6 +160,12 @@ type CreateMsg struct {
154160
Err error
155161
}
156162

163+
// DeleteMsg is sent when a deletion operation completes.
164+
type DeleteMsg struct {
165+
Name string
166+
Err error
167+
}
168+
157169
// ClearStatusMsg is sent to clear the status bar.
158170
type ClearStatusMsg struct {
159171
ID string

internal/tui/update.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5050
return m.handleObjectVersionsMsg(msg)
5151
case CreateMsg:
5252
return m.handleCreateMsg(msg)
53+
case DeleteMsg:
54+
return m.handleDeleteMsg(msg)
5355
case DebouncePreviewMsg:
5456
if msg.CursorVersion == m.cursorVersion {
5557
return m, msg.FetchCmd
@@ -652,6 +654,10 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
652654
return m.handleDownloadConfirmKey(msg)
653655
}
654656

657+
if m.state == viewDeleteConfirm {
658+
return m.handleDeleteConfirmKey(msg)
659+
}
660+
655661
switch {
656662
case key.Matches(msg, keys.Help):
657663
m.showHelp = true
@@ -702,6 +708,9 @@ func (m *Model) handleKeyMsg(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
702708
case key.Matches(msg, keys.Create):
703709
return m.handleCreateKey()
704710

711+
case key.Matches(msg, keys.Delete):
712+
return m.handleDeleteKey()
713+
705714
case key.Matches(msg, keys.Select):
706715
return m.handleSelectKey()
707716

@@ -1642,3 +1651,87 @@ func (m *Model) handleCreateMsg(msg CreateMsg) (tea.Model, tea.Cmd) {
16421651
nm, refreshCmd := m.handleRefreshKey(true)
16431652
return nm, tea.Batch(statusCmd, refreshCmd)
16441653
}
1654+
1655+
func (m *Model) handleDeleteKey() (tea.Model, tea.Cmd) {
1656+
switch m.state {
1657+
case viewBuckets:
1658+
filtered := m.filteredBuckets()
1659+
if m.cursor < 0 || m.cursor >= len(filtered) {
1660+
return m, nil
1661+
}
1662+
item := filtered[m.cursor]
1663+
if item.IsProject {
1664+
return m, nil // Cannot delete projects
1665+
}
1666+
m.pendingDeleteBucket = item.BucketName
1667+
m.pendingDeleteIsBucket = true
1668+
m.state = viewDeleteConfirm
1669+
return m, nil
1670+
1671+
case viewObjects:
1672+
if len(m.selected) > 1 {
1673+
return m, m.AddMessage(LevelError, "Multi-delete not supported yet", 0, "")
1674+
}
1675+
1676+
currentPrefixes, currentObjects, _ := m.filteredObjects()
1677+
if m.cursor < 0 || m.cursor >= len(currentPrefixes)+len(currentObjects) {
1678+
return m, nil
1679+
}
1680+
1681+
m.pendingDeleteBucket = m.currentBucket
1682+
m.pendingDeleteIsBucket = false
1683+
if m.cursor < len(currentPrefixes) {
1684+
m.pendingDeletePrefix = currentPrefixes[m.cursor].Name
1685+
m.pendingDeleteObject = ""
1686+
} else {
1687+
m.pendingDeleteObject = currentObjects[m.cursor-len(currentPrefixes)].Name
1688+
m.pendingDeletePrefix = ""
1689+
}
1690+
m.state = viewDeleteConfirm
1691+
return m, nil
1692+
}
1693+
return m, nil
1694+
}
1695+
1696+
func (m *Model) handleDeleteConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
1697+
switch msg.String() {
1698+
case "y", "Y":
1699+
var cmd tea.Cmd
1700+
if m.pendingDeleteIsBucket {
1701+
cmd = m.deleteBucket(m.pendingDeleteBucket)
1702+
} else if m.pendingDeletePrefix != "" {
1703+
cmd = m.deletePrefix(m.pendingDeleteBucket, m.pendingDeletePrefix)
1704+
} else {
1705+
cmd = m.deleteObject(m.pendingDeleteBucket, m.pendingDeleteObject)
1706+
}
1707+
m.state = viewObjects
1708+
if m.pendingDeleteIsBucket {
1709+
m.state = viewBuckets
1710+
}
1711+
m.pendingDeleteBucket = ""
1712+
m.pendingDeleteObject = ""
1713+
m.pendingDeletePrefix = ""
1714+
return m, cmd
1715+
default:
1716+
if m.pendingDeleteIsBucket {
1717+
m.state = viewBuckets
1718+
} else {
1719+
m.state = viewObjects
1720+
}
1721+
m.pendingDeleteBucket = ""
1722+
m.pendingDeleteObject = ""
1723+
m.pendingDeletePrefix = ""
1724+
return m, nil
1725+
}
1726+
}
1727+
1728+
func (m *Model) handleDeleteMsg(msg DeleteMsg) (tea.Model, tea.Cmd) {
1729+
if msg.Err != nil {
1730+
statusCmd := m.AddMessage(LevelError, fmt.Sprintf("Deletion failed: %v", msg.Err), 0, "")
1731+
return m, statusCmd
1732+
}
1733+
1734+
statusCmd := m.AddMessage(LevelInfo, fmt.Sprintf("Deleted %s", msg.Name), 0, "")
1735+
nm, refreshCmd := m.handleRefreshKey(true)
1736+
return nm, tea.Batch(statusCmd, refreshCmd)
1737+
}

0 commit comments

Comments
 (0)